mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 15:14:56 -04:00
Move toolbar functions into redux reducer (#1763)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
a6a2251c88
commit
b30cc5b390
80 changed files with 2481 additions and 2303 deletions
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
|||
- HedgeDoc instances can be branded either with a '@ \<custom string\>' or '@ \<custom logo\>' after the HedgeDoc logo and text
|
||||
- Images will be loaded via proxy if an image proxy is configured in the backend
|
||||
- The toolbar includes an emoji and fork-awesome icon picker.
|
||||
- Collapsable blocks can be added via a toolbar button or via autocompletion of "<details"
|
||||
- Collapsible blocks can be added via a toolbar button or via autocompletion of "<details"
|
||||
- Added shortcodes for [fork-awesome icons](https://forkaweso.me/Fork-Awesome/icons/) (e.g. `:fa-picture-o:`)
|
||||
- The code button now adds code fences even if the user selected nothing beforehand
|
||||
- Code blocks with 'csv' as language render as tables.
|
||||
|
|
|
@ -1,232 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
describe('Toolbar Buttons', () => {
|
||||
const testText = 'textText'
|
||||
const testLink = 'http://hedgedoc.org'
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visitTestEditor()
|
||||
|
||||
cy.get('.CodeMirror').click().get('textarea').as('codeinput')
|
||||
})
|
||||
|
||||
describe('for single line text', () => {
|
||||
beforeEach(() => {
|
||||
cy.setCodemirrorContent(testText)
|
||||
cy.get('.CodeMirror-line > span').should('exist').should('have.text', testText)
|
||||
})
|
||||
|
||||
describe('with selection', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('@codeinput').type('{ctrl}a')
|
||||
})
|
||||
|
||||
it('should format as bold', () => {
|
||||
cy.getByCypressId('format-bold').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `**${testText}**`)
|
||||
})
|
||||
|
||||
it('should format as italic', () => {
|
||||
cy.getByCypressId('format-italic').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `*${testText}*`)
|
||||
})
|
||||
|
||||
it('should format as underline', () => {
|
||||
cy.getByCypressId('format-underline').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `++${testText}++`)
|
||||
})
|
||||
|
||||
it('should format as strikethrough', () => {
|
||||
cy.get('.btn-toolbar [data-cypress-id="format-strikethrough').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `~~${testText}~~`)
|
||||
})
|
||||
|
||||
it('should format as subscript', () => {
|
||||
cy.getByCypressId('format-subscript').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `~${testText}~`)
|
||||
})
|
||||
|
||||
it('should format as superscript', () => {
|
||||
cy.getByCypressId('format-superscript').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `^${testText}^`)
|
||||
})
|
||||
|
||||
it('should format the line as code block', () => {
|
||||
cy.getByCypressId('format-code-block').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', '```')
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should('have.text', testText)
|
||||
cy.get('.CodeMirror-code > div.CodeMirror-activeline > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
'```'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format links', () => {
|
||||
cy.getByCypressId('format-link').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `[${testText}](https://)`)
|
||||
})
|
||||
|
||||
it('should format as image', () => {
|
||||
cy.getByCypressId('format-image').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ``)
|
||||
})
|
||||
})
|
||||
|
||||
it('should format line as heading', () => {
|
||||
cy.getByCypressId('format-heading').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `# ${testText}`)
|
||||
cy.get('.fa-header').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `## ${testText}`)
|
||||
})
|
||||
|
||||
it('should format the line as code', () => {
|
||||
cy.getByCypressId('format-code-block').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', '```')
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should('have.text', testText)
|
||||
cy.get('.CodeMirror-code > div.CodeMirror-activeline > .CodeMirror-line > span span').should('have.text', '```')
|
||||
})
|
||||
|
||||
it('should add a quote', () => {
|
||||
cy.getByCypressId('format-block-quote').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `> ${testText}`)
|
||||
cy.getByCypressId('format-block-quote').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `> > ${testText}`)
|
||||
})
|
||||
|
||||
it('should format as unordered list', () => {
|
||||
cy.getByCypressId('format-unordered-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `- ${testText}`)
|
||||
cy.getByCypressId('format-unordered-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `- - ${testText}`)
|
||||
})
|
||||
|
||||
it('should format as ordered list', () => {
|
||||
cy.getByCypressId('format-ordered-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `1. ${testText}`)
|
||||
cy.getByCypressId('format-ordered-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `1. 1. ${testText}`)
|
||||
})
|
||||
|
||||
it('should format as check list', () => {
|
||||
cy.getByCypressId('format-check-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `- [ ] ${testText}`)
|
||||
cy.getByCypressId('format-check-list').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `- [ ] - [ ] ${testText}`)
|
||||
})
|
||||
|
||||
it('should insert links', () => {
|
||||
cy.getByCypressId('format-link').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `${testText}[](https://)`)
|
||||
})
|
||||
|
||||
it('should insert an empty image link', () => {
|
||||
cy.getByCypressId('format-image').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `${testText}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for single line link with selection', () => {
|
||||
beforeEach(() => {
|
||||
cy.setCodemirrorContent(testLink)
|
||||
cy.get('.CodeMirror-line > span').should('exist').should('have.text', testLink)
|
||||
cy.get('@codeinput').type('{ctrl}a')
|
||||
})
|
||||
|
||||
it('should format as link', () => {
|
||||
cy.getByCypressId('format-link').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `[](${testLink})`)
|
||||
})
|
||||
|
||||
it('should format as image', () => {
|
||||
cy.getByCypressId('format-image').click()
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ``)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for no text', () => {
|
||||
it('should add an empty code block', () => {
|
||||
cy.getByCypressId('format-code-block').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', '```')
|
||||
cy.get('.CodeMirror-code > div.CodeMirror-activeline > .CodeMirror-line > span span').should('have.text', '```')
|
||||
})
|
||||
|
||||
it('should insert lines', () => {
|
||||
cy.getByCypressId('format-add-line').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should('have.text', '----')
|
||||
})
|
||||
|
||||
it('should add a collapsable block', () => {
|
||||
cy.getByCypressId('format-collapsable-block').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
':::spoiler Toggle label'
|
||||
)
|
||||
})
|
||||
|
||||
it('should add a comment', () => {
|
||||
cy.getByCypressId('format-add-comment').click()
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should('have.text', '> []')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for new tables', () => {
|
||||
beforeEach(() => {
|
||||
cy.getByCypressId('table-size-picker-popover').should('not.exist')
|
||||
cy.getByCypressId('table-size-picker-button').last().click()
|
||||
cy.getByCypressId('table-size-picker-popover').should('be.visible')
|
||||
})
|
||||
|
||||
it('should select table size', () => {
|
||||
cy.getByCypressId('table-size-picker-popover')
|
||||
.find('[data-cypress-col=5][data-cypress-row=3]')
|
||||
.trigger('mouseover')
|
||||
cy.getByCypressId('table-size-picker-popover').find('[data-cypress-selected="true"]').should('have.length', 15)
|
||||
cy.getByCypressId('table-size-picker-popover').find('.popover-header').contains('5x3')
|
||||
cy.getByCypressId('table-size-picker-popover').find('[data-cypress-col=5][data-cypress-row=3]').click()
|
||||
})
|
||||
|
||||
it('should open a custom table size in the modal', () => {
|
||||
cy.getByCypressId('custom-table-size-modal').should('not.exist')
|
||||
cy.getByCypressId('show-custom-table-modal').first().click()
|
||||
cy.getByCypressId('custom-table-size-modal').should('be.visible')
|
||||
cy.getByCypressId('custom-table-size-modal').find('input').first().type('5')
|
||||
cy.getByCypressId('custom-table-size-modal').find('input').last().type('3')
|
||||
cy.getByCypressId('custom-table-size-modal').find('.modal-footer > button').click()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
'| # 1 | # 2 | # 3 | # 4 | # 5 |'
|
||||
)
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
'| ---- | ---- | ---- | ---- | ---- |'
|
||||
)
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(4) > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
'| Text | Text | Text | Text | Text |'
|
||||
)
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(5) > .CodeMirror-line > span span').should(
|
||||
'have.text',
|
||||
'| Text | Text | Text | Text | Text |'
|
||||
)
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span ').should(
|
||||
'have.text',
|
||||
'| Text | Text | Text | Text | Text |'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('for the emoji-picker', () => {
|
||||
it('should open overlay', () => {
|
||||
cy.get('emoji-picker').should('not.be.visible')
|
||||
cy.getByCypressId('show-emoji-picker').click()
|
||||
cy.get('emoji-picker').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -7,16 +7,19 @@ import nextJest from 'next/jest'
|
|||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
dir: './'
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
setupFilesAfterEnv: [
|
||||
'@testing-library/jest-dom/extend-expect'
|
||||
],
|
||||
moduleNameMapper: {
|
||||
// Handle module aliases (this will be automatically configured for you soon)
|
||||
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||
'^@/components/(.*)$': '<rootDir>/src/components/$1',
|
||||
},
|
||||
roots: ["<rootDir>/src"],
|
||||
testPathIgnorePatterns: ["/node_modules/", "/cypress/"]
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
// Optional: configure or set up a testing framework before each test.
|
||||
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
|
||||
|
||||
// Used for __tests__/testing-library.js
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect'
|
|
@ -314,8 +314,9 @@
|
|||
"orderedList": "Ordered List",
|
||||
"checkList": "Checklist",
|
||||
"link": "Link",
|
||||
"image": "Image",
|
||||
"imageLink": "Image",
|
||||
"uploadImage": "Upload Image",
|
||||
"highlight": "Highlight",
|
||||
"table": {
|
||||
"titleWithoutSize": "Table",
|
||||
"titleWithSize": "{{cols}}x{{rows}} Table",
|
||||
|
@ -324,8 +325,8 @@
|
|||
"rows": "Rows",
|
||||
"create": "Create Custom Table"
|
||||
},
|
||||
"line": "Horizontal line",
|
||||
"collapsableBlock": "Collapsable block",
|
||||
"horizontalLine": "Horizontal line",
|
||||
"collapsibleBlock": "Collapsible block",
|
||||
"comment": "Comment",
|
||||
"preferences": "Editor settings",
|
||||
"emoji": "Open emoji picker"
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '../redux'
|
||||
import { getGlobalState } from '../redux'
|
||||
|
||||
export const defaultFetchConfig: Partial<RequestInit> = {
|
||||
mode: 'cors',
|
||||
|
@ -19,7 +19,7 @@ export const defaultFetchConfig: Partial<RequestInit> = {
|
|||
}
|
||||
|
||||
export const getApiUrl = (): string => {
|
||||
return store.getState().apiUrl.apiUrl
|
||||
return getGlobalState().apiUrl.apiUrl
|
||||
}
|
||||
|
||||
export const expectResponseCode = (response: Response, code = 200): void => {
|
||||
|
|
21
src/components/common/number-range/number-range.test.ts
Normal file
21
src/components/common/number-range/number-range.test.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createNumberRangeArray } from './number-range'
|
||||
|
||||
describe('number range', () => {
|
||||
it('creates an empty number range', () => {
|
||||
expect(createNumberRangeArray(0)).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a non-empty number range', () => {
|
||||
expect(createNumberRangeArray(10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
})
|
||||
|
||||
it('fails with a negative range', () => {
|
||||
expect(() => createNumberRangeArray(-1)).toThrow()
|
||||
})
|
||||
})
|
|
@ -11,7 +11,7 @@ import { findWordAtCursor } from './index'
|
|||
|
||||
const wordRegExp = /^(<d(?:e|et|eta|etai|etail|etails)?)$/
|
||||
|
||||
const collapsableBlockHint = (editor: Editor): Promise<Hints | null> => {
|
||||
const collapsibleBlockHint = (editor: Editor): Promise<Hints | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const searchTerm = findWordAtCursor(editor)
|
||||
const searchResult = wordRegExp.exec(searchTerm.text)
|
||||
|
@ -37,7 +37,7 @@ const collapsableBlockHint = (editor: Editor): Promise<Hints | null> => {
|
|||
})
|
||||
}
|
||||
|
||||
export const CollapsableBlockHinter: Hinter = {
|
||||
export const CollapsibleBlockHinter: Hinter = {
|
||||
wordRegExp,
|
||||
hint: collapsableBlockHint
|
||||
hint: collapsibleBlockHint
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type { Editor, Hints } from 'codemirror'
|
||||
import { CodeBlockHinter } from './code-block'
|
||||
import { CollapsableBlockHinter } from './collapsable-block'
|
||||
import { CollapsibleBlockHinter } from './collapsible-block'
|
||||
import { ContainerHinter } from './container'
|
||||
import { EmojiHinter } from './emoji'
|
||||
import { HeaderHinter } from './header'
|
||||
|
@ -65,5 +65,5 @@ export const allHinters: Hinter[] = [
|
|||
ImageHinter,
|
||||
LinkAndExtraTagHinter,
|
||||
PDFHinter,
|
||||
CollapsableBlockHinter
|
||||
CollapsibleBlockHinter
|
||||
]
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Pos } from 'codemirror'
|
|||
import { DateTime } from 'luxon'
|
||||
import type { Hinter } from './index'
|
||||
import { findWordAtCursor } from './index'
|
||||
import { store } from '../../../../redux'
|
||||
import { getGlobalState } from '../../../../redux'
|
||||
|
||||
const wordRegExp = /^(\[(.*])?)$/
|
||||
const allSupportedLinks = [
|
||||
|
@ -27,7 +27,7 @@ const allSupportedLinks = [
|
|||
]
|
||||
|
||||
const getUserName = (): string => {
|
||||
const user = store.getState().user
|
||||
const user = getGlobalState().user
|
||||
return user ? user.displayName : 'Anonymous'
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { Editor, EditorChange } from 'codemirror'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||
import { StatusBar } from './status-bar/status-bar'
|
||||
import { ToolBar } from './tool-bar/tool-bar'
|
||||
|
@ -18,14 +18,14 @@ import { useOnEditorFileDrop } from './hooks/use-on-editor-file-drop'
|
|||
import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
|
||||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
|
||||
import { ExtendedCodemirror } from './extended-codemirror/extended-codemirror'
|
||||
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
|
||||
|
||||
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
const markdownContent = useNoteMarkdownContent()
|
||||
|
||||
const [editor, setEditor] = useState<Editor>()
|
||||
const editor = useRef<Editor>()
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
|
||||
const onPaste = useOnEditorPasteCallback()
|
||||
|
@ -36,38 +36,56 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
setNoteContent(value)
|
||||
}, [])
|
||||
|
||||
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
|
||||
useOnImageUploadFromRenderer()
|
||||
|
||||
useOnImageUploadFromRenderer(editor)
|
||||
|
||||
const onEditorDidMount = useCallback(
|
||||
(mountedEditor: Editor) => {
|
||||
updateStatusBarInfo(mountedEditor)
|
||||
setEditor(mountedEditor)
|
||||
},
|
||||
[updateStatusBarInfo]
|
||||
)
|
||||
const onEditorDidMount = useCallback((mountedEditor: Editor) => {
|
||||
editor.current = mountedEditor
|
||||
}, [])
|
||||
|
||||
const onCursorActivity = useCursorActivityCallback()
|
||||
const onDrop = useOnEditorFileDrop()
|
||||
const codeMirrorOptions = useCodeMirrorOptions()
|
||||
|
||||
const editorFocus = useRef<boolean>(false)
|
||||
const onFocus = useCallback(() => {
|
||||
editorFocus.current = true
|
||||
if (editor.current) {
|
||||
onCursorActivity(editor.current)
|
||||
}
|
||||
}, [editor, onCursorActivity])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
editorFocus.current = false
|
||||
}, [])
|
||||
|
||||
const cursorActivity = useCallback(
|
||||
(editor: Editor) => {
|
||||
if (editorFocus.current) {
|
||||
onCursorActivity(editor)
|
||||
}
|
||||
},
|
||||
[onCursorActivity]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`d-flex flex-column h-100 position-relative`} onMouseEnter={onMakeScrollSource}>
|
||||
<MaxLengthWarning />
|
||||
<ToolBar editor={editor} />
|
||||
<ToolBar />
|
||||
<ExtendedCodemirror
|
||||
className={`overflow-hidden w-100 flex-fill`}
|
||||
value={markdownContent}
|
||||
options={codeMirrorOptions}
|
||||
onPaste={onPaste}
|
||||
onDrop={onDrop}
|
||||
onCursorActivity={updateStatusBarInfo}
|
||||
onCursorActivity={cursorActivity}
|
||||
editorDidMount={onEditorDidMount}
|
||||
onBeforeChange={onBeforeChange}
|
||||
onScroll={onEditorScroll}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
ligatures={ligaturesEnabled}
|
||||
/>
|
||||
<StatusBar statusBarInfo={statusBarInfo} />
|
||||
<StatusBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Editor } from 'codemirror'
|
||||
import type { ScrollState } from '../../synced-scroll/scroll-props'
|
||||
|
@ -11,12 +12,16 @@ import type { ScrollState } from '../../synced-scroll/scroll-props'
|
|||
/**
|
||||
* Monitors the given scroll state and scrolls the editor to the state if changed.
|
||||
*
|
||||
* @param editor The editor that should be manipulated
|
||||
* @param editorRef The editor that should be manipulated
|
||||
* @param scrollState The scroll state that should be monitored
|
||||
*/
|
||||
export const useApplyScrollState = (editor?: Editor, scrollState?: ScrollState): void => {
|
||||
export const useApplyScrollState = (
|
||||
editorRef: MutableRefObject<Editor | undefined>,
|
||||
scrollState?: ScrollState
|
||||
): void => {
|
||||
const lastScrollPosition = useRef<number>()
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !scrollState) {
|
||||
return
|
||||
}
|
||||
|
@ -28,5 +33,5 @@ export const useApplyScrollState = (editor?: Editor, scrollState?: ScrollState):
|
|||
lastScrollPosition.current = newPosition
|
||||
editor.scrollTo(0, newPosition)
|
||||
}
|
||||
}, [editor, scrollState])
|
||||
}, [editorRef, scrollState])
|
||||
}
|
||||
|
|
|
@ -5,34 +5,31 @@
|
|||
*/
|
||||
|
||||
import type { StatusBarInfo } from '../status-bar/status-bar'
|
||||
import { defaultState } from '../status-bar/status-bar'
|
||||
import type { Editor } from 'codemirror'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
|
||||
/**
|
||||
* Provides a {@link StatusBarInfo} object and a function that can update this object using a {@link CodeMirror code mirror instance}.
|
||||
*/
|
||||
export const useCreateStatusBarInfo = (): [
|
||||
statusBarInfo: StatusBarInfo,
|
||||
updateStatusBarInfo: (editor: Editor) => void
|
||||
] => {
|
||||
export const useCreateStatusBarInfo = (): StatusBarInfo => {
|
||||
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
|
||||
const [statusBarInfo, setStatusBarInfo] = useState(defaultState)
|
||||
const selection = useApplicationState((state) => state.noteDetails.selection)
|
||||
const markdownContent = useApplicationState((state) => state.noteDetails.markdownContent)
|
||||
const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines)
|
||||
|
||||
const updateStatusBarInfo = useCallback(
|
||||
(editor: Editor): void => {
|
||||
setStatusBarInfo({
|
||||
position: editor.getCursor(),
|
||||
charactersInDocument: editor.getValue().length,
|
||||
remainingCharacters: maxDocumentLength - editor.getValue().length,
|
||||
linesInDocument: editor.lineCount(),
|
||||
selectedColumns: editor.getSelection().length,
|
||||
selectedLines: editor.getSelection().split('\n').length
|
||||
})
|
||||
},
|
||||
[maxDocumentLength]
|
||||
)
|
||||
return useMemo(() => {
|
||||
const startCharacter = selection.from.character
|
||||
const endCharacter = selection.to?.character ?? 0
|
||||
const startLine = selection.from.line
|
||||
const endLine = selection.to?.line ?? 0
|
||||
|
||||
return [statusBarInfo, updateStatusBarInfo]
|
||||
return {
|
||||
position: { line: startLine, character: startCharacter },
|
||||
charactersInDocument: markdownContent.length,
|
||||
remainingCharacters: maxDocumentLength - markdownContent.length,
|
||||
linesInDocument: markdownContentLines.length,
|
||||
selectedColumns: endCharacter - startCharacter,
|
||||
selectedLines: endLine - startLine
|
||||
}
|
||||
}, [markdownContent.length, markdownContentLines.length, maxDocumentLength, selection])
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import { useCallback } from 'react'
|
||||
import type { CursorPosition } from '../../../../redux/editor/types'
|
||||
import { updateCursorPositions } from '../../../../redux/note-details/methods'
|
||||
|
||||
/**
|
||||
* Provides a callback for codemirror that handles cursor changes
|
||||
*
|
||||
* @return the generated callback
|
||||
*/
|
||||
export const useCursorActivityCallback = (): ((editor: Editor) => void) => {
|
||||
return useCallback((editor) => {
|
||||
const firstSelection = editor.listSelections()[0]
|
||||
if (firstSelection === undefined) {
|
||||
return
|
||||
}
|
||||
const start: CursorPosition = { line: firstSelection.from().line, character: firstSelection.from().ch }
|
||||
const end: CursorPosition = { line: firstSelection.to().line, character: firstSelection.to().ch }
|
||||
updateCursorPositions({
|
||||
from: start,
|
||||
to: start.line === end.line && start.character === end.character ? undefined : end
|
||||
})
|
||||
}, [])
|
||||
}
|
|
@ -39,7 +39,7 @@ export const useOnEditorFileDrop = (): DomEvent => {
|
|||
const newCursor = dropEditor.coordsChar({ top: event.pageY, left: event.pageX }, 'page')
|
||||
dropEditor.setCursor(newCursor)
|
||||
const files: FileList = event.dataTransfer.files
|
||||
handleUpload(files[0], dropEditor)
|
||||
handleUpload(files[0])
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import { useCallback } from 'react'
|
|||
import type { Editor } from 'codemirror'
|
||||
import type { PasteEvent } from '../tool-bar/utils/pasteHandlers'
|
||||
import { handleFilePaste, handleTablePaste } from '../tool-bar/utils/pasteHandlers'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import type { DomEvent } from 'react-codemirror2'
|
||||
|
||||
/**
|
||||
|
@ -17,18 +16,13 @@ import type { DomEvent } from 'react-codemirror2'
|
|||
* @return the created callback
|
||||
*/
|
||||
export const useOnEditorPasteCallback = (): DomEvent => {
|
||||
const smartPasteEnabled = useApplicationState((state) => state.editorConfig.smartPaste)
|
||||
|
||||
return useCallback(
|
||||
(pasteEditor: Editor, event: PasteEvent) => {
|
||||
return useCallback((pasteEditor: Editor, event: PasteEvent) => {
|
||||
if (!event || !event.clipboardData) {
|
||||
return
|
||||
}
|
||||
if (smartPasteEnabled && handleTablePaste(event, pasteEditor)) {
|
||||
if (handleTablePaste(event) || handleFilePaste(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
handleFilePaste(event, pasteEditor)
|
||||
},
|
||||
[smartPasteEnabled]
|
||||
)
|
||||
}, [])
|
||||
}
|
||||
|
|
|
@ -8,30 +8,24 @@ import { useEditorReceiveHandler } from '../../../render-page/window-post-messag
|
|||
import type { ImageUploadMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import { useCallback } from 'react'
|
||||
import { store } from '../../../../redux'
|
||||
import { getGlobalState } from '../../../../redux'
|
||||
import { handleUpload } from '../upload-handler'
|
||||
import type { Editor, Position } from 'codemirror'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { findRegexMatchInText } from '../find-regex-match-in-text'
|
||||
import Optional from 'optional-js'
|
||||
import type { CursorSelection } from '../../../../redux/editor/types'
|
||||
|
||||
const log = new Logger('useOnImageUpload')
|
||||
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
||||
|
||||
/**
|
||||
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
|
||||
*
|
||||
* @param editor The {@link Editor codemirror editor} that should be used to change the markdown code
|
||||
*/
|
||||
export const useOnImageUploadFromRenderer = (editor: Editor | undefined): void => {
|
||||
export const useOnImageUploadFromRenderer = (): void => {
|
||||
useEditorReceiveHandler(
|
||||
CommunicationMessageType.IMAGE_UPLOAD,
|
||||
useCallback(
|
||||
(values: ImageUploadMessage) => {
|
||||
useCallback((values: ImageUploadMessage) => {
|
||||
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
if (!dataUri.startsWith('data:image/')) {
|
||||
log.error('Received uri is no data uri and image!')
|
||||
return
|
||||
|
@ -41,23 +35,20 @@ export const useOnImageUploadFromRenderer = (editor: Editor | undefined): void =
|
|||
.then((result) => result.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
const { cursorFrom, cursorTo, description, additionalText } = Optional.ofNullable(lineIndex)
|
||||
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
|
||||
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
|
||||
.orElseGet(() => calculateInsertAtCurrentCursorPosition(editor))
|
||||
handleUpload(file, editor, cursorFrom, cursorTo, description, additionalText)
|
||||
.orElseGet(() => ({}))
|
||||
handleUpload(file, cursorSelection, alt, title)
|
||||
})
|
||||
.catch((error) => log.error(error))
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
}, [])
|
||||
)
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
cursorFrom: Position
|
||||
cursorTo: Position
|
||||
description?: string
|
||||
additionalText?: string
|
||||
cursorSelection?: CursorSelection
|
||||
alt?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -68,7 +59,7 @@ export interface ExtractResult {
|
|||
* @return the calculated start and end position or undefined if no position could be determined
|
||||
*/
|
||||
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): ExtractResult | undefined => {
|
||||
const currentMarkdownContentLines = store.getState().noteDetails.markdownContent.split('\n')
|
||||
const currentMarkdownContentLines = getGlobalState().noteDetails.markdownContent.split('\n')
|
||||
const lineAtIndex = currentMarkdownContentLines[lineIndex]
|
||||
if (lineAtIndex === undefined) {
|
||||
return
|
||||
|
@ -95,26 +86,17 @@ const findImagePlaceholderInLine = (
|
|||
}
|
||||
|
||||
return {
|
||||
cursorFrom: {
|
||||
ch: startOfImageTag.index,
|
||||
cursorSelection: {
|
||||
from: {
|
||||
character: startOfImageTag.index,
|
||||
line: lineIndex
|
||||
},
|
||||
cursorTo: {
|
||||
ch: startOfImageTag.index + startOfImageTag[0].length,
|
||||
to: {
|
||||
character: startOfImageTag.index + startOfImageTag[0].length,
|
||||
line: lineIndex
|
||||
}
|
||||
},
|
||||
description: startOfImageTag[1],
|
||||
additionalText: startOfImageTag[2]
|
||||
alt: startOfImageTag[1],
|
||||
title: startOfImageTag[2]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a fallback position that is the current editor cursor position.
|
||||
* This wouldn't replace anything and only insert.
|
||||
*
|
||||
* @param editor The editor whose cursor should be used
|
||||
*/
|
||||
const calculateInsertAtCurrentCursorPosition = (editor: Editor): ExtractResult => {
|
||||
const editorCursor = editor.getCursor()
|
||||
return { cursorFrom: editorCursor, cursorTo: editorCursor }
|
||||
}
|
||||
|
|
|
@ -7,14 +7,8 @@
|
|||
import type { Editor, KeyMap, Pass } from 'codemirror'
|
||||
import CodeMirror from 'codemirror'
|
||||
import { isMac } from '../utils'
|
||||
import {
|
||||
addLink,
|
||||
makeSelectionBold,
|
||||
makeSelectionItalic,
|
||||
markSelection,
|
||||
strikeThroughSelection,
|
||||
underlineSelection
|
||||
} from './tool-bar/utils/toolbarButtonUtils'
|
||||
import { formatSelection } from '../../../redux/note-details/methods'
|
||||
import { FormatType } from '../../../redux/note-details/types'
|
||||
|
||||
const isVim = (keyMapName?: string) => keyMapName?.substr(0, 3) === 'vim'
|
||||
|
||||
|
@ -83,11 +77,11 @@ export const createDefaultKeyMap: () => KeyMap = () => {
|
|||
'Cmd-Right': 'goLineRight',
|
||||
Home: 'goLineLeftSmart',
|
||||
End: 'goLineRight',
|
||||
'Cmd-I': makeSelectionItalic,
|
||||
'Cmd-B': makeSelectionBold,
|
||||
'Cmd-U': underlineSelection,
|
||||
'Cmd-D': strikeThroughSelection,
|
||||
'Cmd-M': markSelection
|
||||
'Cmd-I': () => formatSelection(FormatType.ITALIC),
|
||||
'Cmd-B': () => formatSelection(FormatType.BOLD),
|
||||
'Cmd-U': () => formatSelection(FormatType.UNDERLINE),
|
||||
'Cmd-D': () => formatSelection(FormatType.STRIKETHROUGH),
|
||||
'Cmd-M': () => formatSelection(FormatType.HIGHLIGHT)
|
||||
} as KeyMap
|
||||
} else {
|
||||
return {
|
||||
|
@ -99,12 +93,12 @@ export const createDefaultKeyMap: () => KeyMap = () => {
|
|||
Tab: tab,
|
||||
Home: 'goLineLeftSmart',
|
||||
End: 'goLineRight',
|
||||
'Ctrl-I': makeSelectionItalic,
|
||||
'Ctrl-B': makeSelectionBold,
|
||||
'Ctrl-U': underlineSelection,
|
||||
'Ctrl-D': strikeThroughSelection,
|
||||
'Ctrl-M': markSelection,
|
||||
'Ctrl-K': addLink
|
||||
'Ctrl-I': () => formatSelection(FormatType.ITALIC),
|
||||
'Ctrl-B': () => formatSelection(FormatType.BOLD),
|
||||
'Ctrl-U': () => formatSelection(FormatType.UNDERLINE),
|
||||
'Ctrl-D': () => formatSelection(FormatType.STRIKETHROUGH),
|
||||
'Ctrl-M': () => formatSelection(FormatType.HIGHLIGHT),
|
||||
'Ctrl-K': () => formatSelection(FormatType.LINK)
|
||||
} as KeyMap
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
|
||||
import React, { useMemo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import type { Position } from 'codemirror'
|
||||
import type { CursorPosition } from '../../../../redux/editor/types'
|
||||
|
||||
export interface CursorPositionInfoProps {
|
||||
cursorPosition: Position
|
||||
cursorPosition: CursorPosition
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -21,9 +21,9 @@ export const CursorPositionInfo: React.FC<CursorPositionInfoProps> = ({ cursorPo
|
|||
const translationOptions = useMemo(
|
||||
() => ({
|
||||
line: cursorPosition.line + 1,
|
||||
columns: cursorPosition.ch + 1
|
||||
columns: cursorPosition.character + 1
|
||||
}),
|
||||
[cursorPosition.ch, cursorPosition.line]
|
||||
[cursorPosition.character, cursorPosition.line]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Position } from 'codemirror'
|
||||
import React from 'react'
|
||||
import styles from './status-bar.module.scss'
|
||||
import { RemainingCharactersInfo } from './remaining-characters-info'
|
||||
|
@ -13,9 +12,11 @@ import { CursorPositionInfo } from './cursor-position-info'
|
|||
import { SelectionInfo } from './selection-info'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import { SeparatorDash } from './separator-dash'
|
||||
import type { CursorPosition } from '../../../../redux/editor/types'
|
||||
import { useCreateStatusBarInfo } from '../hooks/use-create-status-bar-info'
|
||||
|
||||
export interface StatusBarInfo {
|
||||
position: Position
|
||||
position: CursorPosition
|
||||
selectedColumns: number
|
||||
selectedLines: number
|
||||
linesInDocument: number
|
||||
|
@ -24,7 +25,7 @@ export interface StatusBarInfo {
|
|||
}
|
||||
|
||||
export const defaultState: StatusBarInfo = {
|
||||
position: { line: 0, ch: 0 },
|
||||
position: { line: 0, character: 0 },
|
||||
selectedColumns: 0,
|
||||
selectedLines: 0,
|
||||
linesInDocument: 0,
|
||||
|
@ -32,16 +33,12 @@ export const defaultState: StatusBarInfo = {
|
|||
remainingCharacters: 0
|
||||
}
|
||||
|
||||
export interface StatusBarProps {
|
||||
statusBarInfo: StatusBarInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows additional information about the document length and the current selection.
|
||||
*
|
||||
* @param statusBarInfo The information to show
|
||||
*/
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({ statusBarInfo }) => {
|
||||
export const StatusBar: React.FC = () => {
|
||||
const statusBarInfo = useCreateStatusBarInfo()
|
||||
|
||||
return (
|
||||
<div className={`d-flex flex-row ${styles['status-bar']} px-2`}>
|
||||
<div>
|
||||
|
|
|
@ -4,37 +4,34 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type CodeMirror from 'codemirror'
|
||||
import React, { Fragment, useState } from 'react'
|
||||
import React, { Fragment, useCallback, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { addEmoji } from '../utils/toolbarButtonUtils'
|
||||
import { EmojiPicker } from './emoji-picker'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { getEmojiShortCode } from '../utils/emojiUtils'
|
||||
import { replaceSelection } from '../../../../../redux/note-details/methods'
|
||||
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
export interface EmojiPickerButtonProps {
|
||||
editor: CodeMirror.Editor
|
||||
}
|
||||
|
||||
export const EmojiPickerButton: React.FC<EmojiPickerButtonProps> = ({ editor }) => {
|
||||
export const EmojiPickerButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const onEmojiSelected = useCallback((emoji: EmojiClickEventDetail) => {
|
||||
setShowEmojiPicker(false)
|
||||
Optional.ofNullable(getEmojiShortCode(emoji)).ifPresent((shortCode) => replaceSelection(shortCode))
|
||||
}, [])
|
||||
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
|
||||
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EmojiPicker
|
||||
show={showEmojiPicker}
|
||||
onEmojiSelected={(emoji) => {
|
||||
setShowEmojiPicker(false)
|
||||
addEmoji(emoji, editor)
|
||||
}}
|
||||
onDismiss={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
<EmojiPicker show={showEmojiPicker} onEmojiSelected={onEmojiSelected} onDismiss={hidePicker} />
|
||||
<Button
|
||||
{...cypressId('show-emoji-picker')}
|
||||
variant='light'
|
||||
onClick={() => setShowEmojiPicker((old) => !old)}
|
||||
onClick={showPicker}
|
||||
title={t('editor.editorToolbar.emoji')}>
|
||||
<ForkAwesomeIcon icon='smile-o' />
|
||||
</Button>
|
||||
|
|
|
@ -4,21 +4,16 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type CodeMirror from 'codemirror'
|
||||
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Overlay } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { addTable } from '../utils/toolbarButtonUtils'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { TableSizePickerPopover } from './table-size-picker-popover'
|
||||
import { CustomTableSizeModal } from './custom-table-size-modal'
|
||||
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
|
||||
import { ShowIf } from '../../../../common/show-if/show-if'
|
||||
|
||||
export interface TablePickerButtonProps {
|
||||
editor: CodeMirror.Editor
|
||||
}
|
||||
import { addTableAtCursor } from '../../../../../redux/note-details/methods'
|
||||
|
||||
enum PickerMode {
|
||||
INVISIBLE,
|
||||
|
@ -28,24 +23,19 @@ enum PickerMode {
|
|||
|
||||
/**
|
||||
* Toggles the visibility of a table size picker overlay and inserts the result into the editor.
|
||||
*
|
||||
* @param editor The editor in which the result should get inserted
|
||||
*/
|
||||
export const TablePickerButton: React.FC<TablePickerButtonProps> = ({ editor }) => {
|
||||
export const TablePickerButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
|
||||
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
|
||||
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
|
||||
|
||||
const onSizeSelect = useCallback(
|
||||
(rows: number, columns: number) => {
|
||||
addTable(editor, rows, columns)
|
||||
const onSizeSelect = useCallback((rows: number, columns: number) => {
|
||||
addTableAtCursor(rows, columns)
|
||||
setPickerMode(PickerMode.INVISIBLE)
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const tableTitle = useMemo(() => t('editor.editorToolbar.table.title'), [t])
|
||||
const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t])
|
||||
|
||||
const button = useRef(null)
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ export const TableSizePickerPopover: React.FC<TableSizePickerPopoverProps> = ({
|
|||
{...cypressAttribute('col', `${col + 1}`)}
|
||||
{...cypressAttribute('row', `${row + 1}`)}
|
||||
onMouseEnter={onSizeHover(row + 1, col + 1)}
|
||||
title={t('editor.editorToolbar.table.size', { cols: col + 1, rows: row + 1 })}
|
||||
title={t('editor.editorToolbar.table.titleWithSize', { cols: col + 1, rows: row + 1 })}
|
||||
onClick={() => onTableSizeSelected(row + 1, col + 1)}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -4,179 +4,47 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import React from 'react'
|
||||
import { Button, ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
||||
import { EditorPreferences } from './editor-preferences/editor-preferences'
|
||||
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
|
||||
import { TablePickerButton } from './table-picker/table-picker-button'
|
||||
import styles from './tool-bar.module.scss'
|
||||
import { UploadImageButton } from './upload-image-button'
|
||||
import {
|
||||
addCodeFences,
|
||||
addCollapsableBlock,
|
||||
addComment,
|
||||
addHeaderLevel,
|
||||
addImage,
|
||||
addLine,
|
||||
addLink,
|
||||
addList,
|
||||
addOrderedList,
|
||||
addQuotes,
|
||||
addTaskList,
|
||||
makeSelectionBold,
|
||||
makeSelectionItalic,
|
||||
strikeThroughSelection,
|
||||
subscriptSelection,
|
||||
superscriptSelection,
|
||||
underlineSelection
|
||||
} from './utils/toolbarButtonUtils'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
export interface ToolBarProps {
|
||||
editor?: Editor
|
||||
}
|
||||
|
||||
export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
import { ToolbarButton } from './toolbar-button'
|
||||
import { FormatType } from '../../../../redux/note-details/types'
|
||||
|
||||
export const ToolBar: React.FC = () => {
|
||||
return (
|
||||
<ButtonToolbar className={`bg-light ${styles.toolbar}`}>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<Button
|
||||
{...cypressId('format-bold')}
|
||||
variant='light'
|
||||
onClick={() => makeSelectionBold(editor)}
|
||||
title={t('editor.editorToolbar.bold')}>
|
||||
<ForkAwesomeIcon icon='bold' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-italic')}
|
||||
variant='light'
|
||||
onClick={() => makeSelectionItalic(editor)}
|
||||
title={t('editor.editorToolbar.italic')}>
|
||||
<ForkAwesomeIcon icon='italic' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-underline')}
|
||||
variant='light'
|
||||
onClick={() => underlineSelection(editor)}
|
||||
title={t('editor.editorToolbar.underline')}>
|
||||
<ForkAwesomeIcon icon='underline' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-strikethrough')}
|
||||
variant='light'
|
||||
onClick={() => strikeThroughSelection(editor)}
|
||||
title={t('editor.editorToolbar.strikethrough')}>
|
||||
<ForkAwesomeIcon icon='strikethrough' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-subscript')}
|
||||
variant='light'
|
||||
onClick={() => subscriptSelection(editor)}
|
||||
title={t('editor.editorToolbar.subscript')}>
|
||||
<ForkAwesomeIcon icon='subscript' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-superscript')}
|
||||
variant='light'
|
||||
onClick={() => superscriptSelection(editor)}
|
||||
title={t('editor.editorToolbar.superscript')}>
|
||||
<ForkAwesomeIcon icon='superscript' />
|
||||
</Button>
|
||||
<ToolbarButton icon={'bold'} formatType={FormatType.BOLD} />
|
||||
<ToolbarButton icon={'italic'} formatType={FormatType.ITALIC} />
|
||||
<ToolbarButton icon={'underline'} formatType={FormatType.UNDERLINE} />
|
||||
<ToolbarButton icon={'strikethrough'} formatType={FormatType.STRIKETHROUGH} />
|
||||
<ToolbarButton icon={'subscript'} formatType={FormatType.SUBSCRIPT} />
|
||||
<ToolbarButton icon={'superscript'} formatType={FormatType.SUPERSCRIPT} />
|
||||
<ToolbarButton icon={'eraser'} formatType={FormatType.HIGHLIGHT} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<Button
|
||||
{...cypressId('format-heading')}
|
||||
variant='light'
|
||||
onClick={() => addHeaderLevel(editor)}
|
||||
title={t('editor.editorToolbar.header')}>
|
||||
<ForkAwesomeIcon icon='header' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-code-block')}
|
||||
variant='light'
|
||||
onClick={() => addCodeFences(editor)}
|
||||
title={t('editor.editorToolbar.code')}>
|
||||
<ForkAwesomeIcon icon='code' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-block-quote')}
|
||||
variant='light'
|
||||
onClick={() => addQuotes(editor)}
|
||||
title={t('editor.editorToolbar.blockquote')}>
|
||||
<ForkAwesomeIcon icon='quote-right' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-unordered-list')}
|
||||
variant='light'
|
||||
onClick={() => addList(editor)}
|
||||
title={t('editor.editorToolbar.unorderedList')}>
|
||||
<ForkAwesomeIcon icon='list' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-ordered-list')}
|
||||
variant='light'
|
||||
onClick={() => addOrderedList(editor)}
|
||||
title={t('editor.editorToolbar.orderedList')}>
|
||||
<ForkAwesomeIcon icon='list-ol' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-check-list')}
|
||||
variant='light'
|
||||
onClick={() => addTaskList(editor)}
|
||||
title={t('editor.editorToolbar.checkList')}>
|
||||
<ForkAwesomeIcon icon='check-square' />
|
||||
</Button>
|
||||
<ToolbarButton icon={'header'} formatType={FormatType.HEADER_LEVEL} />
|
||||
<ToolbarButton icon={'code'} formatType={FormatType.CODE_FENCE} />
|
||||
<ToolbarButton icon={'quote-right'} formatType={FormatType.QUOTES} />
|
||||
<ToolbarButton icon={'list'} formatType={FormatType.UNORDERED_LIST} />
|
||||
<ToolbarButton icon={'list-ol'} formatType={FormatType.ORDERED_LIST} />
|
||||
<ToolbarButton icon={'check-square'} formatType={FormatType.CHECK_LIST} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<Button
|
||||
{...cypressId('format-link')}
|
||||
variant='light'
|
||||
onClick={() => addLink(editor)}
|
||||
title={t('editor.editorToolbar.link')}>
|
||||
<ForkAwesomeIcon icon='link' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-image')}
|
||||
variant='light'
|
||||
onClick={() => addImage(editor)}
|
||||
title={t('editor.editorToolbar.image')}>
|
||||
<ForkAwesomeIcon icon='picture-o' />
|
||||
</Button>
|
||||
<UploadImageButton editor={editor} />
|
||||
<ToolbarButton icon={'link'} formatType={FormatType.LINK} />
|
||||
<ToolbarButton icon={'picture-o'} formatType={FormatType.IMAGE_LINK} />
|
||||
<UploadImageButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<TablePickerButton editor={editor} />
|
||||
<Button
|
||||
{...cypressId('format-add-line')}
|
||||
variant='light'
|
||||
onClick={() => addLine(editor)}
|
||||
title={t('editor.editorToolbar.line')}>
|
||||
<ForkAwesomeIcon icon='minus' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-collapsable-block')}
|
||||
variant='light'
|
||||
onClick={() => addCollapsableBlock(editor)}
|
||||
title={t('editor.editorToolbar.collapsableBlock')}>
|
||||
<ForkAwesomeIcon icon='caret-square-o-down' />
|
||||
</Button>
|
||||
<Button
|
||||
{...cypressId('format-add-comment')}
|
||||
variant='light'
|
||||
onClick={() => addComment(editor)}
|
||||
title={t('editor.editorToolbar.comment')}>
|
||||
<ForkAwesomeIcon icon='comment' />
|
||||
</Button>
|
||||
<EmojiPickerButton editor={editor} />
|
||||
<TablePickerButton />
|
||||
<ToolbarButton icon={'minus'} formatType={FormatType.HORIZONTAL_LINE} />
|
||||
<ToolbarButton icon={'caret-square-o-down'} formatType={FormatType.COLLAPSIBLE_BLOCK} />
|
||||
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
|
||||
<EmojiPickerButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||
<EditorPreferences />
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import type { FormatType } from '../../../../redux/note-details/types'
|
||||
import type { IconName } from '../../../common/fork-awesome/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatSelection } from '../../../../redux/note-details/methods'
|
||||
|
||||
export interface ToolbarButtonProps {
|
||||
icon: IconName
|
||||
formatType: FormatType
|
||||
}
|
||||
|
||||
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({ formatType, icon }) => {
|
||||
const { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' })
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
formatSelection(formatType)
|
||||
}, [formatType])
|
||||
|
||||
const title = useMemo(() => t(formatType), [formatType, t])
|
||||
|
||||
return (
|
||||
<Button variant='light' onClick={onClick} title={title} {...cypressId('toolbar.' + formatType)}>
|
||||
<ForkAwesomeIcon icon={icon} />
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -4,7 +4,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import React, { Fragment, useCallback, useRef } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
@ -14,30 +13,17 @@ import { handleUpload } from '../upload-handler'
|
|||
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
export interface UploadImageButtonProps {
|
||||
editor?: Editor
|
||||
}
|
||||
|
||||
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
|
||||
export const UploadImageButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const clickRef = useRef<() => void>()
|
||||
const buttonClick = useCallback(() => {
|
||||
clickRef.current?.()
|
||||
}, [])
|
||||
|
||||
const onUploadImage = useCallback(
|
||||
(file: File) => {
|
||||
if (editor) {
|
||||
handleUpload(file, editor)
|
||||
}
|
||||
const onUploadImage = useCallback((file: File) => {
|
||||
handleUpload(file)
|
||||
return Promise.resolve()
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
@ -4,54 +4,64 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ApplicationState } from '../../../../../redux/application-state'
|
||||
import { initialState } from '../../../../../redux/note-details/initial-state'
|
||||
import { isCursorInCodeFence } from './codefenceDetection'
|
||||
import * as storeModule from '../../../../../redux'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { Editor } from 'codemirror'
|
||||
import { isCursorInCodefence } from './codefenceDetection'
|
||||
|
||||
Mock.configure('jest')
|
||||
|
||||
const mockEditor = (content: string, line: number) => {
|
||||
const contentLines = content.split('\n')
|
||||
return Mock.of<Editor>({
|
||||
getCursor() {
|
||||
return {
|
||||
line: line,
|
||||
ch: 0
|
||||
}
|
||||
},
|
||||
getDoc() {
|
||||
return {
|
||||
getLine(ln: number) {
|
||||
return contentLines[ln] ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Check whether cursor is in codefence', () => {
|
||||
const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState')
|
||||
|
||||
const mockRedux = (content: string, line: number): void => {
|
||||
const contentLines = content.split('\n')
|
||||
getGlobalStateMocked.mockImplementation(() =>
|
||||
Mock.from<ApplicationState>({
|
||||
noteDetails: {
|
||||
...initialState,
|
||||
selection: {
|
||||
from: {
|
||||
line: line,
|
||||
character: 0
|
||||
}
|
||||
},
|
||||
markdownContentLines: contentLines,
|
||||
markdownContent: content
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false for empty document', () => {
|
||||
const editor = mockEditor('', 0)
|
||||
expect(isCursorInCodefence(editor)).toBe(false)
|
||||
mockRedux('', 0)
|
||||
expect(isCursorInCodeFence()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true with one open codefence directly above', () => {
|
||||
const editor = mockEditor('```\n', 1)
|
||||
expect(isCursorInCodefence(editor)).toBe(true)
|
||||
mockRedux('```\n', 1)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true with one open codefence and empty lines above', () => {
|
||||
const editor = mockEditor('```\n\n\n', 3)
|
||||
expect(isCursorInCodefence(editor)).toBe(true)
|
||||
mockRedux('```\n\n\n', 3)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false with one completed codefence above', () => {
|
||||
const editor = mockEditor('```\n\n```\n', 3)
|
||||
expect(isCursorInCodefence(editor)).toBe(false)
|
||||
mockRedux('```\n\n```\n', 3)
|
||||
expect(isCursorInCodeFence()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true with one completed and one open codefence above', () => {
|
||||
const editor = mockEditor('```\n\n```\n\n```\n\n', 6)
|
||||
expect(isCursorInCodefence(editor)).toBe(true)
|
||||
mockRedux('```\n\n```\n\n```\n\n', 6)
|
||||
expect(isCursorInCodeFence()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,16 +4,25 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
|
||||
export const isCursorInCodefence = (editor: Editor): boolean => {
|
||||
const currentLine = editor.getCursor().line
|
||||
let codefenceCount = 0
|
||||
for (let line = currentLine; line >= 0; --line) {
|
||||
const markdownContentLine = editor.getDoc().getLine(line)
|
||||
if (markdownContentLine.startsWith('```')) {
|
||||
codefenceCount++
|
||||
/**
|
||||
* Checks if the start of the current {@link CursorSelection cursor selection} is in a code fence.
|
||||
*/
|
||||
export const isCursorInCodeFence = (): boolean => {
|
||||
const lines = getGlobalState().noteDetails.markdownContentLines.slice(
|
||||
0,
|
||||
getGlobalState().noteDetails.selection.from.line
|
||||
)
|
||||
return countCodeFenceLinesUntilIndex(lines) % 2 === 1
|
||||
}
|
||||
}
|
||||
return codefenceCount % 2 === 1
|
||||
|
||||
/**
|
||||
* Counts the lines that start or end a code fence.
|
||||
*
|
||||
* @param lines The lines that should be inspected
|
||||
* @return the counted lines
|
||||
*/
|
||||
const countCodeFenceLinesUntilIndex = (lines: string[]): number => {
|
||||
return lines.filter((line) => line.startsWith('```')).length
|
||||
}
|
||||
|
|
|
@ -4,11 +4,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import { convertClipboardTableToMarkdown, isTable } from '../../table-extractor'
|
||||
import { handleUpload } from '../../upload-handler'
|
||||
import { insertAtCursor } from './toolbarButtonUtils'
|
||||
import { isCursorInCodefence } from './codefenceDetection'
|
||||
import { replaceSelection } from '../../../../../redux/note-details/methods'
|
||||
import { isCursorInCodeFence } from './codefenceDetection'
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
type ClipboardDataFormats = 'text' | 'url' | 'text/plain' | 'text/uri-list' | 'text/html'
|
||||
|
||||
|
@ -20,26 +21,41 @@ export interface PasteEvent {
|
|||
preventDefault: () => void
|
||||
}
|
||||
|
||||
export const handleTablePaste = (event: PasteEvent, editor: Editor): boolean => {
|
||||
const pasteText = event.clipboardData.getData('text')
|
||||
if (!pasteText || isCursorInCodefence(editor) || !isTable(pasteText)) {
|
||||
/**
|
||||
* Checks if the given {@link PasteEvent paste event} contains a text formatted table
|
||||
* and inserts it into the markdown content.
|
||||
* This happens only if smart paste was activated.
|
||||
*
|
||||
* @param event The {@link PasteEvent} from the browser
|
||||
* @return {@code true} if the event was processed. {@code false} otherwise
|
||||
*/
|
||||
export const handleTablePaste = (event: PasteEvent): boolean => {
|
||||
if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
const markdownTable = convertClipboardTableToMarkdown(pasteText)
|
||||
insertAtCursor(editor, markdownTable)
|
||||
return true
|
||||
}
|
||||
|
||||
export const handleFilePaste = (event: PasteEvent, editor: Editor): boolean => {
|
||||
if (!event.clipboardData.files || event.clipboardData.files.length < 1) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
const files: FileList = event.clipboardData.files
|
||||
if (files && files.length >= 1) {
|
||||
handleUpload(files[0], editor)
|
||||
return Optional.ofNullable(event.clipboardData.getData('text'))
|
||||
.filter((pasteText) => !!pasteText && isTable(pasteText))
|
||||
.map((pasteText) => convertClipboardTableToMarkdown(pasteText))
|
||||
.map((markdownTable) => {
|
||||
replaceSelection(markdownTable)
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
||||
return false
|
||||
|
||||
/**
|
||||
* Checks if the given {@link PasteEvent paste event} contains files and uploads them.
|
||||
*
|
||||
* @param event The {@link PasteEvent} from the browser
|
||||
* @return {@code true} if the event was processed. {@code false} otherwise
|
||||
*/
|
||||
export const handleFilePaste = (event: PasteEvent): boolean => {
|
||||
return Optional.ofNullable(event.clipboardData.files)
|
||||
.filter((files) => !!files && files.length > 0)
|
||||
.map((files) => {
|
||||
handleUpload(files[0])
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
|
||||
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
|
||||
import { getEmojiShortCode } from './emojiUtils'
|
||||
|
||||
export const makeSelectionBold = (editor: Editor): void => wrapTextWith(editor, '**')
|
||||
export const makeSelectionItalic = (editor: Editor): void => wrapTextWith(editor, '*')
|
||||
export const strikeThroughSelection = (editor: Editor): void => wrapTextWith(editor, '~~')
|
||||
export const underlineSelection = (editor: Editor): void => wrapTextWith(editor, '++')
|
||||
export const subscriptSelection = (editor: Editor): void => wrapTextWith(editor, '~')
|
||||
export const superscriptSelection = (editor: Editor): void => wrapTextWith(editor, '^')
|
||||
export const markSelection = (editor: Editor): void => wrapTextWith(editor, '==')
|
||||
|
||||
export const addHeaderLevel = (editor: Editor): void =>
|
||||
changeLines(editor, (line) => (line.startsWith('#') ? `#${line}` : `# ${line}`))
|
||||
export const addCodeFences = (editor: Editor): void => wrapTextWithOrJustPut(editor, '```\n', '\n```')
|
||||
export const addQuotes = (editor: Editor): void => insertOnStartOfLines(editor, '> ')
|
||||
|
||||
export const addList = (editor: Editor): void => createList(editor, () => '- ')
|
||||
export const addOrderedList = (editor: Editor): void => createList(editor, (j) => `${j}. `)
|
||||
export const addTaskList = (editor: Editor): void => createList(editor, () => '- [ ] ')
|
||||
|
||||
export const addImage = (editor: Editor): void => addLink(editor, '!')
|
||||
|
||||
export const addLine = (editor: Editor): void => changeLines(editor, (line) => `${line}\n----`)
|
||||
export const addCollapsableBlock = (editor: Editor): void =>
|
||||
changeLines(editor, (line) => `${line}\n:::spoiler Toggle label\n Toggled content\n:::`)
|
||||
export const addComment = (editor: Editor): void => changeLines(editor, (line) => `${line}\n> []`)
|
||||
export const addTable = (editor: Editor, rows: number, columns: number): void => {
|
||||
const rowArray = createNumberRangeArray(rows)
|
||||
const colArray = createNumberRangeArray(columns).map((col) => col + 1)
|
||||
const head = '| # ' + colArray.join(' | # ') + ' |'
|
||||
const divider = '| ' + colArray.map(() => '----').join(' | ') + ' |'
|
||||
const body = rowArray.map(() => '| ' + colArray.map(() => 'Text').join(' | ') + ' |').join('\n')
|
||||
const table = `${head}\n${divider}\n${body}`
|
||||
changeLines(editor, (line) => `${line}\n${table}`)
|
||||
}
|
||||
|
||||
export const addEmoji = (emoji: EmojiClickEventDetail, editor: Editor): void => {
|
||||
const shortCode = getEmojiShortCode(emoji)
|
||||
if (shortCode) {
|
||||
insertAtCursor(editor, shortCode)
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapTextWith = (editor: Editor, symbol: string, endSymbol?: string): void => {
|
||||
if (!editor.getSelection()) {
|
||||
return
|
||||
}
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
if (range.empty()) {
|
||||
continue
|
||||
}
|
||||
const from = range.from()
|
||||
const to = range.to()
|
||||
|
||||
const selection = editor.getRange(from, to)
|
||||
editor.replaceRange(symbol + selection + (endSymbol || symbol), from, to, '+input')
|
||||
range.head.ch += symbol.length
|
||||
range.anchor.ch += endSymbol ? endSymbol.length : symbol.length
|
||||
}
|
||||
editor.setSelections(ranges)
|
||||
}
|
||||
|
||||
const wrapTextWithOrJustPut = (editor: Editor, symbol: string, endSymbol?: string): void => {
|
||||
if (!editor.getSelection()) {
|
||||
const cursor = editor.getCursor()
|
||||
const lineNumber = cursor.line
|
||||
const line = editor.getLine(lineNumber)
|
||||
const replacement = /\s*\\n/.exec(line) ? `${symbol}${endSymbol ?? ''}` : `${symbol}${line}${endSymbol ?? ''}`
|
||||
editor.replaceRange(replacement, { line: cursor.line, ch: 0 }, { line: cursor.line, ch: line.length }, '+input')
|
||||
}
|
||||
wrapTextWith(editor, symbol, endSymbol ?? symbol)
|
||||
}
|
||||
|
||||
export const insertOnStartOfLines = (editor: Editor, symbol: string): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
const from = range.empty() ? { line: cursor.line, ch: 0 } : range.from()
|
||||
const to = range.empty() ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : range.to()
|
||||
const selection = editor.getRange(from, to)
|
||||
const lines = selection.split('\n')
|
||||
editor.replaceRange(lines.map((line) => `${symbol}${line}`).join('\n'), from, to, '+input')
|
||||
}
|
||||
editor.setSelections(ranges)
|
||||
}
|
||||
|
||||
export const changeLines = (editor: Editor, replaceFunction: (line: string) => string): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
const lineNumber = range.empty() ? cursor.line : range.from().line
|
||||
const line = editor.getLine(lineNumber)
|
||||
editor.replaceRange(
|
||||
replaceFunction(line),
|
||||
{ line: lineNumber, ch: 0 },
|
||||
{
|
||||
line: lineNumber,
|
||||
ch: line.length
|
||||
},
|
||||
'+input'
|
||||
)
|
||||
}
|
||||
editor.setSelections(ranges)
|
||||
}
|
||||
|
||||
export const createList = (editor: Editor, listMark: (i: number) => string): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
const from = range.empty() ? { line: cursor.line, ch: 0 } : range.from()
|
||||
const to = range.empty() ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : range.to()
|
||||
|
||||
const selection = editor.getRange(from, to)
|
||||
const lines = selection.split('\n')
|
||||
editor.replaceRange(lines.map((line, i) => `${listMark(i + 1)}${line}`).join('\n'), from, to, '+input')
|
||||
}
|
||||
editor.setSelections(ranges)
|
||||
}
|
||||
|
||||
export const addLink = (editor: Editor, prefix?: string): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
const from = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.from()
|
||||
const to = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.to()
|
||||
const selection = editor.getRange(from, to)
|
||||
const linkRegex = /^(?:https?|ftp|mailto):/
|
||||
if (linkRegex.exec(selection)) {
|
||||
editor.replaceRange(`${prefix || ''}[](${selection})`, from, to, '+input')
|
||||
} else {
|
||||
editor.replaceRange(`${prefix || ''}[${selection}](https://)`, from, to, '+input')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const insertAtCursor = (editor: Editor, text: string): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const ranges = editor.listSelections()
|
||||
for (const range of ranges) {
|
||||
const from = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.from()
|
||||
const to = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.to()
|
||||
editor.replaceRange(`${text}`, from, to, '+input')
|
||||
}
|
||||
}
|
|
@ -4,30 +4,26 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor, Position } from 'codemirror'
|
||||
import { uploadFile } from '../../../api/media'
|
||||
import { store } from '../../../redux'
|
||||
import { getGlobalState } from '../../../redux'
|
||||
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
|
||||
import { replaceInMarkdownContent } from '../../../redux/note-details/methods'
|
||||
import { replaceSelection, replaceInMarkdownContent } from '../../../redux/note-details/methods'
|
||||
import { t } from 'i18next'
|
||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||
import type { CursorSelection } from '../../../redux/editor/types'
|
||||
|
||||
/**
|
||||
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param editor The editor that should be used to show the progress
|
||||
* @param cursorFrom The position where the progress message should be placed
|
||||
* @param cursorTo An optional position that should be used to replace content in the editor
|
||||
* @param imageDescription The text that should be used in the description part of the resulting image tag
|
||||
* @param cursorSelection The position where the progress message should be placed
|
||||
* @param description The text that should be used in the description part of the resulting image tag
|
||||
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
|
||||
*/
|
||||
export const handleUpload = (
|
||||
file: File,
|
||||
editor: Editor,
|
||||
cursorFrom?: Position,
|
||||
cursorTo?: Position,
|
||||
imageDescription?: string,
|
||||
cursorSelection?: CursorSelection,
|
||||
description?: string,
|
||||
additionalUrlText?: string
|
||||
): void => {
|
||||
if (!file) {
|
||||
|
@ -37,24 +33,20 @@ export const handleUpload = (
|
|||
return
|
||||
}
|
||||
const randomId = Math.random().toString(36).slice(7)
|
||||
const uploadFileInfo =
|
||||
imageDescription !== undefined
|
||||
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: imageDescription })
|
||||
const uploadFileInfo = description
|
||||
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
|
||||
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
||||
|
||||
const uploadPlaceholder = ``
|
||||
const noteId = store.getState().noteDetails.id
|
||||
const insertCode = (replacement: string) => {
|
||||
replaceInMarkdownContent(uploadPlaceholder, replacement)
|
||||
}
|
||||
const noteId = getGlobalState().noteDetails.id
|
||||
|
||||
editor.replaceRange(uploadPlaceholder, cursorFrom ?? editor.getCursor(), cursorTo, '+input')
|
||||
replaceSelection(uploadPlaceholder, cursorSelection)
|
||||
uploadFile(noteId, file)
|
||||
.then(({ link }) => {
|
||||
insertCode(``)
|
||||
replaceInMarkdownContent(uploadPlaceholder, ``)
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
|
||||
insertCode(`![upload of ${file.name} failed]()`)
|
||||
replaceInMarkdownContent(uploadPlaceholder, `![upload of ${file.name} failed]()`)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import equal from 'fast-deep-equal'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { store } from '../../../redux'
|
||||
import { getGlobalState } from '../../../redux'
|
||||
import type { HistoryEntry } from '../../../redux/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||
import { updateLocalHistoryEntry } from '../../../redux/history/methods'
|
||||
|
@ -28,7 +28,7 @@ export const useUpdateLocalHistoryEntry = (updateReady: boolean): void => {
|
|||
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {
|
||||
return
|
||||
}
|
||||
const history = store.getState().history
|
||||
const history = getGlobalState().history
|
||||
const entry: HistoryEntry = history.find((entry) => entry.identifier === id) ?? {
|
||||
identifier: id,
|
||||
title: '',
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import React, { useCallback } from 'react'
|
||||
import sanitize from 'sanitize-filename'
|
||||
import { store } from '../../../../redux'
|
||||
import { getGlobalState } from '../../../../redux'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { download } from '../../../common/download/download'
|
||||
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
||||
|
@ -17,7 +17,7 @@ export const ExportMarkdownSidebarEntry: React.FC = () => {
|
|||
const { t } = useTranslation()
|
||||
const markdownContent = useNoteMarkdownContent()
|
||||
const onClick = useCallback(() => {
|
||||
const sanitized = sanitize(store.getState().noteDetails.noteTitle)
|
||||
const sanitized = sanitize(getGlobalState().noteDetails.noteTitle)
|
||||
download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown')
|
||||
}, [markdownContent, t])
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import { SanitizerMarkdownExtension } from '../markdown-extension/sanitizer/sani
|
|||
/**
|
||||
* Renders markdown code into react elements
|
||||
*
|
||||
* @param markdownCode The markdown code that should be rendered
|
||||
* @param markdownContentLines The markdown code lines that should be rendered
|
||||
* @param additionalMarkdownExtensions A list of {@link MarkdownExtension markdown extensions} that should be used
|
||||
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
|
||||
* @return The React DOM that represents the rendered markdown code
|
||||
|
@ -77,7 +77,6 @@ export const useConvertMarkdownToReactDom = (
|
|||
|
||||
return useMemo(() => {
|
||||
const html = markdownIt.render(markdownContentLines.join('\n'))
|
||||
|
||||
htmlToReactTransformer.resetReplacers()
|
||||
|
||||
return convertHtmlToReact(html, {
|
||||
|
|
|
@ -7,12 +7,13 @@
|
|||
import { MarkdownExtension } from './markdown-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { isDevMode } from '../../../utils/test-modes'
|
||||
|
||||
const log = new Logger('DebuggerMarkdownExtension')
|
||||
|
||||
export class DebuggerMarkdownExtension extends MarkdownExtension {
|
||||
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (isDevMode()) {
|
||||
markdownIt.core.ruler.push('printStateToConsole', (state) => {
|
||||
log.debug('Current state', state)
|
||||
return false
|
||||
|
|
|
@ -13,22 +13,25 @@ import { escapeHtml } from 'markdown-it/lib/common/utils'
|
|||
export class SpoilerMarkdownExtension extends MarkdownExtension {
|
||||
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
|
||||
|
||||
private static createSpoilerContainer(tokens: Token[], index: number): string {
|
||||
/**
|
||||
* Renders the opening and closing token of the container.
|
||||
*
|
||||
* @param tokens The tokens of the document
|
||||
* @param index The currently viewed token
|
||||
* @return The html rendering of the tokens
|
||||
*/
|
||||
private static renderSpoilerContainer(tokens: Token[], index: number): string {
|
||||
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
|
||||
|
||||
if (tokens[index].nesting === 1 && matches && matches[1]) {
|
||||
// opening tag
|
||||
return `<details><summary>${escapeHtml(matches[1])}</summary>`
|
||||
} else {
|
||||
// closing tag
|
||||
return '</details>\n'
|
||||
}
|
||||
return tokens[index].nesting === 1 && matches && matches[1]
|
||||
? `<details><summary>${escapeHtml(matches[1])}</summary>`
|
||||
: '</details>\n'
|
||||
}
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownItContainer(markdownIt, 'spoiler', {
|
||||
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
|
||||
render: SpoilerMarkdownExtension.createSpoilerContainer.bind(this)
|
||||
render: SpoilerMarkdownExtension.renderSpoilerContainer.bind(this)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,16 @@ export enum EditorConfigActionType {
|
|||
SET_SMART_PASTE = 'editor/preferences/setSmartPaste'
|
||||
}
|
||||
|
||||
export interface CursorPosition {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
|
||||
export interface CursorSelection {
|
||||
from: CursorPosition
|
||||
to?: CursorPosition
|
||||
}
|
||||
|
||||
export interface EditorConfig {
|
||||
editorMode: EditorMode
|
||||
syncScroll: boolean
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { store } from '../index'
|
||||
import { getGlobalState, store } from '../index'
|
||||
import type {
|
||||
HistoryEntry,
|
||||
HistoryExportJson,
|
||||
|
@ -69,7 +69,7 @@ export const updateLocalHistoryEntry = (noteId: string, newEntry: HistoryEntry):
|
|||
}
|
||||
|
||||
export const removeHistoryEntry = async (noteId: string): Promise<void> => {
|
||||
const entryToDelete = store.getState().history.find((entry) => entry.identifier === noteId)
|
||||
const entryToDelete = getGlobalState().history.find((entry) => entry.identifier === noteId)
|
||||
if (entryToDelete && entryToDelete.origin === HistoryEntryOrigin.REMOTE) {
|
||||
await deleteHistoryEntry(noteId)
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ export const removeHistoryEntry = async (noteId: string): Promise<void> => {
|
|||
}
|
||||
|
||||
export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> => {
|
||||
const state = store.getState().history
|
||||
const state = getGlobalState().history
|
||||
const entryToUpdate = state.find((entry) => entry.identifier === noteId)
|
||||
if (!entryToUpdate) {
|
||||
return Promise.reject(`History entry for note '${noteId}' not found`)
|
||||
|
@ -100,7 +100,7 @@ export const toggleHistoryEntryPinning = async (noteId: string): Promise<void> =
|
|||
}
|
||||
|
||||
export const downloadHistory = (): void => {
|
||||
const history = store.getState().history
|
||||
const history = getGlobalState().history
|
||||
history.forEach((entry: Partial<HistoryEntry>) => {
|
||||
delete entry.origin
|
||||
})
|
||||
|
@ -129,7 +129,7 @@ export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] =
|
|||
|
||||
export const refreshHistoryState = async (): Promise<void> => {
|
||||
const localEntries = loadLocalHistory()
|
||||
if (!store.getState().user) {
|
||||
if (!getGlobalState().user) {
|
||||
setHistoryEntries(localEntries)
|
||||
return
|
||||
}
|
||||
|
@ -143,7 +143,7 @@ export const safeRefreshHistoryState = (): void => {
|
|||
}
|
||||
|
||||
export const storeLocalHistory = (): void => {
|
||||
const history = store.getState().history
|
||||
const history = getGlobalState().history
|
||||
const localEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.LOCAL)
|
||||
const entriesWithoutOrigin = localEntries.map((entry) => ({
|
||||
...entry,
|
||||
|
@ -153,10 +153,10 @@ export const storeLocalHistory = (): void => {
|
|||
}
|
||||
|
||||
export const storeRemoteHistory = (): Promise<void> => {
|
||||
if (!store.getState().user) {
|
||||
if (!getGlobalState().user) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const history = store.getState().history
|
||||
const history = getGlobalState().history
|
||||
const remoteEntries = history.filter((entry) => entry.origin === HistoryEntryOrigin.REMOTE)
|
||||
const remoteEntryDtos = remoteEntries.map(historyEntryToHistoryEntryPutDto)
|
||||
return postHistory(remoteEntryDtos)
|
||||
|
|
|
@ -7,5 +7,8 @@
|
|||
import { createStore } from 'redux'
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'
|
||||
import { allReducers } from './reducers'
|
||||
import type { ApplicationState } from './application-state'
|
||||
|
||||
export const store = createStore(allReducers, composeWithDevTools())
|
||||
|
||||
export const getGlobalState = (): ApplicationState => store.getState()
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from './types/note-details'
|
||||
import { extractFrontmatter } from './frontmatter-extractor/extractor'
|
||||
import { initialState } from './initial-state'
|
||||
import type { PresentFrontmatterExtractionResult } from './frontmatter-extractor/types'
|
||||
import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser'
|
||||
import { generateNoteTitle } from './generate-note-title'
|
||||
|
||||
/**
|
||||
* Copies a {@link NoteDetails} but with another markdown content.
|
||||
* @param state The previous state.
|
||||
* @param markdownContent The new note markdown content consisting of the frontmatter and markdown part.
|
||||
* @return An updated {@link NoteDetails} state.
|
||||
*/
|
||||
export const buildStateFromUpdatedMarkdownContent = (state: NoteDetails, markdownContent: string): NoteDetails => {
|
||||
return buildStateFromMarkdownContentAndLines(state, markdownContent, markdownContent.split('\n'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a {@link NoteDetails} but with another markdown content.
|
||||
* @param state The previous state.
|
||||
* @param markdownContentLines The new note markdown content as separate lines consisting of the frontmatter and markdown part.
|
||||
* @return An updated {@link NoteDetails} state.
|
||||
*/
|
||||
export const buildStateFromUpdatedMarkdownContentLines = (
|
||||
state: NoteDetails,
|
||||
markdownContentLines: string[]
|
||||
): NoteDetails => {
|
||||
return buildStateFromMarkdownContentAndLines(state, markdownContentLines.join('\n'), markdownContentLines)
|
||||
}
|
||||
|
||||
const buildStateFromMarkdownContentAndLines = (
|
||||
state: NoteDetails,
|
||||
markdownContent: string,
|
||||
markdownContentLines: string[]
|
||||
): NoteDetails => {
|
||||
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
|
||||
if (frontmatterExtraction.isPresent) {
|
||||
return buildStateFromFrontmatterUpdate(
|
||||
{
|
||||
...state,
|
||||
markdownContent: markdownContent,
|
||||
markdownContentLines: markdownContentLines
|
||||
},
|
||||
frontmatterExtraction
|
||||
)
|
||||
} else {
|
||||
return {
|
||||
...state,
|
||||
markdownContent: markdownContent,
|
||||
markdownContentLines: markdownContentLines,
|
||||
rawFrontmatter: '',
|
||||
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: initialState.frontmatterRendererInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from extracted frontmatter data.
|
||||
* @param state The previous redux state.
|
||||
* @param frontmatterExtraction The result of the frontmatter extraction containing the raw data and the line offset.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromFrontmatterUpdate = (
|
||||
state: NoteDetails,
|
||||
frontmatterExtraction: PresentFrontmatterExtractionResult
|
||||
): NoteDetails => {
|
||||
if (frontmatterExtraction.rawText === state.rawFrontmatter) {
|
||||
return state
|
||||
}
|
||||
try {
|
||||
const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText)
|
||||
return {
|
||||
...state,
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: frontmatter,
|
||||
noteTitle: generateNoteTitle(frontmatter, state.firstHeading),
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
deprecatedSyntax: frontmatter.deprecatedTagsSyntax,
|
||||
frontmatterInvalid: false,
|
||||
slideOptions: frontmatter.slideOptions
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
...state,
|
||||
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
deprecatedSyntax: false,
|
||||
frontmatterInvalid: true,
|
||||
slideOptions: initialState.frontmatterRendererInfo.slideOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Mock } from 'ts-mockery'
|
||||
import * as wrapSelectionModule from './formatters/wrap-selection'
|
||||
import { applyFormatTypeToMarkdownLines } from './apply-format-type-to-markdown-lines'
|
||||
import type { CursorPosition, CursorSelection } from '../../editor/types'
|
||||
import { FormatType } from '../types'
|
||||
import * as changeCursorsToWholeLineIfNoToCursorModule from './formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import * as replaceLinesOfSelectionModule from './formatters/replace-lines-of-selection'
|
||||
import * as replaceSelectionModule from './formatters/replace-selection'
|
||||
import * as addLinkModule from './formatters/add-link'
|
||||
|
||||
describe('apply format type to markdown lines', () => {
|
||||
Mock.configure('jest')
|
||||
|
||||
const markdownContentLinesMock = ['input']
|
||||
const cursorSelectionMock = Mock.of<CursorSelection>()
|
||||
|
||||
const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection')
|
||||
const wrapSelectionMockResponse = Mock.of<string[]>()
|
||||
|
||||
const changeCursorsToWholeLineIfNoToCursorMock = jest.spyOn(
|
||||
changeCursorsToWholeLineIfNoToCursorModule,
|
||||
'changeCursorsToWholeLineIfNoToCursor'
|
||||
)
|
||||
const changeCursorsToWholeLineIfNoToCursorMockResponse = Mock.of<CursorSelection>()
|
||||
|
||||
const replaceLinesOfSelectionMock = jest.spyOn(replaceLinesOfSelectionModule, 'replaceLinesOfSelection')
|
||||
|
||||
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
|
||||
const replaceSelectionMockResponse = Mock.of<string[]>()
|
||||
|
||||
const addLinkMock = jest.spyOn(addLinkModule, 'addLink')
|
||||
const addLinkMockResponse = Mock.of<string[]>()
|
||||
|
||||
beforeAll(() => {
|
||||
wrapSelectionMock.mockReturnValue(wrapSelectionMockResponse)
|
||||
changeCursorsToWholeLineIfNoToCursorMock.mockReturnValue(changeCursorsToWholeLineIfNoToCursorMockResponse)
|
||||
replaceLinesOfSelectionMock.mockImplementation(
|
||||
(
|
||||
lines: string[],
|
||||
selection: CursorSelection,
|
||||
replacer: (line: string, lineIndex: number) => string
|
||||
): string[] => {
|
||||
return lines.map(replacer)
|
||||
}
|
||||
)
|
||||
replaceSelectionMock.mockReturnValue(replaceSelectionMockResponse)
|
||||
addLinkMock.mockReturnValue(addLinkMockResponse)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('can process the format type bold', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.BOLD)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '**', '**')
|
||||
})
|
||||
|
||||
it('can process the format type italic', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.ITALIC)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '*', '*')
|
||||
})
|
||||
|
||||
it('can process the format type strikethrough', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
cursorSelectionMock,
|
||||
FormatType.STRIKETHROUGH
|
||||
)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~~', '~~')
|
||||
})
|
||||
|
||||
it('can process the format type underline', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.UNDERLINE)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '++', '++')
|
||||
})
|
||||
|
||||
it('can process the format type subscript', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUBSCRIPT)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~', '~')
|
||||
})
|
||||
|
||||
it('can process the format type superscript', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUPERSCRIPT)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '^', '^')
|
||||
})
|
||||
|
||||
it('can process the format type highlight', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.HIGHLIGHT)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '==', '==')
|
||||
})
|
||||
|
||||
it('can process the format type code fence', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.CODE_FENCE)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
|
||||
expect(wrapSelectionMock).toBeCalledWith(
|
||||
markdownContentLinesMock,
|
||||
changeCursorsToWholeLineIfNoToCursorMockResponse,
|
||||
'```\n',
|
||||
'\n```'
|
||||
)
|
||||
})
|
||||
|
||||
it('can process the format type unordered list', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
cursorSelectionMock,
|
||||
FormatType.UNORDERED_LIST
|
||||
)
|
||||
expect(result).toEqual(['- input'])
|
||||
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
|
||||
})
|
||||
|
||||
it('can process the format type unordered list', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
cursorSelectionMock,
|
||||
FormatType.ORDERED_LIST
|
||||
)
|
||||
expect(result).toEqual(['1. input'])
|
||||
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
|
||||
})
|
||||
|
||||
it('can process the format type check list', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.CHECK_LIST)
|
||||
expect(result).toEqual(['- [ ] input'])
|
||||
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
|
||||
})
|
||||
|
||||
it('can process the format type quotes', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.QUOTES)
|
||||
expect(result).toEqual(['> input'])
|
||||
expect(replaceLinesOfSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, expect.anything())
|
||||
})
|
||||
|
||||
it('can process the format type horizontal line with only from cursor', () => {
|
||||
const fromCursor = Mock.of<CursorPosition>()
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
{ from: fromCursor },
|
||||
FormatType.HORIZONTAL_LINE
|
||||
)
|
||||
expect(result).toEqual(replaceSelectionMockResponse)
|
||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n----`)
|
||||
})
|
||||
|
||||
it('can process the format type horizontal line with from and to cursor', () => {
|
||||
const fromCursor = Mock.of<CursorPosition>()
|
||||
const toCursor = Mock.of<CursorPosition>()
|
||||
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
{ from: fromCursor, to: toCursor },
|
||||
FormatType.HORIZONTAL_LINE
|
||||
)
|
||||
expect(result).toEqual(replaceSelectionMockResponse)
|
||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n----`)
|
||||
})
|
||||
|
||||
it('can process the format type comment with only from cursor', () => {
|
||||
const fromCursor = Mock.of<CursorPosition>()
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, { from: fromCursor }, FormatType.COMMENT)
|
||||
expect(result).toEqual(replaceSelectionMockResponse)
|
||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n> []`)
|
||||
})
|
||||
|
||||
it('can process the format type comment with from and to cursor', () => {
|
||||
const fromCursor = Mock.of<CursorPosition>()
|
||||
const toCursor = Mock.of<CursorPosition>()
|
||||
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
{ from: fromCursor, to: toCursor },
|
||||
FormatType.COMMENT
|
||||
)
|
||||
expect(result).toEqual(replaceSelectionMockResponse)
|
||||
expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n> []`)
|
||||
})
|
||||
|
||||
it('can process the format type collapsible block', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
cursorSelectionMock,
|
||||
FormatType.COLLAPSIBLE_BLOCK
|
||||
)
|
||||
expect(result).toBe(wrapSelectionMockResponse)
|
||||
expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
|
||||
expect(wrapSelectionMock).toBeCalledWith(
|
||||
markdownContentLinesMock,
|
||||
changeCursorsToWholeLineIfNoToCursorMockResponse,
|
||||
':::spoiler Toggle label\n',
|
||||
'\n:::'
|
||||
)
|
||||
})
|
||||
|
||||
it('can process the format type header level with existing level', () => {
|
||||
const inputLines = ['# text']
|
||||
const result = applyFormatTypeToMarkdownLines(inputLines, cursorSelectionMock, FormatType.HEADER_LEVEL)
|
||||
expect(result).toEqual(['## text'])
|
||||
expect(replaceLinesOfSelectionMock).toBeCalledWith(inputLines, cursorSelectionMock, expect.anything())
|
||||
})
|
||||
|
||||
it('can process the format type link', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.LINK)
|
||||
expect(result).toEqual(addLinkMockResponse)
|
||||
expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock)
|
||||
})
|
||||
|
||||
it('can process the format type image link', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.IMAGE_LINK)
|
||||
expect(result).toEqual(addLinkMockResponse)
|
||||
expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '!')
|
||||
})
|
||||
|
||||
it('can process an unknown format type ', () => {
|
||||
const result = applyFormatTypeToMarkdownLines(
|
||||
markdownContentLinesMock,
|
||||
cursorSelectionMock,
|
||||
'UNKNOWN' as FormatType
|
||||
)
|
||||
expect(result).toEqual(markdownContentLinesMock)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { FormatType } from '../types'
|
||||
import { wrapSelection } from './formatters/wrap-selection'
|
||||
import { addLink } from './formatters/add-link'
|
||||
import { replaceLinesOfSelection } from './formatters/replace-lines-of-selection'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
import { changeCursorsToWholeLineIfNoToCursor } from './formatters/utils/change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import { replaceSelection } from './formatters/replace-selection'
|
||||
|
||||
export const applyFormatTypeToMarkdownLines = (
|
||||
markdownContentLines: string[],
|
||||
selection: CursorSelection,
|
||||
type: FormatType
|
||||
): string[] => {
|
||||
switch (type) {
|
||||
case FormatType.BOLD:
|
||||
return wrapSelection(markdownContentLines, selection, '**', '**')
|
||||
case FormatType.ITALIC:
|
||||
return wrapSelection(markdownContentLines, selection, '*', '*')
|
||||
case FormatType.STRIKETHROUGH:
|
||||
return wrapSelection(markdownContentLines, selection, '~~', '~~')
|
||||
case FormatType.UNDERLINE:
|
||||
return wrapSelection(markdownContentLines, selection, '++', '++')
|
||||
case FormatType.SUBSCRIPT:
|
||||
return wrapSelection(markdownContentLines, selection, '~', '~')
|
||||
case FormatType.SUPERSCRIPT:
|
||||
return wrapSelection(markdownContentLines, selection, '^', '^')
|
||||
case FormatType.HIGHLIGHT:
|
||||
return wrapSelection(markdownContentLines, selection, '==', '==')
|
||||
case FormatType.CODE_FENCE:
|
||||
return wrapSelection(
|
||||
markdownContentLines,
|
||||
changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection),
|
||||
'```\n',
|
||||
'\n```'
|
||||
)
|
||||
case FormatType.UNORDERED_LIST:
|
||||
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- ${line}`)
|
||||
case FormatType.ORDERED_LIST:
|
||||
return replaceLinesOfSelection(
|
||||
markdownContentLines,
|
||||
selection,
|
||||
(line, lineIndexInBlock) => `${lineIndexInBlock + 1}. ${line}`
|
||||
)
|
||||
case FormatType.CHECK_LIST:
|
||||
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- [ ] ${line}`)
|
||||
case FormatType.QUOTES:
|
||||
return replaceLinesOfSelection(markdownContentLines, selection, (line) => `> ${line}`)
|
||||
case FormatType.HEADER_LEVEL:
|
||||
return replaceLinesOfSelection(markdownContentLines, selection, (line) =>
|
||||
line.startsWith('#') ? `#${line}` : `# ${line}`
|
||||
)
|
||||
case FormatType.HORIZONTAL_LINE:
|
||||
return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n----')
|
||||
case FormatType.COMMENT:
|
||||
return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n> []')
|
||||
case FormatType.COLLAPSIBLE_BLOCK:
|
||||
return wrapSelection(
|
||||
markdownContentLines,
|
||||
changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection),
|
||||
':::spoiler Toggle label\n',
|
||||
'\n:::'
|
||||
)
|
||||
case FormatType.LINK:
|
||||
return addLink(markdownContentLines, selection)
|
||||
case FormatType.IMAGE_LINK:
|
||||
return addLink(markdownContentLines, selection, '!')
|
||||
default:
|
||||
return markdownContentLines
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { addLink } from './add-link'
|
||||
|
||||
describe('add link', () => {
|
||||
describe('without to-cursor', () => {
|
||||
it('inserts a link', () => {
|
||||
const actual = addLink([''], { from: { line: 0, character: 0 } }, '')
|
||||
expect(actual).toEqual(['[](https://)'])
|
||||
})
|
||||
|
||||
it('inserts a link into a line', () => {
|
||||
const actual = addLink(['aa'], { from: { line: 0, character: 1 } }, '')
|
||||
expect(actual).toEqual(['a[](https://)a'])
|
||||
})
|
||||
|
||||
it('inserts a link with a prefix', () => {
|
||||
const actual = addLink([''], { from: { line: 0, character: 0 } }, 'prefix')
|
||||
expect(actual).toEqual(['prefix[](https://)'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a normal text selected', () => {
|
||||
it('wraps the selection', () => {
|
||||
const actual = addLink(
|
||||
['a'],
|
||||
{
|
||||
from: { line: 0, character: 0 },
|
||||
to: {
|
||||
line: 0,
|
||||
character: 1
|
||||
}
|
||||
},
|
||||
''
|
||||
)
|
||||
expect(actual).toEqual(['[a](https://)'])
|
||||
})
|
||||
|
||||
it('wraps the selection inside of a line', () => {
|
||||
const actual = addLink(['aba'], { from: { line: 0, character: 1 }, to: { line: 0, character: 2 } }, '')
|
||||
expect(actual).toEqual(['a[b](https://)a'])
|
||||
})
|
||||
|
||||
it('wraps the selection with a prefix', () => {
|
||||
const actual = addLink(['a'], { from: { line: 0, character: 0 }, to: { line: 0, character: 1 } }, 'prefix')
|
||||
expect(actual).toEqual(['prefix[a](https://)'])
|
||||
})
|
||||
|
||||
it('wraps a multi line selection', () => {
|
||||
const actual = addLink(['a', 'b', 'c'], { from: { line: 0, character: 0 }, to: { line: 2, character: 1 } }, '')
|
||||
expect(actual).toEqual(['[a', 'b', 'c](https://)'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a url selected', () => {
|
||||
it('wraps the selection', () => {
|
||||
const actual = addLink(
|
||||
['https://google.com'],
|
||||
{
|
||||
from: { line: 0, character: 0 },
|
||||
to: {
|
||||
line: 0,
|
||||
character: 18
|
||||
}
|
||||
},
|
||||
''
|
||||
)
|
||||
expect(actual).toEqual(['[](https://google.com)'])
|
||||
})
|
||||
|
||||
it('wraps the selection with a prefix', () => {
|
||||
const actual = addLink(
|
||||
['https://google.com'],
|
||||
{
|
||||
from: { line: 0, character: 0 },
|
||||
to: {
|
||||
line: 0,
|
||||
character: 18
|
||||
}
|
||||
},
|
||||
'prefix'
|
||||
)
|
||||
expect(actual).toEqual(['prefix[](https://google.com)'])
|
||||
})
|
||||
|
||||
it(`wraps a multi line selection not as link`, () => {
|
||||
const actual = addLink(
|
||||
['a', 'https://google.com', 'c'],
|
||||
{ from: { line: 0, character: 0 }, to: { line: 2, character: 1 } },
|
||||
''
|
||||
)
|
||||
expect(actual).toEqual(['[a', 'https://google.com', 'c](https://)'])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { stringSplice } from './utils/string-splice'
|
||||
import type { CursorSelection } from '../../../editor/types'
|
||||
|
||||
const beforeDescription = '['
|
||||
const afterDescriptionBeforeLink = ']('
|
||||
const defaultUrl = 'https://'
|
||||
const afterLink = ')'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but inserts a new link tag.
|
||||
*
|
||||
* @param markdownContentLines The lines of the document to modify
|
||||
* @param selection If the selection has no to cursor then the tag will be inserted at this position.
|
||||
* If the selection has a to cursor then the selected text will be inserted into the description or the URL part.
|
||||
* @param prefix An optional prefix for the link
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const addLink = (markdownContentLines: string[], selection: CursorSelection, prefix = ''): string[] => {
|
||||
const from = selection.from
|
||||
const to = selection.to ?? from
|
||||
|
||||
return markdownContentLines.map((currentLine, currentLineIndex) => {
|
||||
if (from.line === to.line && currentLineIndex === from.line) {
|
||||
const selectedText = markdownContentLines[from.line].slice(from.character, to.character)
|
||||
const link = buildLink(selectedText, prefix)
|
||||
return stringSplice(currentLine, from.character, link, selectedText.length)
|
||||
} else if (currentLineIndex === from.line) {
|
||||
return stringSplice(currentLine, from.character, beforeDescription)
|
||||
} else if (currentLineIndex === to.line) {
|
||||
return stringSplice(currentLine, to.character, afterDescriptionBeforeLink + defaultUrl + afterLink)
|
||||
} else {
|
||||
return currentLine
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildLink = (selectedText: string, prefix: string): string => {
|
||||
const linkRegex = /^(?:https?|mailto):/
|
||||
if (linkRegex.test(selectedText)) {
|
||||
return prefix + beforeDescription + afterDescriptionBeforeLink + selectedText + afterLink
|
||||
} else {
|
||||
return prefix + beforeDescription + selectedText + afterDescriptionBeforeLink + defaultUrl + afterLink
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { replaceLinesOfSelection } from './replace-lines-of-selection'
|
||||
|
||||
describe('replace lines of selection', () => {
|
||||
it('replaces only the from-cursor line if no to-cursor is present', () => {
|
||||
const actual = replaceLinesOfSelection(
|
||||
['a', 'b', 'c'],
|
||||
{
|
||||
from: {
|
||||
line: 1,
|
||||
character: 123
|
||||
}
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
|
||||
)
|
||||
expect(actual).toEqual(['a', 'text_b_0', 'c'])
|
||||
})
|
||||
|
||||
it('replaces only one line if from-cursor and to-cursor are in the same line', () => {
|
||||
const actual = replaceLinesOfSelection(
|
||||
['a', 'b', 'c'],
|
||||
{
|
||||
from: {
|
||||
line: 1,
|
||||
character: 12
|
||||
},
|
||||
to: {
|
||||
line: 1,
|
||||
character: 34
|
||||
}
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
|
||||
)
|
||||
expect(actual).toEqual(['a', 'text_b_0', 'c'])
|
||||
})
|
||||
|
||||
it('replaces multiple lines', () => {
|
||||
const actual = replaceLinesOfSelection(
|
||||
['a', 'b', 'c', 'd', 'e'],
|
||||
{
|
||||
from: {
|
||||
line: 1,
|
||||
character: 1
|
||||
},
|
||||
to: {
|
||||
line: 3,
|
||||
character: 1
|
||||
}
|
||||
},
|
||||
(line, lineIndexInBlock) => `text_${line}_${lineIndexInBlock}`
|
||||
)
|
||||
expect(actual).toEqual(['a', 'text_b_0', 'text_c_1', 'text_d_2', 'e'])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CursorSelection } from '../../../editor/types'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but modifies the whole selected lines.
|
||||
*
|
||||
* @param markdownContentLines The lines of the document to modify
|
||||
* @param selection If the selection has no to cursor then only the from line will be modified.
|
||||
* If the selection has a to cursor then all lines in the selection will be modified.
|
||||
* @param replacer A function that modifies the selected lines
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const replaceLinesOfSelection = (
|
||||
markdownContentLines: string[],
|
||||
selection: CursorSelection,
|
||||
replacer: (line: string, lineIndexInBlock: number) => string
|
||||
): string[] => {
|
||||
const toLineIndex = selection.to?.line ?? selection.from.line
|
||||
return markdownContentLines.map((currentLine, currentLineIndex) => {
|
||||
if (currentLineIndex < selection.from.line || currentLineIndex > toLineIndex) {
|
||||
return currentLine
|
||||
} else {
|
||||
const lineIndexInBlock = currentLineIndex - selection.from.line
|
||||
return replacer(currentLine, lineIndexInBlock)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { replaceSelection } from './replace-selection'
|
||||
|
||||
describe('replace selection', () => {
|
||||
it('inserts a text after the from-cursor if no to-cursor is present', () => {
|
||||
const actual = replaceSelection(
|
||||
['text1'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 2
|
||||
}
|
||||
},
|
||||
'text2'
|
||||
)
|
||||
expect(actual).toEqual(['tetext2xt1'])
|
||||
})
|
||||
|
||||
it('inserts a text if from-cursor and to-cursor are the same', () => {
|
||||
const actual = replaceSelection(
|
||||
['text1'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 2
|
||||
},
|
||||
to: {
|
||||
line: 0,
|
||||
character: 2
|
||||
}
|
||||
},
|
||||
'text2'
|
||||
)
|
||||
expect(actual).toEqual(['tetext2xt1'])
|
||||
})
|
||||
|
||||
it('replaces a single line text', () => {
|
||||
const actual = replaceSelection(
|
||||
['text1', 'text2', 'text3'],
|
||||
{
|
||||
from: {
|
||||
line: 1,
|
||||
character: 1
|
||||
},
|
||||
to: {
|
||||
line: 1,
|
||||
character: 2
|
||||
}
|
||||
},
|
||||
'text4'
|
||||
)
|
||||
expect(actual).toEqual(['text1', 'ttext4xt2', 'text3'])
|
||||
})
|
||||
|
||||
it('replaces a multi line text', () => {
|
||||
const actual = replaceSelection(
|
||||
['text1', 'text2', 'text3'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 2
|
||||
},
|
||||
to: {
|
||||
line: 2,
|
||||
character: 3
|
||||
}
|
||||
},
|
||||
'text4'
|
||||
)
|
||||
expect(actual).toEqual(['tetext4', 't3'])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { stringSplice } from './utils/string-splice'
|
||||
import type { CursorPosition, CursorSelection } from '../../../editor/types'
|
||||
|
||||
/**
|
||||
* Creates a new {@link NoteDetails note state} but replaces the selected text.
|
||||
*
|
||||
* @param markdownContentLines The lines of the document to modify
|
||||
* @param selection If the selection has no to cursor then text will only be inserted.
|
||||
* If the selection has a to cursor then the selection will be replaced.
|
||||
* @param insertText The text that should be inserted
|
||||
* @return The modified state
|
||||
*/
|
||||
export const replaceSelection = (
|
||||
markdownContentLines: string[],
|
||||
selection: CursorSelection,
|
||||
insertText: string
|
||||
): string[] => {
|
||||
const fromCursor = selection.from
|
||||
const toCursor = selection.to ?? selection.from
|
||||
const processLine = fromCursor.line === toCursor.line ? processSingleLineSelection : processMultiLineSelection
|
||||
return markdownContentLines
|
||||
.map((currentLine, currentLineIndex) =>
|
||||
processLine(currentLine, currentLineIndex, insertText, fromCursor, toCursor)
|
||||
)
|
||||
.filter((currentLine, currentLineIndex) => filterLinesBetweenFromAndTo(currentLineIndex, fromCursor, toCursor))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out every line that is between the from and the to cursor.
|
||||
*
|
||||
* @param currentLineIndex The index of the current line
|
||||
* @param fromCursor The cursor position where the selection starts
|
||||
* @param toCursor The cursor position where the selection ends
|
||||
* @return {@code true} if the line should be present, {@code false} if it should be omitted.
|
||||
*/
|
||||
const filterLinesBetweenFromAndTo = (currentLineIndex: number, fromCursor: CursorPosition, toCursor: CursorPosition) =>
|
||||
currentLineIndex <= fromCursor.line || currentLineIndex >= toCursor.line
|
||||
|
||||
/**
|
||||
* Modifies a line if the selection is only in one line.
|
||||
*
|
||||
* @param line The current line content
|
||||
* @param lineIndex The index of the current line in the document
|
||||
* @param insertText The text to insert at the from cursor
|
||||
* @param fromCursor The cursor position where the selection starts
|
||||
* @param toCursor The cursor position where the selection ends
|
||||
* @return the modified line if the current line index matches the line index in the from cursor position, the unmodified line otherwise.
|
||||
*/
|
||||
const processSingleLineSelection = (
|
||||
line: string,
|
||||
lineIndex: number,
|
||||
insertText: string,
|
||||
fromCursor: CursorPosition,
|
||||
toCursor: CursorPosition
|
||||
) => {
|
||||
return lineIndex !== fromCursor.line
|
||||
? line
|
||||
: stringSplice(line, fromCursor.character, insertText, toCursor.character - fromCursor.character)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the start and the end line of a multi line selection by cutting the tail and head of these lines.
|
||||
*
|
||||
* @param line The current line content
|
||||
* @param lineIndex The index of the current line in the document
|
||||
* @param insertText The text to insert at the from cursor
|
||||
* @param fromCursor The cursor position where the selection starts
|
||||
* @param toCursor The cursor position where the selection ends
|
||||
* @return The modified line if it's the line at the from/to cursor position. The lines between will be unmodified because a filter will take care of them.
|
||||
*/
|
||||
const processMultiLineSelection = (
|
||||
line: string,
|
||||
lineIndex: number,
|
||||
insertText: string,
|
||||
fromCursor: CursorPosition,
|
||||
toCursor: CursorPosition
|
||||
) => {
|
||||
if (lineIndex === fromCursor.line) {
|
||||
return line.slice(0, fromCursor.character) + insertText
|
||||
} else if (lineIndex === toCursor.line) {
|
||||
return line.slice(toCursor.character)
|
||||
} else {
|
||||
return line
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { changeCursorsToWholeLineIfNoToCursor } from './change-cursors-to-whole-line-if-no-to-cursor'
|
||||
import type { CursorSelection } from '../../../../editor/types'
|
||||
|
||||
describe('changeCursorsToWholeLineIfNoToCursor', () => {
|
||||
it(`returns the given selection if to cursor is present`, () => {
|
||||
const givenSelection = {
|
||||
from: {
|
||||
line: 0,
|
||||
character: 0
|
||||
},
|
||||
to: {
|
||||
line: 0,
|
||||
character: 0
|
||||
}
|
||||
}
|
||||
|
||||
expect(changeCursorsToWholeLineIfNoToCursor([], givenSelection)).toEqual(givenSelection)
|
||||
})
|
||||
|
||||
it(`returns the corrected selection if to cursor isn't present and referred line does exist`, () => {
|
||||
const givenSelection = {
|
||||
from: {
|
||||
line: 0,
|
||||
character: 123
|
||||
}
|
||||
}
|
||||
|
||||
const expectedSelection: CursorSelection = {
|
||||
from: {
|
||||
line: 0,
|
||||
character: 0
|
||||
},
|
||||
to: {
|
||||
line: 0,
|
||||
character: 27
|
||||
}
|
||||
}
|
||||
|
||||
expect(changeCursorsToWholeLineIfNoToCursor([`I'm a friendly test string!`], givenSelection)).toEqual(
|
||||
expectedSelection
|
||||
)
|
||||
})
|
||||
|
||||
it(`fails if to cursor isn't present and referred line doesn't exist`, () => {
|
||||
const givenSelection = {
|
||||
from: {
|
||||
line: 1,
|
||||
character: 123
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => changeCursorsToWholeLineIfNoToCursor([''], givenSelection)).toThrow()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CursorSelection } from '../../../../editor/types'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
/**
|
||||
* If the given cursor selection has no to position then the selection will be changed to cover the whole line of the from cursor.
|
||||
*
|
||||
* @param markdownContentLines The markdown content lines that are used to calculate the line length for the to cursor
|
||||
* @param selection The selection to check
|
||||
* @return The corrected selection if no to cursor is present or the unmodified selection otherwise
|
||||
* @throws Error if the line, that the from cursor is referring to, doesn't exist.
|
||||
*/
|
||||
export const changeCursorsToWholeLineIfNoToCursor = (
|
||||
markdownContentLines: string[],
|
||||
selection: CursorSelection
|
||||
): CursorSelection =>
|
||||
selection.to !== undefined
|
||||
? selection
|
||||
: Optional.ofNullable(markdownContentLines[selection.from.line])
|
||||
.map((line) => ({
|
||||
from: {
|
||||
line: selection.from.line,
|
||||
character: 0
|
||||
},
|
||||
to: {
|
||||
line: selection.from.line,
|
||||
character: line.length
|
||||
}
|
||||
}))
|
||||
.orElseThrow(() => new Error(`No line with index ${selection.from.line} found.`))
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { stringSplice } from './string-splice'
|
||||
|
||||
describe('string splice', () => {
|
||||
it(`won't modify a string without deletion or text to add`, () => {
|
||||
expect(stringSplice('I am your friendly test string!', 0, '')).toEqual('I am your friendly test string!')
|
||||
})
|
||||
|
||||
it('can insert a string in the string', () => {
|
||||
expect(stringSplice('I am your friendly test string!', 10, 'very ')).toEqual('I am your very friendly test string!')
|
||||
})
|
||||
|
||||
it('can append a string if the index is beyond the upper bounds', () => {
|
||||
expect(stringSplice('I am your friendly test string!', 100, ' And will ever be!')).toEqual(
|
||||
'I am your friendly test string! And will ever be!'
|
||||
)
|
||||
})
|
||||
|
||||
it('can prepend a string if the index is beyond the lower bounds', () => {
|
||||
expect(stringSplice('I am your friendly test string!', -100, 'Here I come! ')).toEqual(
|
||||
'Here I come! I am your friendly test string!'
|
||||
)
|
||||
})
|
||||
|
||||
it('can delete parts of a string', () => {
|
||||
expect(stringSplice('I am your friendly test string!', 4, '', 5)).toEqual('I am friendly test string!')
|
||||
})
|
||||
|
||||
it('can delete and insert parts of a string', () => {
|
||||
expect(stringSplice('I am your friendly test string!', 10, 'great', 8)).toEqual('I am your great test string!')
|
||||
})
|
||||
|
||||
it(`will ignore a negative delete length`, () => {
|
||||
expect(stringSplice('I am your friendly test string!', 100, '', -100)).toEqual('I am your friendly test string!')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modifies a string by inserting another string and/or deleting characters.
|
||||
*
|
||||
* @param text Text to modify
|
||||
* @param changePosition The position where the other text should be inserted and characters should be deleted
|
||||
* @param textToInsert The text to insert
|
||||
* @param deleteLength The number of characters to delete
|
||||
* @return The modified string
|
||||
*/
|
||||
export const stringSplice = (
|
||||
text: string,
|
||||
changePosition: number,
|
||||
textToInsert: string,
|
||||
deleteLength?: number
|
||||
): string => {
|
||||
const correctedDeleteLength = deleteLength === undefined || deleteLength < 0 ? 0 : deleteLength
|
||||
return text.slice(0, changePosition) + textToInsert + text.slice(changePosition + correctedDeleteLength)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { wrapSelection } from './wrap-selection'
|
||||
|
||||
describe('wrap selection', () => {
|
||||
it(`doesn't modify any line if no to-cursor is present`, () => {
|
||||
const actual = wrapSelection(
|
||||
['a', 'b', 'c'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 0
|
||||
}
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
|
||||
expect(actual).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it(`wraps the selected text in the same line`, () => {
|
||||
const actual = wrapSelection(
|
||||
['a', 'b', 'c'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 0
|
||||
},
|
||||
to: {
|
||||
line: 0,
|
||||
character: 1
|
||||
}
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
|
||||
expect(actual).toEqual(['beforeaafter', 'b', 'c'])
|
||||
})
|
||||
|
||||
it(`wraps the selected text in different lines`, () => {
|
||||
const actual = wrapSelection(
|
||||
['a', 'b', 'c'],
|
||||
{
|
||||
from: {
|
||||
line: 0,
|
||||
character: 0
|
||||
},
|
||||
to: {
|
||||
line: 2,
|
||||
character: 1
|
||||
}
|
||||
},
|
||||
'before',
|
||||
'after'
|
||||
)
|
||||
|
||||
expect(actual).toEqual(['beforea', 'b', 'cafter'])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { stringSplice } from './utils/string-splice'
|
||||
import type { CursorSelection } from '../../../editor/types'
|
||||
|
||||
/**
|
||||
* Creates a copy of the given markdown content lines but wraps the selection.
|
||||
*
|
||||
* @param markdownContentLines The lines of the document to modify
|
||||
* @param selection If the selection has no to cursor then nothing will happen.
|
||||
* If the selection has a to cursor then the selected text will be wrapped.
|
||||
* @param symbolStart A text that will be inserted before the from cursor
|
||||
* @param symbolEnd A text that will be inserted after the to cursor
|
||||
* @return the modified copy of lines
|
||||
*/
|
||||
export const wrapSelection = (
|
||||
markdownContentLines: string[],
|
||||
selection: CursorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): string[] => {
|
||||
if (selection.to === undefined) {
|
||||
return markdownContentLines
|
||||
}
|
||||
|
||||
const to = selection.to ?? selection.from
|
||||
const from = selection.from
|
||||
|
||||
return markdownContentLines.map((currentLine, currentLineIndex) => {
|
||||
if (currentLineIndex === to.line) {
|
||||
if (to.line === from.line) {
|
||||
const moddedLine = stringSplice(currentLine, to.character, symbolEnd)
|
||||
return stringSplice(moddedLine, from.character, symbolStart)
|
||||
} else {
|
||||
return stringSplice(currentLine, to.character, symbolEnd)
|
||||
}
|
||||
} else if (currentLineIndex === from.line) {
|
||||
return stringSplice(currentLine, from.character, symbolStart)
|
||||
} else {
|
||||
return currentLine
|
||||
}
|
||||
})
|
||||
}
|
31
src/redux/note-details/generate-note-title.test.ts
Normal file
31
src/redux/note-details/generate-note-title.test.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { generateNoteTitle } from './generate-note-title'
|
||||
import { initialState } from './initial-state'
|
||||
|
||||
describe('generate note title', () => {
|
||||
it('will choose the frontmatter title first', () => {
|
||||
const actual = generateNoteTitle(
|
||||
{ ...initialState.frontmatter, title: 'frontmatter', opengraph: { title: 'opengraph' } },
|
||||
'first-heading'
|
||||
)
|
||||
expect(actual).toEqual('frontmatter')
|
||||
})
|
||||
|
||||
it('will choose the opengraph title second', () => {
|
||||
const actual = generateNoteTitle(
|
||||
{ ...initialState.frontmatter, opengraph: { title: 'opengraph' } },
|
||||
'first-heading'
|
||||
)
|
||||
expect(actual).toEqual('opengraph')
|
||||
})
|
||||
|
||||
it('will choose the first heading third', () => {
|
||||
const actual = generateNoteTitle({ ...initialState.frontmatter }, 'first-heading')
|
||||
expect(actual).toEqual('first-heading')
|
||||
})
|
||||
})
|
28
src/redux/note-details/generate-note-title.ts
Normal file
28
src/redux/note-details/generate-note-title.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteFrontmatter } from './types/note-details'
|
||||
|
||||
/**
|
||||
* Generates the note title from the given frontmatter or the first heading in the markdown content.
|
||||
*
|
||||
* @param frontmatter The frontmatter of the note
|
||||
* @param firstHeading The first heading in the markdown content
|
||||
* @return The title from the frontmatter or, if no title is present in the frontmatter, the first heading.
|
||||
*/
|
||||
export const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string): string => {
|
||||
if (frontmatter?.title && frontmatter?.title !== '') {
|
||||
return frontmatter.title.trim()
|
||||
} else if (
|
||||
frontmatter?.opengraph &&
|
||||
frontmatter?.opengraph.title !== undefined &&
|
||||
frontmatter?.opengraph.title !== ''
|
||||
) {
|
||||
return (frontmatter?.opengraph.title ?? firstHeading ?? '').trim()
|
||||
} else {
|
||||
return (firstHeading ?? '').trim()
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ export const initialSlideOptions: SlideOptions = {
|
|||
export const initialState: NoteDetails = {
|
||||
markdownContent: '',
|
||||
markdownContentLines: [],
|
||||
selection: { from: { line: 0, character: 0 } },
|
||||
rawFrontmatter: '',
|
||||
frontmatterRendererInfo: {
|
||||
frontmatterInvalid: false,
|
||||
|
@ -50,7 +51,7 @@ export const initialState: NoteDetails = {
|
|||
GA: '',
|
||||
disqus: '',
|
||||
type: NoteType.DOCUMENT,
|
||||
opengraph: new Map<string, string>(),
|
||||
opengraph: {},
|
||||
slideOptions: initialSlideOptions
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,13 +7,19 @@
|
|||
import { store } from '..'
|
||||
import type { NoteDto } from '../../api/notes/types'
|
||||
import type {
|
||||
AddTableAtCursorAction,
|
||||
FormatSelectionAction,
|
||||
FormatType,
|
||||
InsertTextAtCursorAction,
|
||||
ReplaceInMarkdownContentAction,
|
||||
SetNoteDetailsFromServerAction,
|
||||
SetNoteDocumentContentAction,
|
||||
UpdateCursorPositionAction,
|
||||
UpdateNoteTitleByFirstHeadingAction,
|
||||
UpdateTaskListCheckboxAction
|
||||
} from './types'
|
||||
import { NoteDetailsActionType } from './types'
|
||||
import type { CursorPosition, CursorSelection } from '../editor/types'
|
||||
|
||||
/**
|
||||
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
||||
|
@ -75,3 +81,56 @@ export const replaceInMarkdownContent = (replaceable: string, replacement: strin
|
|||
replacement
|
||||
} as ReplaceInMarkdownContentAction)
|
||||
}
|
||||
|
||||
export const updateCursorPositions = (selection: CursorSelection): void => {
|
||||
const correctedSelection: CursorSelection = isFromAfterTo(selection)
|
||||
? {
|
||||
to: selection.from,
|
||||
from: selection.to as CursorPosition
|
||||
}
|
||||
: selection
|
||||
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION,
|
||||
selection: correctedSelection
|
||||
} as UpdateCursorPositionAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the from cursor position in the given selection is after the to cursor position.
|
||||
*
|
||||
* @param selection The cursor selection to check
|
||||
* @return {@code true} if the from cursor position is after the to position
|
||||
*/
|
||||
const isFromAfterTo = (selection: CursorSelection): boolean => {
|
||||
if (selection.to === undefined) {
|
||||
return false
|
||||
}
|
||||
if (selection.from.line < selection.to.line) {
|
||||
return false
|
||||
}
|
||||
return selection.from.line !== selection.to.line || selection.from.character > selection.to.character
|
||||
}
|
||||
|
||||
export const formatSelection = (formatType: FormatType): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.FORMAT_SELECTION,
|
||||
formatType
|
||||
} as FormatSelectionAction)
|
||||
}
|
||||
|
||||
export const addTableAtCursor = (rows: number, columns: number): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.ADD_TABLE_AT_CURSOR,
|
||||
rows,
|
||||
columns
|
||||
} as AddTableAtCursorAction)
|
||||
}
|
||||
|
||||
export const replaceSelection = (text: string, cursorSelection?: CursorSelection): void => {
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.REPLACE_SELECTION,
|
||||
text,
|
||||
cursorSelection
|
||||
} as InsertTextAtCursorAction)
|
||||
}
|
||||
|
|
|
@ -45,14 +45,14 @@ describe('yaml frontmatter', () => {
|
|||
|
||||
it('should parse an empty opengraph object', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:')
|
||||
expect(noteFrontmatter.opengraph).toEqual(new Map<string, string>())
|
||||
expect(noteFrontmatter.opengraph).toEqual({})
|
||||
})
|
||||
|
||||
it('should parse an opengraph title', () => {
|
||||
const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph:
|
||||
title: Testtitle
|
||||
`)
|
||||
expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle')
|
||||
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
|
||||
})
|
||||
|
||||
it('should parse multiple opengraph values', () => {
|
||||
|
@ -61,8 +61,8 @@ describe('yaml frontmatter', () => {
|
|||
image: https://dummyimage.com/48.png
|
||||
image:type: image/png
|
||||
`)
|
||||
expect(noteFrontmatter.opengraph.get('title')).toEqual('Testtitle')
|
||||
expect(noteFrontmatter.opengraph.get('image')).toEqual('https://dummyimage.com/48.png')
|
||||
expect(noteFrontmatter.opengraph.get('image:type')).toEqual('image/png')
|
||||
expect(noteFrontmatter.opengraph.title).toEqual('Testtitle')
|
||||
expect(noteFrontmatter.opengraph.image).toEqual('https://dummyimage.com/48.png')
|
||||
expect(noteFrontmatter.opengraph['image:type']).toEqual('image/png')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
import { load } from 'js-yaml'
|
||||
import type { SlideOptions } from '../types/slide-show-options'
|
||||
import type { NoteFrontmatter } from '../types/note-details'
|
||||
import type { Iso6391Language, NoteFrontmatter, OpenGraph } from '../types/note-details'
|
||||
import { NoteTextDirection, NoteType } from '../types/note-details'
|
||||
import { ISO6391 } from '../types/iso6391'
|
||||
import type { RawNoteFrontmatter } from './types'
|
||||
import { initialSlideOptions } from '../initial-state'
|
||||
import { initialSlideOptions, initialState } from '../initial-state'
|
||||
|
||||
/**
|
||||
* Creates a new frontmatter metadata instance based on a raw yaml string.
|
||||
|
@ -37,30 +37,75 @@ const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontmatter =
|
|||
tags = rawData?.tags?.filter((tag) => tag !== null) ?? []
|
||||
deprecatedTagsSyntax = false
|
||||
} else {
|
||||
tags = []
|
||||
tags = [...initialState.frontmatter.tags]
|
||||
deprecatedTagsSyntax = false
|
||||
}
|
||||
|
||||
return {
|
||||
title: rawData.title ?? '',
|
||||
description: rawData.description ?? '',
|
||||
robots: rawData.robots ?? '',
|
||||
newlinesAreBreaks: rawData.breaks ?? true,
|
||||
GA: rawData.GA ?? '',
|
||||
disqus: rawData.disqus ?? '',
|
||||
lang: (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en',
|
||||
type: rawData.type === NoteType.SLIDE ? NoteType.SLIDE : NoteType.DOCUMENT,
|
||||
dir: rawData.dir === NoteTextDirection.LTR ? NoteTextDirection.LTR : NoteTextDirection.RTL,
|
||||
opengraph: rawData?.opengraph
|
||||
? new Map<string, string>(Object.entries(rawData.opengraph))
|
||||
: new Map<string, string>(),
|
||||
|
||||
title: rawData.title ?? initialState.frontmatter.title,
|
||||
description: rawData.description ?? initialState.frontmatter.description,
|
||||
robots: rawData.robots ?? initialState.frontmatter.robots,
|
||||
newlinesAreBreaks: rawData.breaks ?? initialState.frontmatter.newlinesAreBreaks,
|
||||
GA: rawData.GA ?? initialState.frontmatter.GA,
|
||||
disqus: rawData.disqus ?? initialState.frontmatter.disqus,
|
||||
lang: parseLanguage(rawData),
|
||||
type: parseNoteType(rawData),
|
||||
dir: parseTextDirection(rawData),
|
||||
opengraph: parseOpenGraph(rawData),
|
||||
slideOptions: parseSlideOptions(rawData),
|
||||
tags,
|
||||
deprecatedTagsSyntax
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link OpenGraph open graph} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link OpenGraph open graph}
|
||||
*/
|
||||
const parseOpenGraph = (rawData: RawNoteFrontmatter): OpenGraph => {
|
||||
return { ...(rawData.opengraph ?? initialState.frontmatter.opengraph) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link Iso6391Language iso 6391 language code} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link Iso6391Language iso 6391 language code}
|
||||
*/
|
||||
const parseLanguage = (rawData: RawNoteFrontmatter): Iso6391Language => {
|
||||
return (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? initialState.frontmatter.lang
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link NoteType note type} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link NoteType note type}
|
||||
*/
|
||||
const parseNoteType = (rawData: RawNoteFrontmatter): NoteType => {
|
||||
return rawData.type !== undefined
|
||||
? rawData.type === NoteType.SLIDE
|
||||
? NoteType.SLIDE
|
||||
: NoteType.DOCUMENT
|
||||
: initialState.frontmatter.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link NoteTextDirection note text direction} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
* @param rawData The raw note frontmatter data.
|
||||
* @return the parsed {@link NoteTextDirection note text direction}
|
||||
*/
|
||||
const parseTextDirection = (rawData: RawNoteFrontmatter): NoteTextDirection => {
|
||||
return rawData.dir !== undefined
|
||||
? rawData.dir === NoteTextDirection.LTR
|
||||
? NoteTextDirection.LTR
|
||||
: NoteTextDirection.RTL
|
||||
: initialState.frontmatter.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the {@link SlideOptions} from the {@link RawNoteFrontmatter}.
|
||||
*
|
||||
|
|
|
@ -5,23 +5,29 @@
|
|||
*/
|
||||
|
||||
import type { Reducer } from 'redux'
|
||||
import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser'
|
||||
import type { NoteDetailsActions } from './types'
|
||||
import { NoteDetailsActionType } from './types'
|
||||
import { extractFrontmatter } from './frontmatter-extractor/extractor'
|
||||
import type { NoteDto } from '../../api/notes/types'
|
||||
import { initialState } from './initial-state'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { NoteDetails, NoteFrontmatter } from './types/note-details'
|
||||
import type { PresentFrontmatterExtractionResult } from './frontmatter-extractor/types'
|
||||
import type { NoteDetails } from './types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated-markdown-content'
|
||||
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
|
||||
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
|
||||
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
|
||||
import { buildStateFromAddTableAtCursor } from './reducers/build-state-from-add-table-at-cursor'
|
||||
import { buildStateFromReplaceSelection } from './reducers/build-state-from-replace-selection'
|
||||
import { buildStateFromTaskListUpdate } from './reducers/build-state-from-task-list-update'
|
||||
import { buildStateFromSelectionFormat } from './reducers/build-state-from-selection-format'
|
||||
import { buildStateFromReplaceInMarkdownContent } from './reducers/build-state-from-replace-in-markdown-content'
|
||||
|
||||
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||
state: NoteDetails = initialState,
|
||||
action: NoteDetailsActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case NoteDetailsActionType.UPDATE_CURSOR_POSITION:
|
||||
return buildStateFromUpdateCursorPosition(state, action.selection)
|
||||
case NoteDetailsActionType.SET_DOCUMENT_CONTENT:
|
||||
return buildStateFromMarkdownContentUpdate(state, action.content)
|
||||
return buildStateFromUpdatedMarkdownContent(state, action.content)
|
||||
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
|
||||
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
|
||||
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
|
||||
|
@ -29,187 +35,14 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
|||
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
|
||||
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
|
||||
case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT:
|
||||
return buildStateFromDocumentContentReplacement(state, action.placeholder, action.replacement)
|
||||
return buildStateFromReplaceInMarkdownContent(state, action.placeholder, action.replacement)
|
||||
case NoteDetailsActionType.FORMAT_SELECTION:
|
||||
return buildStateFromSelectionFormat(state, action.formatType)
|
||||
case NoteDetailsActionType.ADD_TABLE_AT_CURSOR:
|
||||
return buildStateFromAddTableAtCursor(state, action.rows, action.columns)
|
||||
case NoteDetailsActionType.REPLACE_SELECTION:
|
||||
return buildStateFromReplaceSelection(state, action.text, action.cursorSelection)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with a modified markdown content.
|
||||
*
|
||||
* @param state The previous redux state
|
||||
* @param replaceable The string that should be replaced in the old markdown content
|
||||
* @param replacement The string that should replace the replaceable
|
||||
* @return An updated {@link NoteDetails} redux state
|
||||
*/
|
||||
const buildStateFromDocumentContentReplacement = (
|
||||
state: NoteDetails,
|
||||
replaceable: string,
|
||||
replacement: string
|
||||
): NoteDetails => {
|
||||
return buildStateFromMarkdownContentUpdate(state, state.markdownContent.replaceAll(replaceable, replacement))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||
* @param dto The first DTO received from the API containing the relevant information about the note.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromServerDto = (dto: NoteDto): NoteDetails => {
|
||||
const newState = convertNoteDtoToNoteDetails(dto)
|
||||
return buildStateFromMarkdownContentUpdate(newState, newState.markdownContent)
|
||||
}
|
||||
|
||||
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]])( .*)/
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state where a checkbox in the markdown content either gets checked or unchecked.
|
||||
* @param state The previous redux state.
|
||||
* @param changedLine The number of the line in which the checkbox should be updated.
|
||||
* @param checkboxChecked true if the checkbox should be checked, false otherwise.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromTaskListUpdate = (
|
||||
state: NoteDetails,
|
||||
changedLine: number,
|
||||
checkboxChecked: boolean
|
||||
): NoteDetails => {
|
||||
const lines = state.markdownContentLines
|
||||
const results = TASK_REGEX.exec(lines[changedLine])
|
||||
if (results) {
|
||||
const before = results[1]
|
||||
const after = results[3]
|
||||
lines[changedLine] = `${before}[${checkboxChecked ? 'x' : ' '}]${after}`
|
||||
return buildStateFromMarkdownContentUpdate(state, lines.join('\n'))
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a fresh document content.
|
||||
* @param state The previous redux state.
|
||||
* @param newMarkdownContent The fresh document content consisting of the frontmatter and markdown part.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromMarkdownContentUpdate = (state: NoteDetails, newMarkdownContent: string): NoteDetails => {
|
||||
const markdownContentLines = newMarkdownContent.split('\n')
|
||||
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
|
||||
if (frontmatterExtraction.isPresent) {
|
||||
return buildStateFromFrontmatterUpdate(
|
||||
{
|
||||
...state,
|
||||
markdownContent: newMarkdownContent,
|
||||
markdownContentLines: markdownContentLines
|
||||
},
|
||||
frontmatterExtraction
|
||||
)
|
||||
} else {
|
||||
return {
|
||||
...state,
|
||||
markdownContent: newMarkdownContent,
|
||||
markdownContentLines: markdownContentLines,
|
||||
rawFrontmatter: '',
|
||||
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: initialState.frontmatterRendererInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from extracted frontmatter data.
|
||||
* @param state The previous redux state.
|
||||
* @param frontmatterExtraction The result of the frontmatter extraction containing the raw data and the line offset.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromFrontmatterUpdate = (
|
||||
state: NoteDetails,
|
||||
frontmatterExtraction: PresentFrontmatterExtractionResult
|
||||
): NoteDetails => {
|
||||
if (frontmatterExtraction.rawText === state.rawFrontmatter) {
|
||||
return state
|
||||
}
|
||||
try {
|
||||
const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText)
|
||||
return {
|
||||
...state,
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: frontmatter,
|
||||
noteTitle: generateNoteTitle(frontmatter, state.firstHeading),
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
deprecatedSyntax: frontmatter.deprecatedTagsSyntax,
|
||||
frontmatterInvalid: false,
|
||||
slideOptions: frontmatter.slideOptions
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
...state,
|
||||
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
|
||||
rawFrontmatter: frontmatterExtraction.rawText,
|
||||
frontmatter: initialState.frontmatter,
|
||||
frontmatterRendererInfo: {
|
||||
lineOffset: frontmatterExtraction.lineOffset,
|
||||
deprecatedSyntax: false,
|
||||
frontmatterInvalid: true,
|
||||
slideOptions: initialState.frontmatterRendererInfo.slideOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with an updated note title from frontmatter data and the first heading.
|
||||
* @param state The previous redux state.
|
||||
* @param firstHeading The first heading of the document. Should be {@code undefined} if there is no such heading.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
firstHeading: firstHeading,
|
||||
noteTitle: generateNoteTitle(state.frontmatter, firstHeading)
|
||||
}
|
||||
}
|
||||
|
||||
const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string) => {
|
||||
if (frontmatter?.title && frontmatter?.title !== '') {
|
||||
return frontmatter.title.trim()
|
||||
} else if (
|
||||
frontmatter?.opengraph &&
|
||||
frontmatter?.opengraph.get('title') &&
|
||||
frontmatter?.opengraph.get('title') !== ''
|
||||
) {
|
||||
return (frontmatter?.opengraph.get('title') ?? firstHeading ?? '').trim()
|
||||
} else {
|
||||
return (firstHeading ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a note DTO from the HTTP API to a {@link NoteDetails} object.
|
||||
* Note that the documentContent will be set but the markdownContent and rawFrontmatterContent are yet to be processed.
|
||||
* @param note The NoteDTO as defined in the backend.
|
||||
* @return The NoteDetails object corresponding to the DTO.
|
||||
*/
|
||||
const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
|
||||
return {
|
||||
markdownContent: note.content,
|
||||
markdownContentLines: note.content.split('\n'),
|
||||
rawFrontmatter: '',
|
||||
frontmatterRendererInfo: initialState.frontmatterRendererInfo,
|
||||
frontmatter: initialState.frontmatter,
|
||||
id: note.metadata.id,
|
||||
noteTitle: initialState.noteTitle,
|
||||
createTime: DateTime.fromISO(note.metadata.createTime),
|
||||
lastChange: {
|
||||
username: note.metadata.updateUser.username,
|
||||
timestamp: DateTime.fromISO(note.metadata.updateTime)
|
||||
},
|
||||
firstHeading: initialState.firstHeading,
|
||||
viewCount: note.metadata.viewCount,
|
||||
alias: note.metadata.alias,
|
||||
authorship: note.metadata.editedBy
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { buildStateFromAddTableAtCursor } from './build-state-from-add-table-at-cursor'
|
||||
import { initialState } from '../initial-state'
|
||||
|
||||
describe('build state from add table at cursor', () => {
|
||||
it('fails if number of rows is negative', () => {
|
||||
expect(() =>
|
||||
buildStateFromAddTableAtCursor(
|
||||
{
|
||||
...initialState
|
||||
},
|
||||
-1,
|
||||
1
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('fails if number of columns is negative', () => {
|
||||
expect(() =>
|
||||
buildStateFromAddTableAtCursor(
|
||||
{
|
||||
...initialState
|
||||
},
|
||||
1,
|
||||
-1
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('generates a table with the correct size', () => {
|
||||
const actual = buildStateFromAddTableAtCursor(
|
||||
{
|
||||
...initialState,
|
||||
markdownContentLines: ['a', 'b', 'c'],
|
||||
markdownContent: 'a\nb\nc',
|
||||
selection: {
|
||||
from: {
|
||||
line: 1,
|
||||
character: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
3,
|
||||
3
|
||||
)
|
||||
expect(actual.markdownContent).toEqual(
|
||||
'a\n\n| # 1 | # 2 | # 3 |\n' +
|
||||
'| ---- | ---- | ---- |\n' +
|
||||
'| Text | Text | Text |\n' +
|
||||
'| Text | Text | Text |\n' +
|
||||
'| Text | Text | Text |b\n' +
|
||||
'c'
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
|
||||
import { replaceSelection } from '../format-selection/formatters/replace-selection'
|
||||
import { createNumberRangeArray } from '../../../components/common/number-range/number-range'
|
||||
|
||||
/**
|
||||
* Copies the given {@link NoteDetails note details state} but adds a markdown table with the given table at the end of the cursor selection.
|
||||
*
|
||||
* @param state The original {@link NoteDetails}
|
||||
* @param rows The number of rows of the new table
|
||||
* @param columns The number of columns of the new table
|
||||
* @return the copied but modified {@link NoteDetails note details state}
|
||||
*/
|
||||
export const buildStateFromAddTableAtCursor = (state: NoteDetails, rows: number, columns: number): NoteDetails => {
|
||||
const table = createMarkdownTable(rows, columns)
|
||||
return buildStateFromUpdatedMarkdownContentLines(
|
||||
state,
|
||||
replaceSelection(state.markdownContentLines, { from: state.selection.to ?? state.selection.from }, table)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a markdown table with the given size.
|
||||
*
|
||||
* @param rows The number of table rows
|
||||
* @param columns The number of table columns
|
||||
* @return The created markdown table
|
||||
*/
|
||||
const createMarkdownTable = (rows: number, columns: number): string => {
|
||||
const rowArray = createNumberRangeArray(rows)
|
||||
const colArray = createNumberRangeArray(columns).map((col) => col + 1)
|
||||
const head = '| # ' + colArray.join(' | # ') + ' |'
|
||||
const divider = '| ' + colArray.map(() => '----').join(' | ') + ' |'
|
||||
const body = rowArray.map(() => '| ' + colArray.map(() => 'Text').join(' | ') + ' |').join('\n')
|
||||
return `\n${head}\n${divider}\n${body}`
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as generateNoteTitleModule from '../generate-note-title'
|
||||
import { buildStateFromFirstHeadingUpdate } from './build-state-from-first-heading-update'
|
||||
import { initialState } from '../initial-state'
|
||||
|
||||
describe('build state from first heading update', () => {
|
||||
const generateNoteTitleMock = jest.spyOn(generateNoteTitleModule, 'generateNoteTitle')
|
||||
|
||||
beforeAll(() => {
|
||||
generateNoteTitleMock.mockImplementation(() => 'generated title')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
generateNoteTitleMock.mockReset()
|
||||
})
|
||||
|
||||
it('generates a new state with the given first heading', () => {
|
||||
const startState = { ...initialState, firstHeading: 'heading', noteTitle: 'noteTitle' }
|
||||
const actual = buildStateFromFirstHeadingUpdate(startState, 'new first heading')
|
||||
expect(actual).toStrictEqual({ ...initialState, firstHeading: 'new first heading', noteTitle: 'generated title' })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { generateNoteTitle } from '../generate-note-title'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with an updated note title from frontmatter data and the first heading.
|
||||
* @param state The previous redux state.
|
||||
* @param firstHeading The first heading of the document. Should be {@code undefined} if there is no such heading.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeading?: string): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
firstHeading: firstHeading,
|
||||
noteTitle: generateNoteTitle(state.frontmatter, firstHeading)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromReplaceInMarkdownContent } from './build-state-from-replace-in-markdown-content'
|
||||
import { initialState } from '../initial-state'
|
||||
|
||||
describe('build state from replace in markdown content', () => {
|
||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentModule,
|
||||
'buildStateFromUpdatedMarkdownContent'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
||||
})
|
||||
|
||||
it('updates the markdown content with the replacement', () => {
|
||||
const startState = { ...initialState, markdownContent: 'replaceable' }
|
||||
const result = buildStateFromReplaceInMarkdownContent(startState, 'replaceable', 'replacement')
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, 'replacement')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
||||
|
||||
const replaceAllExists = String.prototype.replaceAll !== undefined
|
||||
|
||||
/**
|
||||
* A replace-all string function that uses a polyfill if the environment doesn't
|
||||
* support replace-all (like node 14 for unit tests).
|
||||
* TODO: Remove polyfill when node 14 is removed
|
||||
*
|
||||
* @param haystack The string that should be modified
|
||||
* @param needle The string that should get replaced
|
||||
* @param replacement The string that should replace
|
||||
* @return The modified string
|
||||
*/
|
||||
const replaceAll = (haystack: string, needle: string, replacement: string): string =>
|
||||
replaceAllExists ? haystack.replaceAll(needle, replacement) : haystack.split(needle).join(replacement)
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with a modified markdown content.
|
||||
*
|
||||
* @param state The previous redux state
|
||||
* @param replaceable The string that should be replaced in the old markdown content
|
||||
* @param replacement The string that should replace the replaceable
|
||||
* @return An updated {@link NoteDetails} redux state
|
||||
*/
|
||||
export const buildStateFromReplaceInMarkdownContent = (
|
||||
state: NoteDetails,
|
||||
replaceable: string,
|
||||
replacement: string
|
||||
): NoteDetails => {
|
||||
return buildStateFromUpdatedMarkdownContent(state, replaceAll(state.markdownContent, replaceable, replacement))
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
||||
import * as replaceSelectionModule from '../format-selection/formatters/replace-selection'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromReplaceSelection } from './build-state-from-replace-selection'
|
||||
import { initialState } from '../initial-state'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
|
||||
describe('build state from replace selection', () => {
|
||||
const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
||||
'buildStateFromUpdatedMarkdownContentLines'
|
||||
)
|
||||
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
const mockedReplacedLines = ['replaced']
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
|
||||
replaceSelectionMock.mockImplementation(() => mockedReplacedLines)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
|
||||
replaceSelectionMock.mockReset()
|
||||
})
|
||||
|
||||
it('builds a new state with the provided cursor', () => {
|
||||
const originalLines = ['original']
|
||||
const startState = { ...initialState, markdownContentLines: originalLines }
|
||||
const customCursor = Mock.of<CursorSelection>()
|
||||
const textReplacement = 'replacement'
|
||||
|
||||
const result = buildStateFromReplaceSelection(startState, 'replacement', customCursor)
|
||||
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines)
|
||||
expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, customCursor, textReplacement)
|
||||
})
|
||||
|
||||
it('builds a new state with the state cursor', () => {
|
||||
const originalLines = ['original']
|
||||
const selection = Mock.of<CursorSelection>()
|
||||
const startState = { ...initialState, markdownContentLines: originalLines, selection }
|
||||
const textReplacement = 'replacement'
|
||||
|
||||
const result = buildStateFromReplaceSelection(startState, 'replacement')
|
||||
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines)
|
||||
expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, selection, textReplacement)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
|
||||
import { replaceSelection } from '../format-selection/formatters/replace-selection'
|
||||
|
||||
export const buildStateFromReplaceSelection = (state: NoteDetails, text: string, cursorSelection?: CursorSelection) => {
|
||||
return buildStateFromUpdatedMarkdownContentLines(
|
||||
state,
|
||||
replaceSelection(state.markdownContentLines, cursorSelection ? cursorSelection : state.selection, text)
|
||||
)
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import * as applyFormatTypeToMarkdownLinesModule from '../format-selection/apply-format-type-to-markdown-lines'
|
||||
import { buildStateFromSelectionFormat } from './build-state-from-selection-format'
|
||||
import { initialState } from '../initial-state'
|
||||
import { FormatType } from '../types'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
|
||||
describe('build state from selection format', () => {
|
||||
const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
||||
'buildStateFromUpdatedMarkdownContentLines'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
const applyFormatTypeToMarkdownLinesMock = jest.spyOn(
|
||||
applyFormatTypeToMarkdownLinesModule,
|
||||
'applyFormatTypeToMarkdownLines'
|
||||
)
|
||||
const mockedFormattedLines = ['formatted']
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
|
||||
applyFormatTypeToMarkdownLinesMock.mockImplementation(() => mockedFormattedLines)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
|
||||
applyFormatTypeToMarkdownLinesMock.mockReset()
|
||||
})
|
||||
|
||||
it('builds a new state with the formatted code', () => {
|
||||
const originalLines = ['original']
|
||||
const customCursor = Mock.of<CursorSelection>()
|
||||
const startState = { ...initialState, markdownContentLines: originalLines, selection: customCursor }
|
||||
const result = buildStateFromSelectionFormat(startState, FormatType.BOLD)
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedFormattedLines)
|
||||
expect(applyFormatTypeToMarkdownLinesMock).toHaveBeenCalledWith(originalLines, customCursor, FormatType.BOLD)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import type { FormatType } from '../types'
|
||||
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
|
||||
import { applyFormatTypeToMarkdownLines } from '../format-selection/apply-format-type-to-markdown-lines'
|
||||
|
||||
export const buildStateFromSelectionFormat = (state: NoteDetails, type: FormatType): NoteDetails => {
|
||||
return buildStateFromUpdatedMarkdownContentLines(
|
||||
state,
|
||||
applyFormatTypeToMarkdownLines(state.markdownContentLines, state.selection, type)
|
||||
)
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDto } from '../../../api/notes/types'
|
||||
import { buildStateFromServerDto } from './build-state-from-set-note-data-from-server'
|
||||
import * as buildStateFromUpdatedMarkdownContentModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { NoteTextDirection, NoteType } from '../types/note-details'
|
||||
import { DateTime } from 'luxon'
|
||||
import { initialSlideOptions } from '../initial-state'
|
||||
|
||||
describe('build state from set note data from server', () => {
|
||||
const buildStateFromUpdatedMarkdownContentMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentModule,
|
||||
'buildStateFromUpdatedMarkdownContent'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentMock.mockReset()
|
||||
})
|
||||
|
||||
it('builds a new state from the given note dto', () => {
|
||||
const noteDto: NoteDto = {
|
||||
content: 'line1\nline2',
|
||||
metadata: {
|
||||
version: 5678,
|
||||
alias: 'alias',
|
||||
id: 'id',
|
||||
createTime: '2012-05-25T09:08:34.123',
|
||||
description: 'description',
|
||||
editedBy: ['editedBy'],
|
||||
permissions: {
|
||||
owner: {
|
||||
username: 'username',
|
||||
photo: 'photo',
|
||||
email: 'email',
|
||||
displayName: 'displayName'
|
||||
},
|
||||
sharedToGroups: [
|
||||
{
|
||||
canEdit: true,
|
||||
group: {
|
||||
displayName: 'groupdisplayname',
|
||||
name: 'groupname',
|
||||
special: true
|
||||
}
|
||||
}
|
||||
],
|
||||
sharedToUsers: [
|
||||
{
|
||||
canEdit: true,
|
||||
user: {
|
||||
username: 'shareusername',
|
||||
email: 'shareemail',
|
||||
photo: 'sharephoto',
|
||||
displayName: 'sharedisplayname'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
viewCount: 987,
|
||||
tags: ['tag'],
|
||||
title: 'title',
|
||||
updateTime: '2020-05-25T09:08:34.123',
|
||||
updateUser: {
|
||||
username: 'updateusername',
|
||||
photo: 'updatephoto',
|
||||
email: 'updateemail',
|
||||
displayName: 'updatedisplayname'
|
||||
}
|
||||
},
|
||||
editedByAtPosition: [
|
||||
{
|
||||
endPos: 5,
|
||||
createdAt: 'createdAt',
|
||||
startPos: 9,
|
||||
updatedAt: 'updatedAt',
|
||||
userName: 'userName'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const convertedNoteDetails: NoteDetails = {
|
||||
frontmatter: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
deprecatedTagsSyntax: false,
|
||||
robots: '',
|
||||
lang: 'en',
|
||||
dir: NoteTextDirection.LTR,
|
||||
newlinesAreBreaks: true,
|
||||
GA: '',
|
||||
disqus: '',
|
||||
type: NoteType.DOCUMENT,
|
||||
opengraph: {},
|
||||
slideOptions: {
|
||||
transition: 'zoom',
|
||||
autoSlide: 0,
|
||||
autoSlideStoppable: true,
|
||||
backgroundTransition: 'fade',
|
||||
slideNumber: false
|
||||
}
|
||||
},
|
||||
frontmatterRendererInfo: {
|
||||
frontmatterInvalid: false,
|
||||
deprecatedSyntax: false,
|
||||
lineOffset: 0,
|
||||
slideOptions: initialSlideOptions
|
||||
},
|
||||
noteTitle: '',
|
||||
selection: { from: { line: 0, character: 0 } },
|
||||
|
||||
markdownContent: 'line1\nline2',
|
||||
markdownContentLines: ['line1', 'line2'],
|
||||
firstHeading: '',
|
||||
rawFrontmatter: '',
|
||||
id: 'id',
|
||||
createTime: DateTime.fromISO('2012-05-25T09:08:34.123'),
|
||||
lastChange: {
|
||||
username: 'updateusername',
|
||||
timestamp: DateTime.fromISO('2020-05-25T09:08:34.123')
|
||||
},
|
||||
viewCount: 987,
|
||||
alias: 'alias',
|
||||
authorship: ['editedBy']
|
||||
}
|
||||
|
||||
const result = buildStateFromServerDto(noteDto)
|
||||
expect(result).toEqual(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(convertedNoteDetails, 'line1\nline2')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDto } from '../../../api/notes/types'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
||||
import { initialState } from '../initial-state'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||
* @param dto The first DTO received from the API containing the relevant information about the note.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => {
|
||||
const newState = convertNoteDtoToNoteDetails(dto)
|
||||
return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a note DTO from the HTTP API to a {@link NoteDetails} object.
|
||||
* Note that the documentContent will be set but the markdownContent and rawFrontmatterContent are yet to be processed.
|
||||
* @param note The NoteDTO as defined in the backend.
|
||||
* @return The NoteDetails object corresponding to the DTO.
|
||||
*/
|
||||
const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
|
||||
return {
|
||||
...initialState,
|
||||
markdownContent: note.content,
|
||||
markdownContentLines: note.content.split('\n'),
|
||||
rawFrontmatter: '',
|
||||
id: note.metadata.id,
|
||||
createTime: DateTime.fromISO(note.metadata.createTime),
|
||||
lastChange: {
|
||||
username: note.metadata.updateUser.username,
|
||||
timestamp: DateTime.fromISO(note.metadata.updateTime)
|
||||
},
|
||||
viewCount: note.metadata.viewCount,
|
||||
alias: note.metadata.alias,
|
||||
authorship: note.metadata.editedBy
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { initialState } from '../initial-state'
|
||||
import * as buildStateFromUpdatedMarkdownContentLinesModule from '../build-state-from-updated-markdown-content'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromTaskListUpdate } from './build-state-from-task-list-update'
|
||||
|
||||
describe('build state from task list update', () => {
|
||||
const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn(
|
||||
buildStateFromUpdatedMarkdownContentLinesModule,
|
||||
'buildStateFromUpdatedMarkdownContentLines'
|
||||
)
|
||||
const mockedNoteDetails = Mock.of<NoteDetails>()
|
||||
|
||||
beforeAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
buildStateFromUpdatedMarkdownContentLinesMock.mockReset()
|
||||
})
|
||||
|
||||
const markdownContentLines = ['no task', '- [ ] not checked', '- [x] checked']
|
||||
|
||||
it(`doesn't change the state if the line doesn't contain a task`, () => {
|
||||
const startState = { ...initialState, markdownContentLines: markdownContentLines }
|
||||
const result = buildStateFromTaskListUpdate(startState, 0, true)
|
||||
expect(result).toBe(startState)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it(`can change the state of a task to checked`, () => {
|
||||
const startState = { ...initialState, markdownContentLines: markdownContentLines }
|
||||
const result = buildStateFromTaskListUpdate(startState, 1, true)
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
|
||||
'no task',
|
||||
'- [x] not checked',
|
||||
'- [x] checked'
|
||||
])
|
||||
})
|
||||
|
||||
it(`can change the state of a task to unchecked`, () => {
|
||||
const startState = { ...initialState, markdownContentLines: markdownContentLines }
|
||||
const result = buildStateFromTaskListUpdate(startState, 2, false)
|
||||
expect(result).toBe(mockedNoteDetails)
|
||||
expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [
|
||||
'no task',
|
||||
'- [ ] not checked',
|
||||
'- [ ] checked'
|
||||
])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import Optional from 'optional-js'
|
||||
import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content'
|
||||
|
||||
const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )\[[ xX]?]( .*)/
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state where a checkbox in the markdown content either gets checked or unchecked.
|
||||
* @param state The previous redux state.
|
||||
* @param changedLineIndex The number of the line in which the checkbox should be updated.
|
||||
* @param checkboxChecked true if the checkbox should be checked, false otherwise.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromTaskListUpdate = (
|
||||
state: NoteDetails,
|
||||
changedLineIndex: number,
|
||||
checkboxChecked: boolean
|
||||
): NoteDetails => {
|
||||
const lines = [...state.markdownContentLines]
|
||||
return Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex]))
|
||||
.map((results) => {
|
||||
const [, beforeCheckbox, afterCheckbox] = results
|
||||
lines[changedLineIndex] = `${beforeCheckbox}[${checkboxChecked ? 'x' : ' '}]${afterCheckbox}`
|
||||
return buildStateFromUpdatedMarkdownContentLines(state, lines)
|
||||
})
|
||||
.orElse(state)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { initialState } from '../initial-state'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import { buildStateFromUpdateCursorPosition } from './build-state-from-update-cursor-position'
|
||||
|
||||
describe('build state from update cursor position', () => {
|
||||
it('creates a new state with the given cursor', () => {
|
||||
const state = { ...initialState }
|
||||
const selection: CursorSelection = Mock.of<CursorSelection>()
|
||||
expect(buildStateFromUpdateCursorPosition(state, selection)).toStrictEqual({ ...state, selection })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
|
||||
export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
selection
|
||||
}
|
||||
}
|
|
@ -6,13 +6,39 @@
|
|||
|
||||
import type { Action } from 'redux'
|
||||
import type { NoteDto } from '../../api/notes/types'
|
||||
import type { CursorSelection } from '../editor/types'
|
||||
|
||||
export enum NoteDetailsActionType {
|
||||
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
||||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox',
|
||||
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content'
|
||||
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition',
|
||||
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content',
|
||||
FORMAT_SELECTION = 'note-details/format-selection',
|
||||
ADD_TABLE_AT_CURSOR = 'note-details/add-table-at-cursor',
|
||||
REPLACE_SELECTION = 'note-details/replace-selection'
|
||||
}
|
||||
|
||||
export enum FormatType {
|
||||
BOLD = 'bold',
|
||||
ITALIC = 'italic',
|
||||
STRIKETHROUGH = 'strikethrough',
|
||||
UNDERLINE = 'underline',
|
||||
SUBSCRIPT = 'subscript',
|
||||
SUPERSCRIPT = 'superscript',
|
||||
HIGHLIGHT = 'highlight',
|
||||
CODE_FENCE = 'code',
|
||||
UNORDERED_LIST = 'unorderedList',
|
||||
ORDERED_LIST = 'orderedList',
|
||||
CHECK_LIST = 'checkList',
|
||||
QUOTES = 'blockquote',
|
||||
HORIZONTAL_LINE = 'horizontalLine',
|
||||
COMMENT = 'comment',
|
||||
COLLAPSIBLE_BLOCK = 'collapsibleBlock',
|
||||
HEADER_LEVEL = 'header',
|
||||
LINK = 'link',
|
||||
IMAGE_LINK = 'imageLink'
|
||||
}
|
||||
|
||||
export type NoteDetailsActions =
|
||||
|
@ -20,7 +46,11 @@ export type NoteDetailsActions =
|
|||
| SetNoteDetailsFromServerAction
|
||||
| UpdateNoteTitleByFirstHeadingAction
|
||||
| UpdateTaskListCheckboxAction
|
||||
| UpdateCursorPositionAction
|
||||
| ReplaceInMarkdownContentAction
|
||||
| FormatSelectionAction
|
||||
| AddTableAtCursorAction
|
||||
| InsertTextAtCursorAction
|
||||
|
||||
/**
|
||||
* Action for updating the document content of the currently loaded note.
|
||||
|
@ -60,3 +90,25 @@ export interface ReplaceInMarkdownContentAction extends Action<NoteDetailsAction
|
|||
placeholder: string
|
||||
replacement: string
|
||||
}
|
||||
|
||||
export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
|
||||
selection: CursorSelection
|
||||
}
|
||||
|
||||
export interface FormatSelectionAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.FORMAT_SELECTION
|
||||
formatType: FormatType
|
||||
}
|
||||
|
||||
export interface AddTableAtCursorAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.ADD_TABLE_AT_CURSOR
|
||||
rows: number
|
||||
columns: number
|
||||
}
|
||||
|
||||
export interface InsertTextAtCursorAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.REPLACE_SELECTION
|
||||
text: string
|
||||
cursorSelection?: CursorSelection
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import type { DateTime } from 'luxon'
|
||||
import type { SlideOptions } from './slide-show-options'
|
||||
import type { ISO6391 } from './iso6391'
|
||||
import type { CursorSelection } from '../../editor/types'
|
||||
|
||||
/**
|
||||
* Redux state containing the currently loaded note with its content and metadata.
|
||||
|
@ -14,6 +15,7 @@ import type { ISO6391 } from './iso6391'
|
|||
export interface NoteDetails {
|
||||
markdownContent: string
|
||||
markdownContentLines: string[]
|
||||
selection: CursorSelection
|
||||
rawFrontmatter: string
|
||||
frontmatter: NoteFrontmatter
|
||||
frontmatterRendererInfo: RendererFrontmatterInfo
|
||||
|
@ -30,19 +32,23 @@ export interface NoteDetails {
|
|||
firstHeading?: string
|
||||
}
|
||||
|
||||
export type Iso6391Language = typeof ISO6391[number]
|
||||
|
||||
export type OpenGraph = Record<string, string>
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
title: string
|
||||
description: string
|
||||
tags: string[]
|
||||
deprecatedTagsSyntax: boolean
|
||||
robots: string
|
||||
lang: typeof ISO6391[number]
|
||||
lang: Iso6391Language
|
||||
dir: NoteTextDirection
|
||||
newlinesAreBreaks: boolean
|
||||
GA: string
|
||||
disqus: string
|
||||
type: NoteType
|
||||
opengraph: Map<string, string>
|
||||
opengraph: OpenGraph
|
||||
slideOptions: SlideOptions
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue