From b30cc5b390cfaf8fff33efaced1895746c778ddd Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Wed, 26 Jan 2022 17:14:28 +0100 Subject: [PATCH] Move toolbar functions into redux reducer (#1763) Signed-off-by: Tilman Vatteroth --- CHANGELOG.md | 2 +- cypress/integration/toolbar.spec.ts | 232 ---- jest.config.ts | 9 +- jest.setup.ts | 11 - locales/en.json | 7 +- src/api/utils.ts | 4 +- .../common/number-range/number-range.test.ts | 21 + ...lapsable-block.ts => collapsible-block.ts} | 6 +- .../editor-pane/autocompletion/index.ts | 4 +- .../autocompletion/link-and-extra-tag.ts | 4 +- .../editor-page/editor-pane/editor-pane.tsx | 50 +- .../hooks/use-apply-scroll-state.ts | 11 +- .../hooks/use-create-status-bar-info.ts | 41 +- .../hooks/use-cursor-activity-callback.ts | 30 + .../hooks/use-on-editor-file-drop.ts | 2 +- .../hooks/use-on-editor-paste-callback.ts | 24 +- .../use-on-image-upload-from-renderer.ts | 88 +- .../editor-page/editor-pane/key-map.ts | 32 +- .../status-bar/cursor-position-info.tsx | 8 +- .../editor-pane/status-bar/status-bar.tsx | 17 +- .../emoji-picker/emoji-picker-button.tsx | 31 +- .../table-picker/table-picker-button.tsx | 24 +- .../table-size-picker-popover.tsx | 2 +- .../editor-pane/tool-bar/tool-bar.tsx | 182 +-- .../editor-pane/tool-bar/toolbar-button.tsx | 35 + .../tool-bar/upload-image-button.tsx | 24 +- .../tool-bar/utils/codefenceDetection.test.ts | 76 +- .../tool-bar/utils/codefenceDetection.ts | 31 +- .../tool-bar/utils/pasteHandlers.ts | 58 +- .../tool-bar/utils/toolbarButtonUtils.test.ts | 1214 ----------------- .../tool-bar/utils/toolbarButtonUtils.ts | 153 --- .../editor-page/editor-pane/upload-handler.ts | 36 +- .../hooks/useUpdateLocalHistoryEntry.ts | 4 +- .../export-markdown-sidebar-entry.tsx | 4 +- .../use-convert-markdown-to-react-dom.ts | 3 +- .../debugger-markdown-extension.ts | 3 +- .../spoiler-markdown-extension.ts | 21 +- src/redux/editor/types.ts | 10 + src/redux/history/methods.ts | 16 +- src/redux/index.ts | 3 + ...ild-state-from-updated-markdown-content.ts | 106 ++ ...pply-format-type-to-markdown-lines.test.ts | 237 ++++ .../apply-format-type-to-markdown-lines.ts | 76 ++ .../formatters/add-link.test.ts | 99 ++ .../format-selection/formatters/add-link.ts | 50 + .../replace-lines-of-selection.test.ts | 59 + .../formatters/replace-lines-of-selection.ts | 32 + .../formatters/replace-selection.test.ts | 77 ++ .../formatters/replace-selection.ts | 91 ++ ...sors-to-whole-line-if-no-to-cursor.test.ts | 60 + ...e-cursors-to-whole-line-if-no-to-cursor.ts | 35 + .../formatters/utils/string-splice.test.ts | 41 + .../formatters/utils/string-splice.ts | 24 + .../formatters/wrap-selection.test.ts | 65 + .../formatters/wrap-selection.ts | 47 + .../note-details/generate-note-title.test.ts | 31 + src/redux/note-details/generate-note-title.ts | 28 + src/redux/note-details/initial-state.ts | 3 +- src/redux/note-details/methods.ts | 59 + .../parser.test.ts | 10 +- .../raw-note-frontmatter-parser/parser.ts | 77 +- src/redux/note-details/reducer.ts | 207 +-- ...ild-state-from-add-table-at-cursor.test.ts | 60 + .../build-state-from-add-table-at-cursor.ts | 42 + ...ld-state-from-first-heading-update.test.ts | 26 + .../build-state-from-first-heading-update.ts | 22 + ...e-from-replace-in-markdown-content.test.ts | 34 + ...-state-from-replace-in-markdown-content.ts | 39 + ...build-state-from-replace-selection.test.ts | 59 + .../build-state-from-replace-selection.ts | 17 + .../build-state-from-selection-format.test.ts | 47 + .../build-state-from-selection-format.ts | 17 + ...ate-from-set-note-data-from-server.test.ts | 142 ++ ...ld-state-from-set-note-data-from-server.ts | 45 + .../build-state-from-task-list-update.test.ts | 58 + .../build-state-from-task-list-update.ts | 32 + ...-state-from-update-cursor-position.test.ts | 18 + ...build-state-from-update-cursor-position.ts | 15 + src/redux/note-details/types.ts | 54 +- src/redux/note-details/types/note-details.ts | 10 +- 80 files changed, 2481 insertions(+), 2303 deletions(-) delete mode 100644 cypress/integration/toolbar.spec.ts delete mode 100644 jest.setup.ts create mode 100644 src/components/common/number-range/number-range.test.ts rename src/components/editor-page/editor-pane/autocompletion/{collapsable-block.ts => collapsible-block.ts} (88%) create mode 100644 src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts create mode 100644 src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx delete mode 100644 src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.test.ts delete mode 100644 src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.ts create mode 100644 src/redux/note-details/build-state-from-updated-markdown-content.ts create mode 100644 src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.test.ts create mode 100644 src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.ts create mode 100644 src/redux/note-details/format-selection/formatters/add-link.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/add-link.ts create mode 100644 src/redux/note-details/format-selection/formatters/replace-lines-of-selection.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/replace-lines-of-selection.ts create mode 100644 src/redux/note-details/format-selection/formatters/replace-selection.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/replace-selection.ts create mode 100644 src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts create mode 100644 src/redux/note-details/format-selection/formatters/utils/string-splice.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/utils/string-splice.ts create mode 100644 src/redux/note-details/format-selection/formatters/wrap-selection.test.ts create mode 100644 src/redux/note-details/format-selection/formatters/wrap-selection.ts create mode 100644 src/redux/note-details/generate-note-title.test.ts create mode 100644 src/redux/note-details/generate-note-title.ts create mode 100644 src/redux/note-details/reducers/build-state-from-add-table-at-cursor.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-add-table-at-cursor.ts create mode 100644 src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-first-heading-update.ts create mode 100644 src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.ts create mode 100644 src/redux/note-details/reducers/build-state-from-replace-selection.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-replace-selection.ts create mode 100644 src/redux/note-details/reducers/build-state-from-selection-format.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-selection-format.ts create mode 100644 src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts create mode 100644 src/redux/note-details/reducers/build-state-from-task-list-update.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-task-list-update.ts create mode 100644 src/redux/note-details/reducers/build-state-from-update-cursor-position.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-update-cursor-position.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 478ef4e6b..753e6d0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0 - HedgeDoc instances can be branded either with a '@ \' or '@ \' 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 " { - 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', `![${testText}](https://)`) - }) - }) - - 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}![](https://)`) - }) - }) - - 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', `![](${testLink})`) - }) - }) - - 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') - }) - }) -}) diff --git a/jest.config.ts b/jest.config.ts index 066e2e373..0ed8a7582 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -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: ['/jest.setup.ts'], + setupFilesAfterEnv: [ + '@testing-library/jest-dom/extend-expect' + ], moduleNameMapper: { // Handle module aliases (this will be automatically configured for you soon) - '^@/components/(.*)$': '/components/$1', + '^@/components/(.*)$': '/src/components/$1', }, + roots: ["/src"], testPathIgnorePatterns: ["/node_modules/", "/cypress/"] } diff --git a/jest.setup.ts b/jest.setup.ts deleted file mode 100644 index 8f458b570..000000000 --- a/jest.setup.ts +++ /dev/null @@ -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' diff --git a/locales/en.json b/locales/en.json index d923f4efe..c651660be 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" diff --git a/src/api/utils.ts b/src/api/utils.ts index e79492c97..ea1010e1f 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { store } from '../redux' +import { getGlobalState } from '../redux' export const defaultFetchConfig: Partial = { mode: 'cors', @@ -19,7 +19,7 @@ export const defaultFetchConfig: Partial = { } export const getApiUrl = (): string => { - return store.getState().apiUrl.apiUrl + return getGlobalState().apiUrl.apiUrl } export const expectResponseCode = (response: Response, code = 200): void => { diff --git a/src/components/common/number-range/number-range.test.ts b/src/components/common/number-range/number-range.test.ts new file mode 100644 index 000000000..c8d365ce9 --- /dev/null +++ b/src/components/common/number-range/number-range.test.ts @@ -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() + }) +}) diff --git a/src/components/editor-page/editor-pane/autocompletion/collapsable-block.ts b/src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts similarity index 88% rename from src/components/editor-page/editor-pane/autocompletion/collapsable-block.ts rename to src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts index 091548b77..b465d40df 100644 --- a/src/components/editor-page/editor-pane/autocompletion/collapsable-block.ts +++ b/src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts @@ -11,7 +11,7 @@ import { findWordAtCursor } from './index' const wordRegExp = /^( => { +const collapsibleBlockHint = (editor: Editor): Promise => { return new Promise((resolve) => { const searchTerm = findWordAtCursor(editor) const searchResult = wordRegExp.exec(searchTerm.text) @@ -37,7 +37,7 @@ const collapsableBlockHint = (editor: Editor): Promise => { }) } -export const CollapsableBlockHinter: Hinter = { +export const CollapsibleBlockHinter: Hinter = { wordRegExp, - hint: collapsableBlockHint + hint: collapsibleBlockHint } diff --git a/src/components/editor-page/editor-pane/autocompletion/index.ts b/src/components/editor-page/editor-pane/autocompletion/index.ts index af84e7043..446a08230 100644 --- a/src/components/editor-page/editor-pane/autocompletion/index.ts +++ b/src/components/editor-page/editor-pane/autocompletion/index.ts @@ -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 ] diff --git a/src/components/editor-page/editor-pane/autocompletion/link-and-extra-tag.ts b/src/components/editor-page/editor-pane/autocompletion/link-and-extra-tag.ts index f85dc48d7..0f7e4b1bf 100644 --- a/src/components/editor-page/editor-pane/autocompletion/link-and-extra-tag.ts +++ b/src/components/editor-page/editor-pane/autocompletion/link-and-extra-tag.ts @@ -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' } diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index 36d158f95..7d7c3dcfb 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -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 = ({ scrollState, onScroll, onMakeScrollSource }) => { const markdownContent = useNoteMarkdownContent() - const [editor, setEditor] = useState() + const editor = useRef() const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) const onPaste = useOnEditorPasteCallback() @@ -36,38 +36,56 @@ export const EditorPane: React.FC = ({ 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(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 (
- + - +
) } diff --git a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts index e5ba8cb05..da6caa14d 100644 --- a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts +++ b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts @@ -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, + scrollState?: ScrollState +): void => { const lastScrollPosition = useRef() 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]) } diff --git a/src/components/editor-page/editor-pane/hooks/use-create-status-bar-info.ts b/src/components/editor-page/editor-pane/hooks/use-create-status-bar-info.ts index 822cc7683..5548aff48 100644 --- a/src/components/editor-page/editor-pane/hooks/use-create-status-bar-info.ts +++ b/src/components/editor-page/editor-pane/hooks/use-create-status-bar-info.ts @@ -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]) } diff --git a/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts b/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts new file mode 100644 index 000000000..3580bce4f --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts @@ -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 + }) + }, []) +} diff --git a/src/components/editor-page/editor-pane/hooks/use-on-editor-file-drop.ts b/src/components/editor-page/editor-pane/hooks/use-on-editor-file-drop.ts index 8cfffcf8e..917f271bb 100644 --- a/src/components/editor-page/editor-pane/hooks/use-on-editor-file-drop.ts +++ b/src/components/editor-page/editor-pane/hooks/use-on-editor-file-drop.ts @@ -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]) } }, []) } diff --git a/src/components/editor-page/editor-pane/hooks/use-on-editor-paste-callback.ts b/src/components/editor-page/editor-pane/hooks/use-on-editor-paste-callback.ts index caf798ffb..e9c2112d2 100644 --- a/src/components/editor-page/editor-pane/hooks/use-on-editor-paste-callback.ts +++ b/src/components/editor-page/editor-pane/hooks/use-on-editor-paste-callback.ts @@ -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) => { - if (!event || !event.clipboardData) { - return - } - if (smartPasteEnabled && handleTablePaste(event, pasteEditor)) { - return - } - handleFilePaste(event, pasteEditor) - }, - [smartPasteEnabled] - ) + return useCallback((pasteEditor: Editor, event: PasteEvent) => { + if (!event || !event.clipboardData) { + return + } + if (handleTablePaste(event) || handleFilePaste(event)) { + event.preventDefault() + return + } + }, []) } diff --git a/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts b/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts index 92290fc48..d960c0de2 100644 --- a/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts +++ b/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts @@ -8,56 +8,47 @@ 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) => { - 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 - } + useCallback((values: ImageUploadMessage) => { + const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values + if (!dataUri.startsWith('data:image/')) { + log.error('Received uri is no data uri and image!') + return + } - fetch(dataUri) - .then((result) => result.blob()) - .then((blob) => { - const file = new File([blob], fileName, { type: blob.type }) - const { cursorFrom, cursorTo, description, additionalText } = Optional.ofNullable(lineIndex) - .map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine)) - .orElseGet(() => calculateInsertAtCurrentCursorPosition(editor)) - handleUpload(file, editor, cursorFrom, cursorTo, description, additionalText) - }) - .catch((error) => log.error(error)) - }, - [editor] - ) + fetch(dataUri) + .then((result) => result.blob()) + .then((blob) => { + const file = new File([blob], fileName, { type: blob.type }) + const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex) + .map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine)) + .orElseGet(() => ({})) + handleUpload(file, cursorSelection, alt, title) + }) + .catch((error) => log.error(error)) + }, []) ) } 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, - line: lineIndex + cursorSelection: { + from: { + character: startOfImageTag.index, + line: lineIndex + }, + to: { + character: startOfImageTag.index + startOfImageTag[0].length, + line: lineIndex + } }, - cursorTo: { - ch: 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 } -} diff --git a/src/components/editor-page/editor-pane/key-map.ts b/src/components/editor-page/editor-pane/key-map.ts index 5bc8ff290..3f9b08284 100644 --- a/src/components/editor-page/editor-pane/key-map.ts +++ b/src/components/editor-page/editor-pane/key-map.ts @@ -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 } } diff --git a/src/components/editor-page/editor-pane/status-bar/cursor-position-info.tsx b/src/components/editor-page/editor-pane/status-bar/cursor-position-info.tsx index 2588f66f1..fed182852 100644 --- a/src/components/editor-page/editor-pane/status-bar/cursor-position-info.tsx +++ b/src/components/editor-page/editor-pane/status-bar/cursor-position-info.tsx @@ -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 = ({ 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 ( diff --git a/src/components/editor-page/editor-pane/status-bar/status-bar.tsx b/src/components/editor-page/editor-pane/status-bar/status-bar.tsx index 27bd63042..2255bc272 100644 --- a/src/components/editor-page/editor-pane/status-bar/status-bar.tsx +++ b/src/components/editor-page/editor-pane/status-bar/status-bar.tsx @@ -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 = ({ statusBarInfo }) => { +export const StatusBar: React.FC = () => { + const statusBarInfo = useCreateStatusBarInfo() + return (
diff --git a/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx index 3f9c34232..86f989478 100644 --- a/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx @@ -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 = ({ 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 ( - { - setShowEmojiPicker(false) - addEmoji(emoji, editor) - }} - onDismiss={() => setShowEmojiPicker(false)} - /> + diff --git a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx index fd5ea2776..1bcd25adf 100644 --- a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx @@ -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 = ({ editor }) => { +export const TablePickerButton: React.FC = () => { const { t } = useTranslation() const [pickerMode, setPickerMode] = useState(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) - setPickerMode(PickerMode.INVISIBLE) - }, - [editor] - ) + const onSizeSelect = useCallback((rows: number, columns: number) => { + addTableAtCursor(rows, columns) + setPickerMode(PickerMode.INVISIBLE) + }, []) - const tableTitle = useMemo(() => t('editor.editorToolbar.table.title'), [t]) + const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t]) const button = useRef(null) diff --git a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-size-picker-popover.tsx b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-size-picker-popover.tsx index 879ee78b8..08f551469 100644 --- a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-size-picker-popover.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-size-picker-popover.tsx @@ -58,7 +58,7 @@ export const TableSizePickerPopover: React.FC = ({ {...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)} /> ) diff --git a/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx b/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx index 179dee5d9..351037273 100644 --- a/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx @@ -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 = ({ 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 ( - - - - - - + + + + + + + - - - - - - + + + + + + - - - + + + - - - - - + + + + + diff --git a/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx b/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx new file mode 100644 index 000000000..c6810a897 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx @@ -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 = ({ formatType, icon }) => { + const { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' }) + + const onClick = useCallback(() => { + formatSelection(formatType) + }, [formatType]) + + const title = useMemo(() => t(formatType), [formatType, t]) + + return ( + + ) +} diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx b/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx index 514a424ac..871d918a1 100644 --- a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx @@ -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 = ({ 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) - } - return Promise.resolve() - }, - [editor] - ) - - if (!editor) { - return null - } + const onUploadImage = useCallback((file: File) => { + handleUpload(file) + return Promise.resolve() + }, []) return ( diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts index e1cd88e39..f7742a45f 100644 --- a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts +++ b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts @@ -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({ - 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({ + 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) }) }) diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts index d70bd753e..49aa2ddd0 100644 --- a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts +++ b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts @@ -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++ - } - } - return codefenceCount % 2 === 1 +/** + * 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 +} + +/** + * 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 } diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts b/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts index 0121b8351..bab0726f6 100644 --- a/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts +++ b/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts @@ -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 + + return Optional.ofNullable(event.clipboardData.getData('text')) + .filter((pasteText) => !!pasteText && isTable(pasteText)) + .map((pasteText) => convertClipboardTableToMarkdown(pasteText)) + .map((markdownTable) => { + replaceSelection(markdownTable) + return true + }) + .orElse(false) } -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 true - } - 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) } diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.test.ts b/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.test.ts deleted file mode 100644 index 3b8467449..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.test.ts +++ /dev/null @@ -1,1214 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Position, Range } from 'codemirror' -import type CodeMirror from 'codemirror' -import type { EmojiClickEventDetail } from 'emoji-picker-element/shared' -import { Mock } from 'ts-mockery' -import { - addCodeFences, - addCollapsableBlock, - addComment, - addEmoji, - addHeaderLevel, - addImage, - addLine, - addLink, - addList, - addOrderedList, - addQuotes, - addTable, - addTaskList, - makeSelectionBold, - makeSelectionItalic, - markSelection, - strikeThroughSelection, - subscriptSelection, - superscriptSelection, - underlineSelection -} from './toolbarButtonUtils' - -Mock.configure('jest') - -const testContent = '1st line\n2nd line\n3rd line' - -interface FromTo { - to: CodeMirror.Position - from: CodeMirror.Position -} - -const buildRanges = () => { - const cursor: FromTo = { - to: Mock.of({ - ch: 0, - line: 0 - }), - from: Mock.of({ - ch: 0, - line: 0 - }) - } - - const firstLine: FromTo = { - from: Mock.of({ - ch: 0, - line: 0 - }), - to: Mock.of({ - ch: 8, - line: 0 - }) - } - - const multiline: FromTo = { - from: Mock.of({ - ch: 0, - line: 1 - }), - to: Mock.of({ - ch: 8, - line: 2 - }) - } - - const multilineOffset: FromTo = { - from: Mock.of({ - ch: 4, - line: 1 - }), - to: Mock.of({ - ch: 4, - line: 2 - }) - } - return { cursor, firstLine, multiline, multilineOffset } -} - -const buildEditor = (functions: Record) => { - return Mock.of({ - getSelection: () => testContent, - getRange: (from: Position, to: Position) => { - const lines = testContent.split('\n') - if (from.line === to.line) { - return lines[from.line].slice(from.ch, to.ch) - } - let output = lines[from.line].slice(from.ch) - for (let i = from.line + 1; i < to.line; i++) { - output += lines[from.line] - } - output += lines[to.line].slice(0, to.ch) - return output - }, - setSelections: () => undefined, - ...functions - }) -} - -const mockListSelections = (positions: FromTo, empty: boolean): (() => CodeMirror.Range[]) => { - return () => - Mock.of([ - { - anchor: positions.from, - head: positions.to, - from: () => positions.from, - to: () => positions.to, - empty: () => empty - } - ]) -} - -const expectFromToReplacement = ( - position: FromTo, - expectedReplacement: string, - done: () => void -): ((replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => void) => { - return (replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => { - expect(from).toEqual(position.from) - expect(to).toEqual(position.to) - expect(replacement).toEqual(expectedReplacement) - done() - } -} - -describe('test makeSelectionBold', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - makeSelectionBold(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '**1st line**', done) - }) - makeSelectionBold(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '**2nd line3rd line**', done) - }) - makeSelectionBold(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '**line3rd **', done) - }) - makeSelectionBold(editor) - }) -}) - -describe('test makeSelectionItalic', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - makeSelectionItalic(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '*1st line*', done) - }) - makeSelectionItalic(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '*2nd line3rd line*', done) - }) - makeSelectionItalic(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '*line3rd *', done) - }) - makeSelectionItalic(editor) - }) -}) - -describe('test underlineSelection', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - underlineSelection(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '++1st line++', done) - }) - underlineSelection(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '++2nd line3rd line++', done) - }) - underlineSelection(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '++line3rd ++', done) - }) - underlineSelection(editor) - }) -}) - -describe('test strikeThroughSelection', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - strikeThroughSelection(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '~~1st line~~', done) - }) - strikeThroughSelection(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '~~2nd line3rd line~~', done) - }) - strikeThroughSelection(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '~~line3rd ~~', done) - }) - strikeThroughSelection(editor) - }) -}) - -describe('test subscriptSelection', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - subscriptSelection(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '~1st line~', done) - }) - subscriptSelection(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '~2nd line3rd line~', done) - }) - subscriptSelection(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '~line3rd ~', done) - }) - subscriptSelection(editor) - }) -}) - -describe('test superscriptSelection', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - superscriptSelection(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '^1st line^', done) - }) - superscriptSelection(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '^2nd line3rd line^', done) - }) - superscriptSelection(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '^line3rd ^', done) - }) - superscriptSelection(editor) - }) -}) - -describe('test markSelection', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true) - }) - markSelection(editor) - done() - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '==1st line==', done) - }) - markSelection(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '==2nd line3rd line==', done) - }) - markSelection(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '==line3rd ==', done) - }) - markSelection(editor) - }) -}) - -describe('test addHeaderLevel', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const noHeading = testContent.split('\n')[0] - const firstHeading = `# ${noHeading}` - const secondHeading = `## ${noHeading}` - - const firstLineNoHeading = testContent.split('\n')[1] - const firstLineFirstHeading = `# ${firstLineNoHeading}` - - it('no heading before', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(firstHeading) - done() - }, - getLine: (): string => noHeading - }) - addHeaderLevel(editor) - }) - - it('level one heading before', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(secondHeading) - done() - }, - getLine: (): string => firstHeading - }) - addHeaderLevel(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(firstLineFirstHeading) - done() - }, - getLine: (): string => firstLineNoHeading - }) - addHeaderLevel(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(firstLineFirstHeading) - done() - }, - getLine: (): string => firstLineNoHeading - }) - addHeaderLevel(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(firstLineFirstHeading) - done() - }, - getLine: (): string => firstLineNoHeading - }) - addHeaderLevel(editor) - }) -}) - -describe('test addCodeFences', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - it('just cursor empty line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - getSelection: () => '', - listSelections: mockListSelections(cursor, true), - getLine: (): string => '', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('```\n\n```') - done() - } - }) - addCodeFences(editor) - }) - - it('just cursor nonempty line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - getSelection: () => '', - listSelections: mockListSelections(cursor, true), - getLine: (): string => '1st line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('```\n1st line\n```') - done() - } - }) - addCodeFences(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - getSelection: () => testContent, - listSelections: mockListSelections(firstLine, false), - replaceRange: expectFromToReplacement(firstLine, '```\n1st line\n```', done) - }) - addCodeFences(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - getSelection: () => testContent, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '```\n2nd line3rd line\n```', done) - }) - addCodeFences(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - getSelection: () => testContent, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '```\nline3rd \n```', done) - }) - addCodeFences(editor) - }) -}) - -describe('test addQuotes', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`> ${textFirstLine}`) - done() - } - }) - addQuotes(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `> ${textFirstLine}`, done) - }) - addQuotes(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - replaceRange: expectFromToReplacement(multiline, '> 2nd line3rd line', done) - }) - addQuotes(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - replaceRange: expectFromToReplacement(multilineOffset, '> line3rd ', done) - }) - addQuotes(editor) - }) -}) - -describe('test unordered list', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`- ${textFirstLine}`) - done() - } - }) - addList(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `- ${textFirstLine}`, done) - }) - addList(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('- 2nd line3rd line') - done() - } - }) - addList(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('- line3rd ') - done() - } - }) - addList(editor) - }) -}) -describe('test ordered list', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`1. ${textFirstLine}`) - done() - } - }) - addOrderedList(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `1. ${textFirstLine}`, done) - }) - addOrderedList(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('1. 2nd line3rd line') - done() - } - }) - addOrderedList(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('1. line3rd ') - done() - } - }) - addOrderedList(editor) - }) -}) -describe('test todo list', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`- [ ] ${textFirstLine}`) - done() - } - }) - addTaskList(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `- [ ] ${textFirstLine}`, done) - }) - addTaskList(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('- [ ] 2nd line3rd line') - done() - } - }) - addTaskList(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('- [ ] line3rd ') - done() - } - }) - addTaskList(editor) - }) -}) - -describe('test addLink', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('[](https://)') - done() - } - }) - addLink(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `[${textFirstLine}](https://)`, done) - }) - addLink(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('[2nd line3rd line](https://)') - done() - } - }) - addLink(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('[line3rd ](https://)') - done() - } - }) - addLink(editor) - }) -}) - -describe('test addImage', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('![](https://)') - done() - } - }) - addImage(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `![${textFirstLine}](https://)`, done) - }) - addImage(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('![2nd line3rd line](https://)') - done() - } - }) - addImage(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('![line3rd ](https://)') - done() - } - }) - addImage(editor) - }) -}) - -describe('test addLine', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`${textFirstLine}\n----`) - done() - } - }) - addLine(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `${textFirstLine}\n----`, done) - }) - addLine(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n----') - done() - } - }) - addLine(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n----') - done() - } - }) - addLine(editor) - }) -}) - -describe('test collapsable block', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`${textFirstLine}\n:::spoiler Toggle label\n Toggled content\n:::`) - done() - } - }) - addCollapsableBlock(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement( - firstLine, - `${textFirstLine}\n:::spoiler Toggle label\n Toggled content\n:::`, - done - ) - }) - addCollapsableBlock(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n:::spoiler Toggle label\n Toggled content\n:::') - done() - } - }) - addCollapsableBlock(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n:::spoiler Toggle label\n Toggled content\n:::') - done() - } - }) - addCollapsableBlock(editor) - }) -}) - -describe('test addComment', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`${textFirstLine}\n> []`) - done() - } - }) - addComment(editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `${textFirstLine}\n> []`, done) - }) - addComment(editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n> []') - done() - } - }) - addComment(editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual('2nd line\n> []') - done() - } - }) - addComment(editor) - }) -}) -describe('test addTable', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - const table = '| # 1 | # 2 | # 3 |\n| ---- | ---- | ---- |\n| Text | Text | Text |' - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`${textFirstLine}\n${table}`) - done() - } - }) - addTable(editor, 1, 3) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, `${textFirstLine}\n${table}`, done) - }) - addTable(editor, 1, 3) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`2nd line\n${table}`) - done() - } - }) - addTable(editor, 1, 3) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(`2nd line\n${table}`) - done() - } - }) - addTable(editor, 1, 3) - }) -}) - -describe('test addEmoji with native emoji', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - const emoji = Mock.of({ - emoji: { - annotation: 'input numbers', - group: 8, - order: 3809, - shortcodes: ['1234'], - tags: ['1234', 'input', 'numbers'], - unicode: '🔢', - version: 0.6 - }, - unicode: '🔢' - }) - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(':1234:') - done() - } - }) - addEmoji(emoji, editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, ':1234:', done) - }) - addEmoji(emoji, editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: expectFromToReplacement(multiline, ':1234:', done) - }) - addEmoji(emoji, editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: expectFromToReplacement(multilineOffset, ':1234:', done) - }) - addEmoji(emoji, editor) - }) -}) - -describe('test addEmoji with native emoji', () => { - const { cursor, firstLine, multiline, multilineOffset } = buildRanges() - - const textFirstLine = testContent.split('\n')[0] - const forkAwesomeIcon = ':fa-star:' - const emoji = Mock.of({ - emoji: { - name: 'fa-star', - shortcodes: ['fa-star'], - url: '/img/forkawesome.png' - }, - skinTone: 0, - name: 'fa-star' - }) - - it('just cursor', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(cursor, true), - getLine: (): string => textFirstLine, - replaceRange: (replacement: string | string[]) => { - expect(replacement).toEqual(forkAwesomeIcon) - done() - } - }) - addEmoji(emoji, editor) - }) - - it('1st line', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(firstLine, false), - getLine: (): string => textFirstLine, - replaceRange: expectFromToReplacement(firstLine, forkAwesomeIcon, done) - }) - addEmoji(emoji, editor) - }) - - it('multiple lines', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multiline, false), - getLine: (): string => '2nd line', - replaceRange: expectFromToReplacement(multiline, forkAwesomeIcon, done) - }) - addEmoji(emoji, editor) - }) - - it('multiple lines with offset', (done) => { - const editor = buildEditor({ - getCursor: () => cursor.from, - listSelections: mockListSelections(multilineOffset, false), - getLine: (): string => '2nd line', - replaceRange: expectFromToReplacement(multilineOffset, forkAwesomeIcon, done) - }) - addEmoji(emoji, editor) - }) -}) diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.ts b/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.ts deleted file mode 100644 index 56958c60b..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/utils/toolbarButtonUtils.ts +++ /dev/null @@ -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') - } -} diff --git a/src/components/editor-page/editor-pane/upload-handler.ts b/src/components/editor-page/editor-pane/upload-handler.ts index dfd05f0da..13018be81 100644 --- a/src/components/editor-page/editor-pane/upload-handler.ts +++ b/src/components/editor-page/editor-pane/upload-handler.ts @@ -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 }) - : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name }) + const uploadFileInfo = description + ? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description }) + : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name }) const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})` - 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(`![${imageDescription ?? ''}](${link}${additionalUrlText ?? ''})`) + replaceInMarkdownContent(uploadPlaceholder, `![${description ?? ''}](${link}${additionalUrlText ?? ''})`) }) .catch((error: Error) => { showErrorNotification('editor.upload.failed', { fileName: file.name })(error) - insertCode(`![upload of ${file.name} failed]()`) + replaceInMarkdownContent(uploadPlaceholder, `![upload of ${file.name} failed]()`) }) } diff --git a/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts b/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts index 7052ef777..983473eb3 100644 --- a/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts +++ b/src/components/editor-page/hooks/useUpdateLocalHistoryEntry.ts @@ -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: '', diff --git a/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx b/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx index b6d7e4dd9..96296f28b 100644 --- a/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/specific-sidebar-entries/export-markdown-sidebar-entry.tsx @@ -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]) diff --git a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts index f21330545..f0e0fa775 100644 --- a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts +++ b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts @@ -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, { diff --git a/src/components/markdown-renderer/markdown-extension/debugger-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/debugger-markdown-extension.ts index 422b28583..52e4136f7 100644 --- a/src/components/markdown-renderer/markdown-extension/debugger-markdown-extension.ts +++ b/src/components/markdown-renderer/markdown-extension/debugger-markdown-extension.ts @@ -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 diff --git a/src/components/markdown-renderer/markdown-extension/spoiler-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/spoiler-markdown-extension.ts index ad9b3fcbf..02254e656 100644 --- a/src/components/markdown-renderer/markdown-extension/spoiler-markdown-extension.ts +++ b/src/components/markdown-renderer/markdown-extension/spoiler-markdown-extension.ts @@ -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 `
${escapeHtml(matches[1])}` - } else { - // closing tag - return '
\n' - } + return tokens[index].nesting === 1 && matches && matches[1] + ? `
${escapeHtml(matches[1])}` + : '
\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) }) } } diff --git a/src/redux/editor/types.ts b/src/redux/editor/types.ts index 8e577404e..25ad05916 100644 --- a/src/redux/editor/types.ts +++ b/src/redux/editor/types.ts @@ -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 diff --git a/src/redux/history/methods.ts b/src/redux/history/methods.ts index 94cf958a6..fd61717a7 100644 --- a/src/redux/history/methods.ts +++ b/src/redux/history/methods.ts @@ -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 => { - 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 => { } export const toggleHistoryEntryPinning = async (noteId: string): Promise => { - 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 = } export const downloadHistory = (): void => { - const history = store.getState().history + const history = getGlobalState().history history.forEach((entry: Partial) => { delete entry.origin }) @@ -129,7 +129,7 @@ export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntry[] = export const refreshHistoryState = async (): Promise => { 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 => { - 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) diff --git a/src/redux/index.ts b/src/redux/index.ts index 4e98ec519..24a5f74b8 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -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() diff --git a/src/redux/note-details/build-state-from-updated-markdown-content.ts b/src/redux/note-details/build-state-from-updated-markdown-content.ts new file mode 100644 index 000000000..948068649 --- /dev/null +++ b/src/redux/note-details/build-state-from-updated-markdown-content.ts @@ -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 + } + } + } +} diff --git a/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.test.ts b/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.test.ts new file mode 100644 index 000000000..30a4b3088 --- /dev/null +++ b/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.test.ts @@ -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() + + const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection') + const wrapSelectionMockResponse = Mock.of() + + const changeCursorsToWholeLineIfNoToCursorMock = jest.spyOn( + changeCursorsToWholeLineIfNoToCursorModule, + 'changeCursorsToWholeLineIfNoToCursor' + ) + const changeCursorsToWholeLineIfNoToCursorMockResponse = Mock.of() + + const replaceLinesOfSelectionMock = jest.spyOn(replaceLinesOfSelectionModule, 'replaceLinesOfSelection') + + const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection') + const replaceSelectionMockResponse = Mock.of() + + const addLinkMock = jest.spyOn(addLinkModule, 'addLink') + const addLinkMockResponse = Mock.of() + + 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() + 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() + const toCursor = Mock.of() + + 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() + 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() + const toCursor = Mock.of() + + 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) + }) +}) diff --git a/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.ts b/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.ts new file mode 100644 index 000000000..5585808c9 --- /dev/null +++ b/src/redux/note-details/format-selection/apply-format-type-to-markdown-lines.ts @@ -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 + } +} diff --git a/src/redux/note-details/format-selection/formatters/add-link.test.ts b/src/redux/note-details/format-selection/formatters/add-link.test.ts new file mode 100644 index 000000000..0879a9f8f --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/add-link.test.ts @@ -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://)']) + }) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/add-link.ts b/src/redux/note-details/format-selection/formatters/add-link.ts new file mode 100644 index 000000000..d1e228fa8 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/add-link.ts @@ -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 + } +} diff --git a/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.test.ts b/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.test.ts new file mode 100644 index 000000000..8f2b15463 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.test.ts @@ -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']) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.ts b/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.ts new file mode 100644 index 000000000..261a7c296 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.ts @@ -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) + } + }) +} diff --git a/src/redux/note-details/format-selection/formatters/replace-selection.test.ts b/src/redux/note-details/format-selection/formatters/replace-selection.test.ts new file mode 100644 index 000000000..ec47d1af2 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/replace-selection.test.ts @@ -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']) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/replace-selection.ts b/src/redux/note-details/format-selection/formatters/replace-selection.ts new file mode 100644 index 000000000..e5bc12202 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/replace-selection.ts @@ -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 + } +} diff --git a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts b/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts new file mode 100644 index 000000000..17c9e57fd --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts @@ -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() + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts b/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts new file mode 100644 index 000000000..5cd40b967 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts @@ -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.`)) diff --git a/src/redux/note-details/format-selection/formatters/utils/string-splice.test.ts b/src/redux/note-details/format-selection/formatters/utils/string-splice.test.ts new file mode 100644 index 000000000..f126f787a --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/utils/string-splice.test.ts @@ -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!') + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/utils/string-splice.ts b/src/redux/note-details/format-selection/formatters/utils/string-splice.ts new file mode 100644 index 000000000..697f8837c --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/utils/string-splice.ts @@ -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) +} diff --git a/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts b/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts new file mode 100644 index 000000000..9e178a58b --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts @@ -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']) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/wrap-selection.ts b/src/redux/note-details/format-selection/formatters/wrap-selection.ts new file mode 100644 index 000000000..e33836035 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/wrap-selection.ts @@ -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 + } + }) +} diff --git a/src/redux/note-details/generate-note-title.test.ts b/src/redux/note-details/generate-note-title.test.ts new file mode 100644 index 000000000..ae86d119f --- /dev/null +++ b/src/redux/note-details/generate-note-title.test.ts @@ -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') + }) +}) diff --git a/src/redux/note-details/generate-note-title.ts b/src/redux/note-details/generate-note-title.ts new file mode 100644 index 000000000..dab27bfee --- /dev/null +++ b/src/redux/note-details/generate-note-title.ts @@ -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() + } +} diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts index bc8861014..f8d5907a2 100644 --- a/src/redux/note-details/initial-state.ts +++ b/src/redux/note-details/initial-state.ts @@ -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(), + opengraph: {}, slideOptions: initialSlideOptions } } diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index 22de95492..692fc5408 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -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) +} diff --git a/src/redux/note-details/raw-note-frontmatter-parser/parser.test.ts b/src/redux/note-details/raw-note-frontmatter-parser/parser.test.ts index 20a992057..2c762862e 100644 --- a/src/redux/note-details/raw-note-frontmatter-parser/parser.test.ts +++ b/src/redux/note-details/raw-note-frontmatter-parser/parser.test.ts @@ -45,14 +45,14 @@ describe('yaml frontmatter', () => { it('should parse an empty opengraph object', () => { const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:') - expect(noteFrontmatter.opengraph).toEqual(new Map()) + 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') }) }) diff --git a/src/redux/note-details/raw-note-frontmatter-parser/parser.ts b/src/redux/note-details/raw-note-frontmatter-parser/parser.ts index 810d5af0d..41305e51e 100644 --- a/src/redux/note-details/raw-note-frontmatter-parser/parser.ts +++ b/src/redux/note-details/raw-note-frontmatter-parser/parser.ts @@ -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(Object.entries(rawData.opengraph)) - : new Map(), - + 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}. * diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index 65a8d6bab..8454b002a 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -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 = ( 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 = ( 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 - } -} diff --git a/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.test.ts b/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.test.ts new file mode 100644 index 000000000..4db3cbfb5 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.test.ts @@ -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' + ) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.ts b/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.ts new file mode 100644 index 000000000..9c5174868 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-add-table-at-cursor.ts @@ -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}` +} diff --git a/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts b/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts new file mode 100644 index 000000000..8af083ec0 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-first-heading-update.test.ts @@ -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' }) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-first-heading-update.ts b/src/redux/note-details/reducers/build-state-from-first-heading-update.ts new file mode 100644 index 000000000..5665bec4f --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-first-heading-update.ts @@ -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) + } +} diff --git a/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.test.ts b/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.test.ts new file mode 100644 index 000000000..aba180d8f --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.test.ts @@ -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() + + 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') + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.ts b/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.ts new file mode 100644 index 000000000..ec617eafd --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-replace-in-markdown-content.ts @@ -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)) +} diff --git a/src/redux/note-details/reducers/build-state-from-replace-selection.test.ts b/src/redux/note-details/reducers/build-state-from-replace-selection.test.ts new file mode 100644 index 000000000..d62459526 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-replace-selection.test.ts @@ -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() + 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() + 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() + 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) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-replace-selection.ts b/src/redux/note-details/reducers/build-state-from-replace-selection.ts new file mode 100644 index 000000000..fd353b066 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-replace-selection.ts @@ -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) + ) +} diff --git a/src/redux/note-details/reducers/build-state-from-selection-format.test.ts b/src/redux/note-details/reducers/build-state-from-selection-format.test.ts new file mode 100644 index 000000000..11216429b --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-selection-format.test.ts @@ -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() + 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() + 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) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-selection-format.ts b/src/redux/note-details/reducers/build-state-from-selection-format.ts new file mode 100644 index 000000000..c7e457e5b --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-selection-format.ts @@ -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) + ) +} diff --git a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts new file mode 100644 index 000000000..8cd42d75e --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.test.ts @@ -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() + + 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') + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts new file mode 100644 index 000000000..78d2c512d --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts @@ -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 + } +} diff --git a/src/redux/note-details/reducers/build-state-from-task-list-update.test.ts b/src/redux/note-details/reducers/build-state-from-task-list-update.test.ts new file mode 100644 index 000000000..4504056cc --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-task-list-update.test.ts @@ -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() + + 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' + ]) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-task-list-update.ts b/src/redux/note-details/reducers/build-state-from-task-list-update.ts new file mode 100644 index 000000000..a82e75f93 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-task-list-update.ts @@ -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) +} diff --git a/src/redux/note-details/reducers/build-state-from-update-cursor-position.test.ts b/src/redux/note-details/reducers/build-state-from-update-cursor-position.test.ts new file mode 100644 index 000000000..5a7362076 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-update-cursor-position.test.ts @@ -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() + expect(buildStateFromUpdateCursorPosition(state, selection)).toStrictEqual({ ...state, selection }) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-update-cursor-position.ts b/src/redux/note-details/reducers/build-state-from-update-cursor-position.ts new file mode 100644 index 000000000..b9657694a --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-update-cursor-position.ts @@ -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 + } +} diff --git a/src/redux/note-details/types.ts b/src/redux/note-details/types.ts index 4174c9d07..0afc16fae 100644 --- a/src/redux/note-details/types.ts +++ b/src/redux/note-details/types.ts @@ -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 { + type: NoteDetailsActionType.UPDATE_CURSOR_POSITION + selection: CursorSelection +} + +export interface FormatSelectionAction extends Action { + type: NoteDetailsActionType.FORMAT_SELECTION + formatType: FormatType +} + +export interface AddTableAtCursorAction extends Action { + type: NoteDetailsActionType.ADD_TABLE_AT_CURSOR + rows: number + columns: number +} + +export interface InsertTextAtCursorAction extends Action { + type: NoteDetailsActionType.REPLACE_SELECTION + text: string + cursorSelection?: CursorSelection +} diff --git a/src/redux/note-details/types/note-details.ts b/src/redux/note-details/types/note-details.ts index b3d197204..fdb16c8d6 100644 --- a/src/redux/note-details/types/note-details.ts +++ b/src/redux/note-details/types/note-details.ts @@ -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 + 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 + opengraph: OpenGraph slideOptions: SlideOptions }