diff --git a/cypress/integration/autocompletion.spec.ts b/cypress/integration/autocompletion.spec.ts deleted file mode 100644 index 26f3f4b20..000000000 --- a/cypress/integration/autocompletion.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -describe('Autocompletion works for', () => { - beforeEach(() => { - cy.visitTestNote() - cy.get('.CodeMirror').click().get('textarea').as('codeinput') - }) - - describe('code block', () => { - it('via enter', () => { - cy.setCodemirrorContent('```') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains('```abnf') - cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('```') - cy.getMarkdownBody().findByCypressId('highlighted-code-block').should('exist') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent('```') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains('```abnf') - cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('```') - cy.getMarkdownBody().findByCypressId('highlighted-code-block').should('exist') - }) - }) - - describe('container', () => { - it('via enter', () => { - cy.setCodemirrorContent(':::') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains(':::success') - cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('::: ') - cy.getMarkdownBody().find('.alert').should('exist') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent(':::') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains(':::success') - cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('::: ') - cy.getMarkdownBody().find('.alert').should('exist') - }) - }) - - describe('emoji', () => { - describe('normal emoji', () => { - it('via enter', () => { - cy.setCodemirrorContent(':hedg') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains(':hedgehog:') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent(':hedg') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains(':hedgehog:') - }) - }) - - describe('fork-awesome-icon', () => { - it('via enter', () => { - cy.setCodemirrorContent(':fa-face') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains(':fa-facebook:') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent(':fa-face') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains(':fa-facebook:') - }) - }) - }) - - describe('header', () => { - it('via enter', () => { - cy.setCodemirrorContent('#') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('# ') - cy.getMarkdownBody().find('h1').should('have.text', '\n ') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent('#') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('# ') - cy.getMarkdownBody().find('h1').should('have.text', '\n ') - }) - }) - - describe('images', () => { - it('via enter', () => { - cy.setCodemirrorContent('!') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('![image alt](https:// "title")') - cy.getMarkdownBody().findByCypressId('image-placeholder-image-drop').should('exist') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent('!') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('![image alt](https:// "title")') - cy.getMarkdownBody().findByCypressId('image-placeholder-image-drop').should('exist') - }) - }) - - describe('links', () => { - it('via enter', () => { - cy.setCodemirrorContent('[') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('[link text](https:// "title") ') - cy.getMarkdownBody() - .find('p > a') - .should('have.text', 'link text') - .should('have.attr', 'href', 'https://') - .should('have.attr', 'title', 'title') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent('[') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('[link text](https:// "title") ') - cy.getMarkdownBody() - .find('p > a') - .should('have.text', 'link text') - .should('have.attr', 'href', 'https://') - .should('have.attr', 'title', 'title') - }) - }) - - describe('pdf', () => { - it('via enter', () => { - cy.setCodemirrorContent('{') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('@codeinput').type('{enter}') - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('{%pdf https:// %}') - cy.getMarkdownBody().find('p').should('exist') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent('{') - cy.get('.CodeMirror-hints').should('be.visible') - cy.get('.CodeMirror-hints > li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('{%pdf https:// %}') - cy.getMarkdownBody().find('p').should('exist') - }) - }) - - describe('collapsible blocks', () => { - it('via enter', () => { - cy.setCodemirrorContent('') // after selecting the hint, the last line of the inserted suggestion is active - cy.getMarkdownBody().find('details').should('exist') - }) - it('via doubleclick', () => { - cy.setCodemirrorContent(' li').first().dblclick() - cy.get('.CodeMirror-hints').should('not.exist') - cy.get('.CodeMirror-activeline').contains('') - cy.getMarkdownBody().find('details').should('exist') - }) - }) -}) diff --git a/cypress/integration/documentTitle.spec.ts b/cypress/integration/documentTitle.spec.ts index 2ecd67a9e..65f26fe87 100644 --- a/cypress/integration/documentTitle.spec.ts +++ b/cypress/integration/documentTitle.spec.ts @@ -76,7 +76,8 @@ describe('Document Title', () => { it('katex code looks right', () => { cy.setCodemirrorContent(`# $\\alpha$-foo`) cy.getIframeBody().find('h1').should('contain', 'α') - cy.get('.CodeMirror textarea').type('{Enter}{Enter}{Enter}{Enter}{Enter}') //This is a workaround because I don't know how to make sure, that the title gets updated in time. + //TODO: Remove workaround after https://github.com/hedgedoc/react-client/issues/1816 has been fixed. + cy.get('.cm-editor .cm-content').type('{Enter}{Enter}{Enter}{Enter}{Enter}') cy.title().should('eq', `α-foo - HedgeDoc @ ${branding.name}`) }) }) diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index faa409ce8..9570a75b6 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -12,19 +12,6 @@ describe('File upload', () => { cy.fixture('demo.png').as('demoImage') }) - it("doesn't prevent drag'n'drop of plain text", () => { - const dataTransfer = new DataTransfer() - cy.setCodemirrorContent('line 1\nline 2\ndragline') - cy.get('.CodeMirror').click() - cy.get('.CodeMirror-line > span').last().dblclick() - cy.get('.CodeMirror-line > span > .cm-matchhighlight').trigger('dragstart', { dataTransfer }) - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').trigger('drop', { dataTransfer }) - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should( - 'have.text', - 'lindraglinee 1' - ) - }) - describe('works', () => { beforeEach(() => { cy.intercept( @@ -50,7 +37,7 @@ describe('File upload', () => { }, { force: true } ) - cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`) + cy.get('.cm-line').contains(`![](${imageUrl})`) }) it('via paste', () => { @@ -61,13 +48,13 @@ describe('File upload', () => { getData: (_: string) => '' } } - cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent) - cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`) + cy.get('.cm-content').trigger('paste', pasteEvent) + cy.get('.cm-line').contains(`![](${imageUrl})`) }) }) it('via drag and drop', () => { - cy.get('.CodeMirror-scroll').selectFile( + cy.get('.cm-content').selectFile( { contents: '@demoImage', fileName: 'demo.png', @@ -75,7 +62,7 @@ describe('File upload', () => { }, { action: 'drag-drop', force: true } ) - cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`) + cy.get('.cm-line').contains(`![](${imageUrl})`) }) }) @@ -98,7 +85,7 @@ describe('File upload', () => { }, { force: true } ) - cy.get('.CodeMirror-activeline').contains('![upload of demo.png failed]()') + cy.get('.cm-line').contains('![upload of demo.png failed]()') }) it('lets text paste still work', () => { @@ -108,7 +95,7 @@ describe('File upload', () => { getData: (type = 'text') => testText } } - cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent) - cy.get('.CodeMirror-activeline').contains(`${testText}`) + cy.get('.cm-content').trigger('paste', pasteEvent) + cy.get('.cm-line').contains(`${testText}`) }) }) diff --git a/cypress/integration/import.spec.ts b/cypress/integration/import.spec.ts index 2f0f869ac..65cd716bd 100644 --- a/cypress/integration/import.spec.ts +++ b/cypress/integration/import.spec.ts @@ -21,11 +21,8 @@ describe('Import markdown file', () => { }, { force: true } ) - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should( - 'have.text', - '# Some short import test file' - ) - cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span > span').should('have.text', ':)') + cy.get('.cm-editor .cm-line:nth-child(1)').should('have.text', '# Some short import test file') + cy.get('.cm-editor .cm-line:nth-child(2)').should('have.text', ':)') }) it('import on note with content', () => { @@ -40,12 +37,9 @@ describe('Import markdown file', () => { }, { force: true } ) - cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', 'test') - cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span > span').should('have.text', 'abc') - cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span > span').should( - 'have.text', - '# Some short import test file' - ) - cy.get('.CodeMirror-code > div:nth-of-type(4) > .CodeMirror-line > span > span').should('have.text', ':)') + cy.get('.cm-editor .cm-line:nth-child(1)').should('have.text', 'test') + cy.get('.cm-editor .cm-line:nth-child(2)').should('have.text', 'abc') + cy.get('.cm-editor .cm-line:nth-child(3)').should('have.text', '# Some short import test file') + cy.get('.cm-editor .cm-line:nth-child(4)').should('have.text', ':)') }) }) diff --git a/cypress/integration/taskLists.spec.ts b/cypress/integration/taskLists.spec.ts index 582970637..00f0e36a6 100644 --- a/cypress/integration/taskLists.spec.ts +++ b/cypress/integration/taskLists.spec.ts @@ -4,11 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const TEST_STRING_UNCHECKED = '- [ ] abc\n\n* [ ] abc\n\n+ [ ] abc\n\n1. [ ] abc\n\n10. [ ] abc\n\n5) [ ] abc' -const TEST_STRING_CHECKED_LOWER = '- [x] abc\n\n* [x] abc\n\n+ [x] abc\n\n1. [x] abc\n\n10. [x] abc\n\n5) [x] abc' -const TEST_STRING_CHECKED_UPPER = '- [X] abc\n\n* [X] abc\n\n+ [X] abc\n\n1. [X] abc\n\n10. [X] abc\n\n5) [X] abc' -const TEST_STRING_INVALID = '- [Y] abc\n\n* [ ] abc\n\n+ [-] abc\n\n1. [.] abc\n\n10. [] abc\n\n5) [-] abc' - describe('Task lists ', () => { beforeEach(() => { cy.visitTestNote() @@ -16,55 +11,55 @@ describe('Task lists ', () => { describe('render with checkboxes ', () => { it('when unchecked', () => { - cy.setCodemirrorContent(TEST_STRING_UNCHECKED) + cy.setCodemirrorContent('- [ ] abc\n\n* [ ] abc\n\n+ [ ] abc\n\n1. [ ] abc\n\n10. [ ] abc\n\n5) [ ] abc') cy.getMarkdownBody().find('input[type=checkbox]').should('have.length', 6) }) it('when checked lowercase', () => { - cy.setCodemirrorContent(TEST_STRING_CHECKED_LOWER) + cy.setCodemirrorContent('- [x] abc\n\n* [x] abc\n\n+ [x] abc\n\n1. [x] abc\n\n10. [x] abc\n\n5) [x] abc') cy.getMarkdownBody().find('input[type=checkbox]').should('have.length', 6) }) it('when checked uppercase', () => { - cy.setCodemirrorContent(TEST_STRING_CHECKED_UPPER) + cy.setCodemirrorContent('- [X] abc\n\n* [X] abc\n\n+ [X] abc\n\n1. [X] abc\n\n10. [X] abc\n\n5) [X] abc') cy.getMarkdownBody().find('input[type=checkbox]').should('have.length', 6) }) }) it('do not render as checkboxes when invalid', () => { - cy.setCodemirrorContent(TEST_STRING_INVALID) + cy.setCodemirrorContent('- [Y] abc\n\n* [ ] abc\n\n+ [-] abc\n\n1. [.] abc\n\n10. [] abc\n\n5) [-] abc') cy.getMarkdownBody().find('input[type=checkbox]').should('have.length', 0) }) describe('are clickable and change the markdown source ', () => { it('from unchecked to checked', () => { - cy.setCodemirrorContent(TEST_STRING_UNCHECKED) + cy.setCodemirrorContent('- [ ] abc') cy.getMarkdownBody() .find('input[type=checkbox]') .each((box) => { - box.trigger('click') + box.click() }) - cy.get('.CodeMirror-line > span').should('exist').should('contain.text', '[x]').should('not.contain.text', '[ ]') + cy.get('.cm-editor .cm-line').first().should('contain.text', '[x]').should('not.contain.text', '[ ]') }) it('from checked (lowercase) to unchecked', () => { - cy.setCodemirrorContent(TEST_STRING_CHECKED_LOWER) + cy.setCodemirrorContent('- [x] abc') cy.getMarkdownBody() .find('input[type=checkbox]') .each((box) => { - box.trigger('click') + box.click() }) - cy.get('.CodeMirror-line > span').should('exist').should('contain.text', '[ ]').should('not.contain.text', '[x]') + cy.get('.cm-editor .cm-line').should('exist').should('contain.text', '[ ]').should('not.contain.text', '[x]') }) it('from checked (uppercase) to unchecked', () => { - cy.setCodemirrorContent(TEST_STRING_CHECKED_UPPER) + cy.setCodemirrorContent('- [X] abc') cy.getMarkdownBody() .find('input[type=checkbox]') .each((box) => { - box.trigger('click') + box.click() }) - cy.get('.CodeMirror-line > span').should('exist').should('contain.text', '[ ]').should('not.contain.text', '[X]') + cy.get('.cm-editor .cm-line').should('exist').should('contain.text', '[ ]').should('not.contain.text', '[X]') }) }) }) diff --git a/cypress/support/checkLinks.ts b/cypress/support/check-links.ts similarity index 100% rename from cypress/support/checkLinks.ts rename to cypress/support/check-links.ts diff --git a/cypress/support/fill.ts b/cypress/support/fill.ts index 4f9a8337d..1f6909da8 100644 --- a/cypress/support/fill.ts +++ b/cypress/support/fill.ts @@ -3,33 +3,18 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import 'cypress-fill-command' declare namespace Cypress { interface Chainable { - /** - * Custom command to fill an input field with text and trigger a change event. - * @example cy.get(input).fill('content') - */ - fill(value: string): Chainable - setCodemirrorContent(value: string): Chainable } } -Cypress.Commands.add( - 'fill', - { - prevSubject: 'element' - }, - (subject, value) => { - return cy.wrap(subject).invoke('val', value).trigger('change', { force: true }) - } -) - Cypress.Commands.add('setCodemirrorContent', (content: string) => { const line = content.split('\n').find((value) => value !== '') - cy.get('.CodeMirror').click().get('textarea').type('{ctrl}a').type('{backspace}').fill(content) + cy.get('.cm-editor').click().get('.cm-content').fill(content) if (line) { - cy.get('.CodeMirror').find('.CodeMirror-line').should('contain.text', line) + cy.get('.cm-editor').find('.cm-line').should('contain.text', line) } }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 98c29893f..037bdb760 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -20,7 +20,7 @@ // *********************************************************** import 'cypress-commands' -import './checkLinks' +import './check-links' import './config' import './fill' import './get-by-id' diff --git a/cypress/support/visit.ts b/cypress/support/visit.ts index 1995dd7cb..9812bcb86 100644 --- a/cypress/support/visit.ts +++ b/cypress/support/visit.ts @@ -17,11 +17,11 @@ declare namespace Cypress { } Cypress.Commands.add('visitHome', () => { - return cy.visit('/', { retryOnNetworkFailure: true }) + return cy.visit('/', { retryOnNetworkFailure: true, retryOnStatusCodeFailure: true }) }) Cypress.Commands.add('visitHistory', () => { - return cy.visit(`/history`, { retryOnNetworkFailure: true }) + return cy.visit(`/history`, { retryOnNetworkFailure: true, retryOnStatusCodeFailure: true }) }) export enum PAGE_MODE { @@ -31,5 +31,8 @@ export enum PAGE_MODE { } Cypress.Commands.add('visitTestNote', (pageMode: PAGE_MODE = PAGE_MODE.EDITOR, query?: string) => { - return cy.visit(`/${pageMode}/${testNoteId}${query ? `?${query}` : ''}`, { retryOnNetworkFailure: true }) + return cy.visit(`/${pageMode}/${testNoteId}${query ? `?${query}` : ''}`, { + retryOnNetworkFailure: true, + retryOnStatusCodeFailure: true + }) }) diff --git a/locales/en.json b/locales/en.json index c651660be..4050c3145 100644 --- a/locales/en.json +++ b/locales/en.json @@ -345,10 +345,10 @@ "pinnedToHistory": "Pinned to history" }, "statusBar": { - "cursor": "Line {{line}}, Columns {{columns}}", + "cursor": "Line {{line}}, Character {{columns}}", "selection": { - "column": "Selected {{count}} columns", - "line": "Selected {{count}} lines" + "characters": "Selected {{count}} characters", + "lines": "Selected {{count}} lines" }, "lines": "{{lines}} Lines", "length": "Length {{length}}", diff --git a/package.json b/package.json index bdfe9db48..daf4f0799 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ ] }, "dependencies": { + "@codemirror/lang-markdown": "0.19.5", + "@codemirror/language-data": "0.19.1", + "@codemirror/theme-one-dark": "0.19.1", "@fontsource/source-sans-pro": "4.5.3", "@hedgedoc/html-to-react": "1.1.1", "@hedgedoc/markdown-it-image-size": "1.0.3", @@ -43,9 +46,9 @@ "@matejmazur/react-katex": "3.1.3", "@redux-devtools/core": "3.11.0", "@svgr/webpack": "6.2.1", + "@uiw/react-codemirror": "4.3.3", "abcjs": "6.0.0-beta.38", "bootstrap": "4.6.1", - "codemirror": "5.65.1", "copy-webpack-plugin": "10.2.4", "cross-env": "7.0.3", "d3-graphviz": "3.2.0", @@ -89,7 +92,6 @@ "react": "17.0.2", "react-bootstrap": "1.6.4", "react-bootstrap-typeahead": "5.2.1", - "react-codemirror2": "7.2.1", "react-diff-viewer": "3.1.1", "react-dom": "17.0.2", "react-i18next": "11.15.4", @@ -114,7 +116,6 @@ "@testing-library/jest-dom": "5.16.2", "@testing-library/react": "12.1.2", "@testing-library/user-event": "13.5.0", - "@types/codemirror": "5.60.5", "@types/d3-graphviz": "2.6.7", "@types/diff": "5.0.2", "@types/dompurify": "2.3.3", @@ -134,6 +135,7 @@ "@typescript-eslint/parser": "5.11.0", "cypress": "9.4.1", "cypress-commands": "2.0.1", + "cypress-fill-command": "1.0.2", "eslint": "8.9.0", "eslint-config-next": "12.0.10", "eslint-config-prettier": "8.3.0", diff --git a/src/components/editor-page/editor-pane/autocompletion/code-block.ts b/src/components/editor-page/editor-pane/autocompletion/code-block.ts deleted file mode 100644 index c38a1d037..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/code-block.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor, generateHintListByPrefix } from './index' -import { showErrorNotification } from '../../../../redux/ui-notifications/methods' -import { Logger } from '../../../../utils/logger' - -type highlightJsImport = typeof import('../../../common/hljs/hljs') - -const log = new Logger('Autocompletion > CodeBlock') -const wordRegExp = /^```((?:\w|-|\+)*)$/ -let allSupportedLanguages: string[] = [] - -/** - * Fetches the highlight js chunk. - * @return the retrieved highlight js api - */ -const loadHighlightJs = async (): Promise => { - try { - return await import('../../../common/hljs/hljs') - } catch (error) { - showErrorNotification('common.errorWhileLoadingLibrary', { name: 'highlight.js' })(error as Error) - log.error('Error while loading highlight.js', error) - return null - } -} - -/** - * Extracts the language from the current line in the editor. - * - * @param editor The editor that contains the search time - * @return null if no search term could be found or the found word and the cursor position. - */ -const extractSearchTerm = ( - editor: Editor -): null | { - searchTerm: string - startIndex: number - endIndex: number -} => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - return null - } - - return { - searchTerm: searchResult[1], - startIndex: searchTerm.start, - endIndex: searchTerm.end - } -} - -/** - * Builds the list of languages that are supported by highlight js or custom embeddings. - * @return An array of language names - */ -const buildLanguageList = async (): Promise => { - const highlightJs = await loadHighlightJs() - - if (highlightJs === null) { - return [] - } - - if (allSupportedLanguages.length === 0) { - allSupportedLanguages = highlightJs.default - .listLanguages() - .concat('csv', 'flow', 'html', 'js', 'markmap', 'abc', 'graphviz', 'mermaid', 'vega-lite') - } - - return allSupportedLanguages -} - -/** - * Creates a codemirror autocompletion hint with supported highlight js languages. - * - * @param editor The codemirror editor that requested the autocompletion - * @return The generated {@link Hints} or null if no hints exist. - */ -const codeBlockHint = async (editor: Editor): Promise => { - const searchResult = extractSearchTerm(editor) - if (!searchResult) { - return null - } - - const languages = await buildLanguageList() - if (languages.length === 0) { - return null - } - const suggestions = generateHintListByPrefix(searchResult.searchTerm, languages) - if (!suggestions) { - return null - } - const lineIndex = editor.getCursor().line - return { - list: suggestions.map( - (suggestion: string): Hint => ({ - text: '```' + suggestion + '\n\n```\n', - displayText: suggestion - }) - ), - from: Pos(lineIndex, searchResult.startIndex), - to: Pos(lineIndex, searchResult.endIndex) - } -} - -export const CodeBlockHinter: Hinter = { - wordRegExp, - hint: codeBlockHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts b/src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts deleted file mode 100644 index b465d40df..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/collapsible-block.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' - -const wordRegExp = /^( => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const suggestions = ['
\n Toggle label\n Toggled content\n
'] - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.map( - (suggestion: string): Hint => ({ - text: suggestion - }) - ), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end + 1) - }) - } - }) -} - -export const CollapsibleBlockHinter: Hinter = { - wordRegExp, - hint: collapsibleBlockHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/container.ts b/src/components/editor-page/editor-pane/autocompletion/container.ts deleted file mode 100644 index ab2638b57..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/container.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' -import { alertLevels } from '../../../markdown-renderer/markdown-extension/alert-markdown-extension' - -const wordRegExp = /^:::((?:\w|-|\+)*)$/ -const spoilerSuggestion: Hint = { - text: ':::spoiler Toggle label\nToggled content\n::: \n', - displayText: 'spoiler' -} -const suggestions = alertLevels - .map( - (suggestion: string): Hint => ({ - text: ':::' + suggestion + '\n\n::: \n', - displayText: suggestion - }) - ) - .concat(spoilerSuggestion) - -const containerHint = (editor: Editor): Promise => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.filter((suggestion) => suggestion.displayText?.startsWith(searchResult[1])), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end) - }) - } - }) -} - -export const ContainerHinter: Hinter = { - wordRegExp, - hint: containerHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/emoji.ts b/src/components/editor-page/editor-pane/autocompletion/emoji.ts deleted file mode 100644 index 84126be5f..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/emoji.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import Database from 'emoji-picker-element/database' -import type { Emoji, EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared' -import { emojiPickerConfig } from '../tool-bar/emoji-picker/emoji-picker' -import { getEmojiIcon, getEmojiShortCode } from '../tool-bar/utils/emojiUtils' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' -import { Logger } from '../../../../utils/logger' - -const emojiIndex = new Database(emojiPickerConfig) -const emojiWordRegex = /^:([\w-_+]*)$/ -const log = new Logger('Autocompletion > Emoji') - -const findEmojiInDatabase = async (emojiIndex: Database, term: string): Promise => { - try { - if (term === '') { - return await emojiIndex.getTopFavoriteEmoji(7) - } - const queryResult = await emojiIndex.getEmojiBySearchQuery(term) - if (queryResult.length === 0) { - return await emojiIndex.getTopFavoriteEmoji(7) - } else { - return queryResult - } - } catch (error) { - log.error('Error while searching for emoji', term, error) - return [] - } -} - -const convertEmojiEventToHint = (emojiData: EmojiClickEventDetail): Hint | undefined => { - const shortCode = getEmojiShortCode(emojiData) - if (!shortCode) { - return undefined - } - return { - text: shortCode, - render: (parent: HTMLLIElement) => { - const wrapper = document.createElement('div') - wrapper.innerHTML = `${getEmojiIcon(emojiData)} ${shortCode}` - parent.appendChild(wrapper) - } - } -} - -const generateEmojiHints = async (editor: Editor): Promise => { - const searchTerm = findWordAtCursor(editor) - const searchResult = emojiWordRegex.exec(searchTerm.text) - if (searchResult === null) { - return null - } - const suggestionList: Emoji[] = await findEmojiInDatabase(emojiIndex, searchResult[1]) - const cursor = editor.getCursor() - const skinTone = await emojiIndex.getPreferredSkinTone() - const emojiEventDetails: EmojiClickEventDetail[] = suggestionList - .filter((emoji) => !!emoji.shortcodes) - .map((emoji) => ({ - emoji, - skinTone: skinTone, - unicode: (emoji as NativeEmoji).unicode ? (emoji as NativeEmoji).unicode : undefined, - name: emoji.name - })) - - const hints = emojiEventDetails.map(convertEmojiEventToHint).filter((o) => !!o) as Hint[] - return { - list: hints, - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end) - } -} - -export const EmojiHinter: Hinter = { - wordRegExp: emojiWordRegex, - hint: generateEmojiHints -} diff --git a/src/components/editor-page/editor-pane/autocompletion/header.ts b/src/components/editor-page/editor-pane/autocompletion/header.ts deleted file mode 100644 index b58f94ebf..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/header.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor, generateHintListByPrefix } from './index' - -const wordRegExp = /^(\s{0,3})(#{1,6})$/ -const allSupportedHeaders = ['# h1', '## h2', '### h3', '#### h4', '##### h5', '###### h6', '###### tags: `example`'] -const allSupportedHeadersTextToInsert = ['# ', '## ', '### ', '#### ', '##### ', '###### ', '###### tags: `example`'] - -const headerHint = (editor: Editor): Promise => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const term = searchResult[0] - if (!term) { - resolve(null) - return - } - const suggestions = generateHintListByPrefix(term, allSupportedHeaders) - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.map( - (suggestion): Hint => ({ - text: allSupportedHeadersTextToInsert[allSupportedHeaders.indexOf(suggestion)], - displayText: suggestion - }) - ), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end) - }) - } - }) -} - -export const HeaderHinter: Hinter = { - wordRegExp, - hint: headerHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/image.ts b/src/components/editor-page/editor-pane/autocompletion/image.ts deleted file mode 100644 index dd6fcd491..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/image.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' - -const wordRegExp = /^(!(\[.*])?)$/ -const allSupportedImages = [ - '![image alt](https:// "title")', - '![image alt](https:// "title" =WidthxHeight)', - '![image alt][reference]' -] - -const imageHint = (editor: Editor): Promise => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const suggestions = allSupportedImages - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.map( - (suggestion: string): Hint => ({ - text: suggestion - }) - ), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end + 1) - }) - } - }) -} - -export const ImageHinter: Hinter = { - wordRegExp, - hint: imageHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/index.ts b/src/components/editor-page/editor-pane/autocompletion/index.ts deleted file mode 100644 index 446a08230..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hints } from 'codemirror' -import { CodeBlockHinter } from './code-block' -import { CollapsibleBlockHinter } from './collapsible-block' -import { ContainerHinter } from './container' -import { EmojiHinter } from './emoji' -import { HeaderHinter } from './header' -import { ImageHinter } from './image' -import { LinkAndExtraTagHinter } from './link-and-extra-tag' -import { PDFHinter } from './pdf' - -interface findWordAtCursorResponse { - start: number - end: number - text: string -} - -export interface Hinter { - wordRegExp: RegExp - hint: (editor: Editor) => Promise -} - -const allowedChars = /[^\s]/ - -export const findWordAtCursor = (editor: Editor): findWordAtCursorResponse => { - const cursor = editor.getCursor() - const line = editor.getLine(cursor.line) - let start = cursor.ch - let end = cursor.ch - while (start && allowedChars.test(line.charAt(start - 1))) { - --start - } - while (end < line.length && allowedChars.test(line.charAt(end))) { - ++end - } - - return { - text: line.slice(start, end).toLowerCase(), - start: start, - end: end - } -} - -/** - * Generates a list (with max 8 entries) of hints for the autocompletion. - * - * @param prefix This is the case insensitive prefix that every hint must have - * @param hintCandidates The list of hint candidates - */ -export const generateHintListByPrefix = (prefix: string, hintCandidates: string[]): string[] => { - const searchTerm = prefix.toLowerCase() - return hintCandidates.filter((item) => item.toLowerCase().startsWith(searchTerm)).slice(0, 7) -} - -export const allHinters: Hinter[] = [ - CodeBlockHinter, - ContainerHinter, - EmojiHinter, - HeaderHinter, - ImageHinter, - LinkAndExtraTagHinter, - PDFHinter, - 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 deleted file mode 100644 index 0f7e4b1bf..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/link-and-extra-tag.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import { DateTime } from 'luxon' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' -import { getGlobalState } from '../../../../redux' - -const wordRegExp = /^(\[(.*])?)$/ -const allSupportedLinks = [ - '[link text](https:// "title")', - '[reference]: https:// "title"', - '[link text][reference]', - '[reference]', - '[^footnote reference]: https://', - '[^footnote reference]', - '^[inline footnote]', - '[TOC]', - 'name', - 'time', - '[color=#FFFFFF]' -] - -const getUserName = (): string => { - const user = getGlobalState().user - return user ? user.displayName : 'Anonymous' -} - -const linkAndExtraTagHint = (editor: Editor): Promise => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const suggestions = allSupportedLinks - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.map((suggestion: string): Hint => { - switch (suggestion) { - case 'name': - // Get the user when a completion happens, this prevents to early calls resulting in 'Anonymous' - return { - text: `[name=${getUserName()}]` - } - case 'time': - // show the current time when the autocompletion is opened and not when the function is loaded - return { - text: `[time=${DateTime.local().toFormat('DDDD T')}]` - } - default: - return { - text: suggestion + ' ', - displayText: suggestion - } - } - }), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end + 1) - }) - } - }) -} - -export const LinkAndExtraTagHinter: Hinter = { - wordRegExp, - hint: linkAndExtraTagHint -} diff --git a/src/components/editor-page/editor-pane/autocompletion/pdf.ts b/src/components/editor-page/editor-pane/autocompletion/pdf.ts deleted file mode 100644 index 19ad6da22..000000000 --- a/src/components/editor-page/editor-pane/autocompletion/pdf.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, Hint, Hints } from 'codemirror' -import { Pos } from 'codemirror' -import type { Hinter } from './index' -import { findWordAtCursor } from './index' - -const wordRegExp = /^({[%}]?)$/ - -const pdfHint = (editor: Editor): Promise => { - return new Promise((resolve) => { - const searchTerm = findWordAtCursor(editor) - const searchResult = wordRegExp.exec(searchTerm.text) - if (searchResult === null) { - resolve(null) - return - } - const suggestions = ['{%pdf https:// %}'] - const cursor = editor.getCursor() - if (!suggestions) { - resolve(null) - } else { - resolve({ - list: suggestions.map( - (suggestion: string): Hint => ({ - text: suggestion - }) - ), - from: Pos(cursor.line, searchTerm.start), - to: Pos(cursor.line, searchTerm.end + 1) - }) - } - }) -} - -export const PDFHinter: Hinter = { - wordRegExp, - hint: pdfHint -} diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index 7d7c3dcfb..eaa95a7a9 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -1,89 +1,112 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Editor, EditorChange } from 'codemirror' -import React, { useCallback, useRef } from 'react' +import React, { useCallback, useMemo, 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' import { useApplicationState } from '../../../hooks/common/use-application-state' import { setNoteContent } from '../../../redux/note-details/methods' import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' -import { useCodeMirrorOptions } from './hooks/use-code-mirror-options' -import { useOnEditorPasteCallback } from './hooks/use-on-editor-paste-callback' -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 { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer' -import { ExtendedCodemirror } from './extended-codemirror/extended-codemirror' +import type { ReactCodeMirrorRef } from '@uiw/react-codemirror' +import ReactCodeMirror from '@uiw/react-codemirror' import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback' +import { useApplyScrollState } from './hooks/use-apply-scroll-state' +import styles from './extended-codemirror/codemirror.module.scss' +import { oneDark } from '@codemirror/theme-one-dark' +import { useTranslation } from 'react-i18next' +import { Logger } from '../../../utils/logger' +import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension' +import { useCodeMirrorPasteExtension } from './hooks/code-mirror-extensions/use-code-mirror-paste-extension' +import { useCodeMirrorFileDropExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-drop-extension' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { languages } from '@codemirror/language-data' +import { EditorView } from '@codemirror/view' +import { autocompletion } from '@codemirror/autocomplete' +import { useCodeMirrorFocusReference } from './hooks/use-code-mirror-focus-reference' +import { useOffScreenScrollProtection } from './hooks/use-off-screen-scroll-protection' + +const logger = new Logger('EditorPane') export const EditorPane: React.FC = ({ scrollState, onScroll, onMakeScrollSource }) => { const markdownContent = useNoteMarkdownContent() - const editor = useRef() const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) + const codeMirrorRef = useRef(null) - const onPaste = useOnEditorPasteCallback() - const onEditorScroll = useOnEditorScroll(onScroll) - useApplyScrollState(editor, scrollState) + useApplyScrollState(codeMirrorRef, scrollState) - const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => { - setNoteContent(value) - }, []) + const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll) + const editorPasteExtension = useCodeMirrorPasteExtension() + const dropExtension = useCodeMirrorFileDropExtension() + const [focusExtension, editorFocused] = useCodeMirrorFocusReference() + const saveOffFocusScrollStateExtensions = useOffScreenScrollProtection() + const cursorActivityExtension = useCursorActivityCallback(editorFocused) + + const onBeforeChange = useCallback( + (value: string): void => { + if (!editorFocused.current) { + logger.debug("Don't post content change because editor isn't focused") + } else { + setNoteContent(value) + } + }, + [editorFocused] + ) + + const extensions = useMemo( + () => [ + markdown({ base: markdownLanguage, codeLanguages: languages }), + ...saveOffFocusScrollStateExtensions, + focusExtension, + EditorView.lineWrapping, + editorScrollExtension, + editorPasteExtension, + dropExtension, + autocompletion(), + cursorActivityExtension + ], + [ + cursorActivityExtension, + dropExtension, + editorPasteExtension, + editorScrollExtension, + focusExtension, + saveOffFocusScrollStateExtensions + ] + ) useOnImageUploadFromRenderer() - 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] + const codeMirrorClassName = useMemo( + () => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`, + [ligaturesEnabled] ) + const { t } = useTranslation() + return (
-
diff --git a/src/components/editor-page/editor-pane/extended-codemirror/codemirror-imports.ts b/src/components/editor-page/editor-pane/extended-codemirror/codemirror-imports.ts deleted file mode 100644 index 59e27fb63..000000000 --- a/src/components/editor-page/editor-pane/extended-codemirror/codemirror-imports.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -// codemirror addons -import 'codemirror/addon/comment/comment' -import 'codemirror/addon/dialog/dialog' -import 'codemirror/addon/display/autorefresh' -import 'codemirror/addon/display/fullscreen' -import 'codemirror/addon/display/placeholder' -import 'codemirror/addon/edit/closebrackets' -import 'codemirror/addon/edit/closetag' -import 'codemirror/addon/edit/continuelist' -import 'codemirror/addon/edit/matchbrackets' -import 'codemirror/addon/edit/matchtags' -import 'codemirror/addon/fold/foldcode' -import 'codemirror/addon/fold/foldgutter' -import 'codemirror/addon/fold/markdown-fold' -import 'codemirror/addon/hint/show-hint' -import 'codemirror/addon/search/jump-to-line' -import 'codemirror/addon/search/match-highlighter' -import 'codemirror/addon/search/search' -import 'codemirror/addon/selection/active-line' -// codemirror keymaps -import 'codemirror/keymap/emacs' -import 'codemirror/keymap/sublime' -import 'codemirror/keymap/vim' -// codemirror syntax highlighting modes -import 'codemirror/mode/apl/apl' -import 'codemirror/mode/asciiarmor/asciiarmor' -import 'codemirror/mode/asn.1/asn.1' -import 'codemirror/mode/asterisk/asterisk' -import 'codemirror/mode/brainfuck/brainfuck' -import 'codemirror/mode/clike/clike' -import 'codemirror/mode/clojure/clojure' -import 'codemirror/mode/cmake/cmake' -import 'codemirror/mode/cobol/cobol' -import 'codemirror/mode/coffeescript/coffeescript' -import 'codemirror/mode/commonlisp/commonlisp' -import 'codemirror/mode/crystal/crystal' -import 'codemirror/mode/css/css' -import 'codemirror/mode/cypher/cypher' -import 'codemirror/mode/d/d' -import 'codemirror/mode/dart/dart' -import 'codemirror/mode/diff/diff' -import 'codemirror/mode/django/django' -import 'codemirror/mode/dockerfile/dockerfile' -import 'codemirror/mode/dtd/dtd' -import 'codemirror/mode/dylan/dylan' -import 'codemirror/mode/ebnf/ebnf' -import 'codemirror/mode/ecl/ecl' -import 'codemirror/mode/eiffel/eiffel' -import 'codemirror/mode/elm/elm' -import 'codemirror/mode/erlang/erlang' -import 'codemirror/mode/factor/factor' -import 'codemirror/mode/fcl/fcl' -import 'codemirror/mode/forth/forth' -import 'codemirror/mode/fortran/fortran' -import 'codemirror/mode/gas/gas' -import 'codemirror/mode/gfm/gfm' -import 'codemirror/mode/gherkin/gherkin' -import 'codemirror/mode/go/go' -import 'codemirror/mode/groovy/groovy' -import 'codemirror/mode/haml/haml' -import 'codemirror/mode/handlebars/handlebars' -import 'codemirror/mode/haskell/haskell' -import 'codemirror/mode/haskell-literate/haskell-literate' -import 'codemirror/mode/haxe/haxe' -import 'codemirror/mode/htmlembedded/htmlembedded' -import 'codemirror/mode/htmlmixed/htmlmixed' -import 'codemirror/mode/http/http' -import 'codemirror/mode/idl/idl' -import 'codemirror/mode/javascript/javascript' -import 'codemirror/mode/jinja2/jinja2' -import 'codemirror/mode/jsx/jsx' -import 'codemirror/mode/julia/julia' -import 'codemirror/mode/livescript/livescript' -import 'codemirror/mode/lua/lua' -import 'codemirror/mode/markdown/markdown' -import 'codemirror/mode/mathematica/mathematica' -import 'codemirror/mode/mbox/mbox' -import 'codemirror/mode/mirc/mirc' -import 'codemirror/mode/mllike/mllike' -import 'codemirror/mode/modelica/modelica' -import 'codemirror/mode/mscgen/mscgen' -import 'codemirror/mode/mumps/mumps' -import 'codemirror/mode/nginx/nginx' -import 'codemirror/mode/nsis/nsis' -import 'codemirror/mode/ntriples/ntriples' -import 'codemirror/mode/octave/octave' -import 'codemirror/mode/oz/oz' -import 'codemirror/mode/pascal/pascal' -import 'codemirror/mode/pegjs/pegjs' -import 'codemirror/mode/perl/perl' -import 'codemirror/mode/php/php' -import 'codemirror/mode/pig/pig' -import 'codemirror/mode/powershell/powershell' -import 'codemirror/mode/properties/properties' -import 'codemirror/mode/protobuf/protobuf' -import 'codemirror/mode/pug/pug' -import 'codemirror/mode/puppet/puppet' -import 'codemirror/mode/python/python' -import 'codemirror/mode/q/q' -import 'codemirror/mode/r/r' -import 'codemirror/mode/rpm/rpm' -import 'codemirror/mode/rst/rst' -import 'codemirror/mode/ruby/ruby' -import 'codemirror/mode/rust/rust' -import 'codemirror/mode/sas/sas' -import 'codemirror/mode/sass/sass' -import 'codemirror/mode/scheme/scheme' -import 'codemirror/mode/shell/shell' -import 'codemirror/mode/sieve/sieve' -import 'codemirror/mode/slim/slim' -import 'codemirror/mode/smalltalk/smalltalk' -import 'codemirror/mode/smarty/smarty' -import 'codemirror/mode/solr/solr' -import 'codemirror/mode/soy/soy' -import 'codemirror/mode/sparql/sparql' -import 'codemirror/mode/spreadsheet/spreadsheet' -import 'codemirror/mode/sql/sql' -import 'codemirror/mode/stex/stex' -import 'codemirror/mode/stylus/stylus' -import 'codemirror/mode/swift/swift' -import 'codemirror/mode/tcl/tcl' -import 'codemirror/mode/textile/textile' -import 'codemirror/mode/tiddlywiki/tiddlywiki' -import 'codemirror/mode/tiki/tiki' -import 'codemirror/mode/toml/toml' -import 'codemirror/mode/tornado/tornado' -import 'codemirror/mode/troff/troff' -import 'codemirror/mode/ttcn/ttcn' -import 'codemirror/mode/ttcn-cfg/ttcn-cfg' -import 'codemirror/mode/turtle/turtle' -import 'codemirror/mode/twig/twig' -import 'codemirror/mode/vb/vb' -import 'codemirror/mode/vbscript/vbscript' -import 'codemirror/mode/velocity/velocity' -import 'codemirror/mode/verilog/verilog' -import 'codemirror/mode/vhdl/vhdl' -import 'codemirror/mode/vue/vue' -import 'codemirror/mode/wast/wast' -import 'codemirror/mode/webidl/webidl' -import 'codemirror/mode/xml/xml' -import 'codemirror/mode/xquery/xquery' -import 'codemirror/mode/yacas/yacas' -import 'codemirror/mode/yaml/yaml' -import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter' -import 'codemirror/mode/z80/z80' diff --git a/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss b/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss index ec37b3bf3..d32d896a4 100644 --- a/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss +++ b/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss @@ -4,44 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -.extended-codemirror { - :global { - @import '~codemirror/lib/codemirror'; - @import '~codemirror/addon/display/fullscreen'; - @import '~codemirror/addon/fold/foldgutter'; - @import '~codemirror/addon/dialog/dialog'; - @import '~codemirror/theme/neat'; - @import './one-dark'; - @import './hints'; - - .CodeMirror { - & { - @import '../../../../../global-styles/variables.module'; - font-family: "Fira Code", $font-family-emojis, Consolas, monaco, monospace; - } - letter-spacing: 0.025em; - line-height: 1.25; - font-size: 18px; - height: 100%; - } - - .file-drag .CodeMirror-cursors { - visibility: visible; - } +.extendedCodemirror { + :global(.cm-editor .cm-line) { + @import '../../../../../global-styles/variables.module'; + font-family: "Fira Code", $font-family-emojis, Consolas, monaco, monospace; } &.no-ligatures { - :global { - .CodeMirror { - - .CodeMirror-line, .CodeMirror-line-like { - font-feature-settings: inherit; - } - - .CodeMirror-line, .CodeMirror-line-like { - font-variant-ligatures: none; - } - } + :global(.cm-editor .cm-line) { + font-variant-ligatures: none; } } } diff --git a/src/components/editor-page/editor-pane/extended-codemirror/extended-codemirror.tsx b/src/components/editor-page/editor-pane/extended-codemirror/extended-codemirror.tsx deleted file mode 100644 index 2b7e70b1e..000000000 --- a/src/components/editor-page/editor-pane/extended-codemirror/extended-codemirror.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import React from 'react' -import type { IControlledCodeMirror } from 'react-codemirror2' -import { Controlled } from 'react-codemirror2' -import './codemirror-imports' -import styles from './codemirror.module.scss' -import { allHinters, findWordAtCursor } from '../autocompletion' -import type { Editor } from 'codemirror' - -export interface ExtendedCodemirrorProps extends Omit { - ligatures?: boolean -} - -const onChange = (editor: Editor) => { - const searchTerm = findWordAtCursor(editor) - for (const hinter of allHinters) { - if (hinter.wordRegExp.test(searchTerm.text)) { - editor.showHint({ - container: editor.getWrapperElement(), - hint: hinter.hint, - completeSingle: false, - completeOnSingleClick: false, - alignWithWord: true - }) - return - } - } -} - -/** - * A {@link Controlled controlled code mirror} but with several addons, different font, ligatures and other improvements. - * - * @param className Additional css class names that should be added to the component - * @param ligatures Renders text ligatures if {@code true} - * @param props Other code mirror props that will be forwarded to the editor - */ -export const ExtendedCodemirror: React.FC = ({ className, ligatures, ...props }) => { - return ( - - ) -} diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts new file mode 100644 index 000000000..a774bfec5 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useCallback, useMemo } from 'react' +import { handleUpload } from '../../upload-handler' +import { EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' +import Optional from 'optional-js' + +/** + * Creates a callback that is used to process file drops on the code mirror editor + * + * @return the code mirror callback + */ +export const useCodeMirrorFileDropExtension = (): Extension => { + const onDrop = useCallback((event: DragEvent, view: EditorView): void => { + if (!event.pageX || !event.pageY) { + return + } + Optional.ofNullable(event.dataTransfer?.files) + .filter((files) => files.length > 0) + .ifPresent((files) => { + event.preventDefault() + const newCursor = view.posAtCoords({ y: event.pageY, x: event.pageX }) + if (newCursor === null) { + return + } + handleUpload(files[0], { from: newCursor }) + }) + }, []) + + return useMemo( + () => + EditorView.domEventHandlers({ + drop: onDrop + }), + [onDrop] + ) +} diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts new file mode 100644 index 000000000..c0026d81e --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import { handleFilePaste, handleTablePaste } from '../../tool-bar/utils/pasteHandlers' +import { EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' + +/** + * Creates a {@link Extension code mirror extension} that handles the table or file paste action. + * + * @return the created {@link Extension code mirror extension} + */ +export const useCodeMirrorPasteExtension = (): Extension => { + return useMemo( + () => + EditorView.domEventHandlers({ + paste: (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) { + return + } + if (handleTablePaste(clipboardData) || handleFilePaste(clipboardData)) { + event.preventDefault() + return + } + } + }), + [] + ) +} diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts new file mode 100644 index 000000000..be6f9567d --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useCallback, useMemo } from 'react' +import type { ScrollState } from '../../../synced-scroll/scroll-props' +import { EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' + +export type OnScrollCallback = ((scrollState: ScrollState) => void) | undefined + +/** + * Extracts the {@link ScrollState scroll state} from the given {@link EditorView editor view}. + * + * @param view The {@link EditorView editor view} whose scroll state should be extracted. + */ +export const extractScrollState = (view: EditorView): ScrollState => { + const state = view.state + const scrollTop = view.scrollDOM.scrollTop + const lineBlockAtHeight = view.lineBlockAtHeight(scrollTop) + const line = state.doc.lineAt(lineBlockAtHeight.from) + const percentageRaw = (scrollTop - lineBlockAtHeight.top) / lineBlockAtHeight.height + const scrolledPercentage = Math.floor(percentageRaw * 100) + return { + firstLineInView: line.number, + scrolledPercentage + } +} + +/** + * Creates a code mirror extension for the scroll binding. + * It calculates a {@link ScrollState} and posts it on change. + * + * @param onScroll The callback that is used to post {@link ScrollState scroll states} when the editor view is scrolling. + * @return The extensions that watches the scrolling in the editor. + */ +export const useCodeMirrorScrollWatchExtension = (onScroll: OnScrollCallback): Extension => { + const onEditorScroll = useCallback( + (view: EditorView) => { + if (!onScroll || !view) { + return undefined + } + onScroll(extractScrollState(view)) + }, + [onScroll] + ) + return useMemo( + () => + EditorView.domEventHandlers({ + scroll: (event, view) => onEditorScroll(view) + }), + [onEditorScroll] + ) +} 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 da6caa14d..a7d951597 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 @@ -6,8 +6,24 @@ import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' -import type { Editor } from 'codemirror' import type { ScrollState } from '../../synced-scroll/scroll-props' +import type { ReactCodeMirrorRef } from '@uiw/react-codemirror' +import { EditorView } from '@codemirror/view' +import equal from 'fast-deep-equal' + +/** + * Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}. + * + * @param view The {@link EditorView view} that should be scrolled + * @param scrollState The {@link ScrollState scroll state} that should be applied + */ +export const applyScrollState = (view: EditorView, scrollState: ScrollState): void => { + const line = view.state.doc.line(scrollState.firstLineInView) + const lineBlock = view.lineBlockAt(line.from) + const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100 + const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin }) + view.dispatch({ effects: [stateEffect] }) +} /** * Monitors the given scroll state and scrolls the editor to the state if changed. @@ -16,22 +32,21 @@ import type { ScrollState } from '../../synced-scroll/scroll-props' * @param scrollState The scroll state that should be monitored */ export const useApplyScrollState = ( - editorRef: MutableRefObject, + editorRef: MutableRefObject, scrollState?: ScrollState ): void => { - const lastScrollPosition = useRef() + const lastScrollPosition = useRef() + useEffect(() => { - const editor = editorRef.current - if (!editor || !scrollState) { + const view = editorRef.current?.view + if (!view || !scrollState) { return } - const startYOfLine = editor.heightAtLine(scrollState.firstLineInView - 1, 'local') - const heightOfLine = (editor.lineInfo(scrollState.firstLineInView - 1).handle as { height: number }).height - const newPositionRaw = startYOfLine + (heightOfLine * scrollState.scrolledPercentage) / 100 - const newPosition = Math.floor(newPositionRaw) - if (newPosition !== lastScrollPosition.current) { - lastScrollPosition.current = newPosition - editor.scrollTo(0, newPosition) + + if (equal(scrollState, lastScrollPosition.current)) { + return } + applyScrollState(view, scrollState) + lastScrollPosition.current = scrollState }, [editorRef, scrollState]) } diff --git a/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts b/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts new file mode 100644 index 000000000..4645d3dae --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { RefObject } from 'react' +import { useMemo, useRef } from 'react' +import { EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' + +/** + * Creates a {@link RefObject reference} that contains the information if the editor is currently focused or not. + * + * @returns The reference and the necessary {@link Extension code mirror extension} that receives the focus and blur events + */ +export const useCodeMirrorFocusReference = (): [Extension, RefObject] => { + const focusReference = useRef(false) + const codeMirrorExtension = useMemo( + () => + EditorView.domEventHandlers({ + blur: () => { + focusReference.current = false + }, + focus: () => { + focusReference.current = true + } + }), + [] + ) + + return [codeMirrorExtension, focusReference] +} diff --git a/src/components/editor-page/editor-pane/hooks/use-code-mirror-options.ts b/src/components/editor-page/editor-pane/hooks/use-code-mirror-options.ts deleted file mode 100644 index bf4c7f0fc..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-code-mirror-options.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { EditorConfiguration } from 'codemirror' -import { useMemo } from 'react' -import { createDefaultKeyMap } from '../key-map' -import { useApplicationState } from '../../../../hooks/common/use-application-state' -import { useTranslation } from 'react-i18next' - -/** - * Generates the configuration for a CodeMirror instance. - */ -export const useCodeMirrorOptions = (): EditorConfiguration => { - const editorPreferences = useApplicationState((state) => state.editorConfig.preferences) - const { t } = useTranslation() - - return useMemo( - () => ({ - ...editorPreferences, - mode: 'gfm', - viewportMargin: 20, - styleActiveLine: true, - lineNumbers: true, - lineWrapping: true, - showCursorWhenSelecting: true, - highlightSelectionMatches: true, - inputStyle: 'textarea', - matchBrackets: true, - autoCloseBrackets: true, - matchTags: { - bothTags: true - }, - autoCloseTags: true, - foldGutter: true, - gutters: ['CodeMirror-linenumbers', 'authorship-gutters', 'CodeMirror-foldgutter'], - extraKeys: createDefaultKeyMap(), - flattenSpans: true, - addModeClass: true, - autoRefresh: true, - // otherCursors: true, - placeholder: t('editor.placeholder') - }), - [t, editorPreferences] - ) -} 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 deleted file mode 100644 index 5548aff48..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-create-status-bar-info.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { StatusBarInfo } from '../status-bar/status-bar' -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 => { - const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength) - const selection = useApplicationState((state) => state.noteDetails.selection) - const markdownContent = useApplicationState((state) => state.noteDetails.markdownContent) - const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines) - - 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 { - 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 index 3580bce4f..60a84b436 100644 --- 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 @@ -4,27 +4,36 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Editor } from 'codemirror' -import { useCallback } from 'react' -import type { CursorPosition } from '../../../../redux/editor/types' +import type { RefObject } from 'react' +import { useMemo } from 'react' import { updateCursorPositions } from '../../../../redux/note-details/methods' +import type { ViewUpdate } from '@codemirror/view' +import { EditorView } from '@codemirror/view' +import { Logger } from '../../../../utils/logger' +import type { Extension } from '@codemirror/state' + +const logger = new Logger('useCursorActivityCallback') /** * 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 - }) - }, []) +export const useCursorActivityCallback = (editorFocused: RefObject): Extension => { + return useMemo( + () => + EditorView.updateListener.of((viewUpdate: ViewUpdate): void => { + if (!editorFocused.current) { + logger.debug("Don't post updated cursor because editor isn't focused") + return + } + const firstSelection = viewUpdate.state.selection.main + const newCursorPos = { + from: firstSelection.from, + to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to + } + updateCursorPositions(newCursorPos) + }), + [editorFocused] + ) } diff --git a/src/components/editor-page/editor-pane/hooks/use-line-based-position.ts b/src/components/editor-page/editor-pane/hooks/use-line-based-position.ts new file mode 100644 index 000000000..d8a009f59 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-line-based-position.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import { useApplicationState } from '../../../../hooks/common/use-application-state' + +export interface LineBasedPosition { + line: number + character: number +} + +const calculateLineBasedPosition = (absolutePosition: number, lineStartIndexes: number[]): LineBasedPosition => { + const foundLineIndex = lineStartIndexes.findIndex((startIndex) => absolutePosition < startIndex) + const line = foundLineIndex === -1 ? lineStartIndexes.length - 1 : foundLineIndex - 1 + return { + line: line, + character: absolutePosition - lineStartIndexes[line] + } +} + +/** + * Returns the line+character based position of the to cursor, if available. + */ +export const useLineBasedToPosition = (): LineBasedPosition | undefined => { + const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes) + const selection = useApplicationState((state) => state.noteDetails.selection) + + return useMemo(() => { + const to = selection.to + if (to === undefined) { + return undefined + } + return calculateLineBasedPosition(to, lineStartIndexes) + }, [selection.to, lineStartIndexes]) +} + +/** + * Returns the line+character based position of the from cursor. + */ +export const useLineBasedFromPosition = (): LineBasedPosition => { + const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes) + const selection = useApplicationState((state) => state.noteDetails.selection) + + return useMemo(() => { + return calculateLineBasedPosition(selection.from, lineStartIndexes) + }, [selection.from, lineStartIndexes]) +} diff --git a/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts b/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts new file mode 100644 index 000000000..eb48125eb --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo, useRef } from 'react' +import type { ScrollState } from '../../synced-scroll/scroll-props' +import { extractScrollState } from './code-mirror-extensions/use-code-mirror-scroll-watch-extension' +import { applyScrollState } from './use-apply-scroll-state' +import { store } from '../../../../redux' +import type { Extension } from '@codemirror/state' +import { Logger } from '../../../../utils/logger' +import { EditorView } from '@codemirror/view' + +const logger = new Logger('useOffScreenScrollProtection') + +/** + * If the editor content changes while the editor isn't focused then the editor starts jumping around. + * This extension fixes this behaviour by saving the scroll state when the editor looses focus and applies it on content changes. + * + * @returns necessary {@link Extension code mirror extensions} to provide the functionality + */ +export const useOffScreenScrollProtection = (): Extension[] => { + const offFocusScrollState = useRef() + + return useMemo(() => { + const saveOffFocusScrollStateExtension = EditorView.domEventHandlers({ + blur: (event, view) => { + offFocusScrollState.current = extractScrollState(view) + logger.debug('Save off-focus scroll state', offFocusScrollState.current) + }, + focus: () => { + offFocusScrollState.current = undefined + } + }) + + const changeExtension = EditorView.updateListener.of((update) => { + const view = update.view + const scrollState = offFocusScrollState.current + if (!scrollState || !update.docChanged) { + return + } + logger.debug('Apply off-focus scroll state', scrollState) + applyScrollState(view, scrollState) + const selection = store.getState().noteDetails.selection + view.dispatch( + view.state.update({ + selection: { + anchor: selection.from, + head: selection.to + } + }) + ) + }) + + return [saveOffFocusScrollStateExtension, changeExtension] + }, []) +} 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 deleted file mode 100644 index 917f271bb..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-on-editor-file-drop.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { useCallback } from 'react' -import type { Editor } from 'codemirror' -import { handleUpload } from '../upload-handler' -import type { DomEvent } from 'react-codemirror2' - -interface DropEvent { - pageX: number - pageY: number - dataTransfer: { - files: FileList - effectAllowed: string - } | null - preventDefault: () => void -} - -/** - * Creates a callback that is used to process file drops on the code mirror editor - * - * @return the code mirror callback - */ -export const useOnEditorFileDrop = (): DomEvent => { - return useCallback((dropEditor: Editor, event: DropEvent) => { - if ( - event && - dropEditor && - event.pageX && - event.pageY && - event.dataTransfer && - event.dataTransfer.files && - event.dataTransfer.files.length >= 1 - ) { - event.preventDefault() - const newCursor = dropEditor.coordsChar({ top: event.pageY, left: event.pageX }, 'page') - dropEditor.setCursor(newCursor) - const files: FileList = event.dataTransfer.files - 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 deleted file mode 100644 index e9c2112d2..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-on-editor-paste-callback.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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 type { DomEvent } from 'react-codemirror2' - -/** - * Creates a callback that handles the table or file paste action in code mirror. - * - * @return the created callback - */ -export const useOnEditorPasteCallback = (): DomEvent => { - 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-editor-scroll.ts b/src/components/editor-page/editor-pane/hooks/use-on-editor-scroll.ts deleted file mode 100644 index afa01ad04..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-on-editor-scroll.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { DomEvent } from 'react-codemirror2' -import { useCallback, useEffect, useState } from 'react' -import type { Editor, ScrollInfo } from 'codemirror' -import type { ScrollState } from '../../synced-scroll/scroll-props' - -/** - * Creates a callback for the scroll binding of the code mirror editor. - * It calculates a {@link ScrollState} and posts it on change. - * - * @param onScroll The callback that is used to post the {@link ScrolLState}. - * @return The callback for the code mirror scroll binding. - */ -export const useOnEditorScroll = (onScroll?: (scrollState: ScrollState) => void): DomEvent => { - const [editorScrollState, setEditorScrollState] = useState() - - useEffect(() => { - if (onScroll && editorScrollState) { - onScroll(editorScrollState) - } - }, [editorScrollState, onScroll]) - - return useCallback( - (editor: Editor, scrollInfo: ScrollInfo) => { - if (!editor || !onScroll || !scrollInfo) { - return - } - const line = editor.lineAtHeight(scrollInfo.top, 'local') - const startYOfLine = editor.heightAtLine(line, 'local') - const lineInfo = editor.lineInfo(line) - if (lineInfo === null) { - return - } - const heightOfLine = (lineInfo.handle as { height: number }).height - const percentageRaw = Math.max(scrollInfo.top - startYOfLine, 0) / heightOfLine - const percentage = Math.floor(percentageRaw * 100) - - setEditorScrollState({ firstLineInView: line + 1, scrolledPercentage: percentage }) - }, - [onScroll] - ) -} 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 d960c0de2..5044c5597 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 @@ -36,7 +36,7 @@ export const useOnImageUploadFromRenderer = (): void => { .then((blob) => { const file = new File([blob], fileName, { type: blob.type }) const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex) - .map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine)) + .flatMap((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine)) .orElseGet(() => ({})) handleUpload(file, cursorSelection, alt, title) }) @@ -58,26 +58,25 @@ export interface ExtractResult { * @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder. * @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 = getGlobalState().noteDetails.markdownContent.split('\n') - const lineAtIndex = currentMarkdownContentLines[lineIndex] - if (lineAtIndex === undefined) { - return - } - return findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], lineIndex, replacementIndexInLine) +const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): Optional => { + const noteDetails = getGlobalState().noteDetails + const currentMarkdownContentLines = noteDetails.markdownContent.lines + return Optional.ofNullable(noteDetails.markdownContent.lineStartIndexes[lineIndex]).map((startIndexOfLine) => + findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], startIndexOfLine, replacementIndexInLine) + ) } /** * Tries to find the right image placeholder in the given line. * * @param line The line that should be inspected - * @param lineIndex The index of the line in the document + * @param startIndexOfLine The absolute start index of the line in the document * @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder. * @return the calculated start and end position or undefined if no position could be determined */ const findImagePlaceholderInLine = ( line: string, - lineIndex: number, + startIndexOfLine: number, replacementIndexInLine = 0 ): ExtractResult | undefined => { const startOfImageTag = findRegexMatchInText(line, imageWithPlaceholderLinkRegex, replacementIndexInLine) @@ -85,16 +84,12 @@ const findImagePlaceholderInLine = ( return } + const from = startIndexOfLine + startOfImageTag.index + const to = from + startOfImageTag[0].length return { cursorSelection: { - from: { - character: startOfImageTag.index, - line: lineIndex - }, - to: { - character: startOfImageTag.index + startOfImageTag[0].length, - line: lineIndex - } + from, + to }, alt: startOfImageTag[1], title: startOfImageTag[2] diff --git a/src/components/editor-page/editor-pane/key-map.ts b/src/components/editor-page/editor-pane/key-map.ts deleted file mode 100644 index 3f9b08284..000000000 --- a/src/components/editor-page/editor-pane/key-map.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { Editor, KeyMap, Pass } from 'codemirror' -import CodeMirror from 'codemirror' -import { isMac } from '../utils' -import { formatSelection } from '../../../redux/note-details/methods' -import { FormatType } from '../../../redux/note-details/types' - -const isVim = (keyMapName?: string) => keyMapName?.substr(0, 3) === 'vim' - -const f10 = (editor: Editor): void | typeof Pass => editor.setOption('fullScreen', !editor.getOption('fullScreen')) -const esc = (editor: Editor): void | typeof Pass => { - if (editor.getOption('fullScreen') && !isVim(editor.getOption('keyMap'))) { - editor.setOption('fullScreen', false) - } else { - return CodeMirror.Pass - } -} -const suppressKey = (): void => { - /*no op*/ -} -const tab = (editor: Editor) => { - const tab = '\t' - - // contruct x length spaces - const spaces = Array((editor.getOption('indentUnit') ?? 0) + 1).join(' ') - - // auto indent whole line when in list or blockquote - const cursor = editor.getCursor() - const line = editor.getLine(cursor.line) - - // this regex match the following patterns - // 1. blockquote starts with "> " or ">>" - // 2. unorder list starts with *+-parseInt - // 3. order list starts with "1." or "1)" - const regex = /^(\s*)(>[> ]*|[*+-]\s|(\d+)([.)]))/ - - let match - const multiple = editor.getSelection().split('\n').length > 1 || editor.getSelections().length > 1 - - if (multiple) { - editor.execCommand('defaultTab') - } else if ((match = regex.exec(line)) !== null) { - const ch = match[1].length - const pos = { - line: cursor.line, - ch: ch - } - if (editor.getOption('indentWithTabs')) { - editor.replaceRange(tab, pos, pos, '+input') - } else { - editor.replaceRange(spaces, pos, pos, '+input') - } - } else { - if (editor.getOption('indentWithTabs')) { - editor.execCommand('defaultTab') - } else { - editor.replaceSelection(spaces) - } - } -} - -export const createDefaultKeyMap: () => KeyMap = () => { - if (isMac()) { - return { - F9: suppressKey, - F10: f10, - Esc: esc, - 'Cmd-S': suppressKey, - Enter: 'newlineAndIndentContinueMarkdownList', - Tab: tab, - 'Cmd-Left': 'goLineLeftSmart', - 'Cmd-Right': 'goLineRight', - Home: 'goLineLeftSmart', - End: 'goLineRight', - '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 { - F9: suppressKey, - F10: f10, - Esc: esc, - 'Ctrl-S': suppressKey, - Enter: 'newlineAndIndentContinueMarkdownList', - Tab: tab, - Home: 'goLineLeftSmart', - End: 'goLineRight', - '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 fed182852..1ef9e755b 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,27 +6,23 @@ import React, { useMemo } from 'react' import { Trans } from 'react-i18next' -import type { CursorPosition } from '../../../../redux/editor/types' - -export interface CursorPositionInfoProps { - cursorPosition: CursorPosition -} +import { useLineBasedFromPosition } from '../hooks/use-line-based-position' /** * Renders a translated text that shows the given cursor position. - * - * @param cursorPosition The cursor position that should be included */ -export const CursorPositionInfo: React.FC = ({ cursorPosition }) => { +export const CursorPositionInfo: React.FC = () => { + const lineBasedPosition = useLineBasedFromPosition() + const translationOptions = useMemo( () => ({ - line: cursorPosition.line + 1, - columns: cursorPosition.character + 1 + line: lineBasedPosition.line + 1, + columns: lineBasedPosition.character + 1 }), - [cursorPosition.character, cursorPosition.line] + [lineBasedPosition] ) - return ( + return translationOptions === undefined ? null : ( diff --git a/src/components/editor-page/editor-pane/status-bar/number-of-lines-in-document-info.tsx b/src/components/editor-page/editor-pane/status-bar/number-of-lines-in-document-info.tsx index 5f9ea5926..479af798e 100644 --- a/src/components/editor-page/editor-pane/status-bar/number-of-lines-in-document-info.tsx +++ b/src/components/editor-page/editor-pane/status-bar/number-of-lines-in-document-info.tsx @@ -6,20 +6,18 @@ import React, { useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' - -export interface LinesInDocumentInfoProps { - numberOfLinesInDocument: number -} +import { useApplicationState } from '../../../../hooks/common/use-application-state' /** * Renders a translated text that shows the number of lines in the document. * * @param linesInDocument The number of lines in the document */ -export const NumberOfLinesInDocumentInfo: React.FC = ({ numberOfLinesInDocument }) => { +export const NumberOfLinesInDocumentInfo: React.FC = () => { useTranslation() - const translationOptions = useMemo(() => ({ lines: numberOfLinesInDocument }), [numberOfLinesInDocument]) + const linesInDocument = useApplicationState((state) => state.noteDetails.markdownContent.lines.length) + const translationOptions = useMemo(() => ({ lines: linesInDocument }), [linesInDocument]) return ( diff --git a/src/components/editor-page/editor-pane/status-bar/remaining-characters-info.tsx b/src/components/editor-page/editor-pane/status-bar/remaining-characters-info.tsx index db49339eb..46b1f05dd 100644 --- a/src/components/editor-page/editor-pane/status-bar/remaining-characters-info.tsx +++ b/src/components/editor-page/editor-pane/status-bar/remaining-characters-info.tsx @@ -7,11 +7,7 @@ import React, { useMemo } from 'react' import { cypressId } from '../../../../utils/cypress-attribute' import { Trans, useTranslation } from 'react-i18next' - -export interface LengthInfoProps { - remainingCharacters: number - charactersInDocument: number -} +import { useApplicationState } from '../../../../hooks/common/use-application-state' /** * Renders a translated text that shows the number of remaining characters. @@ -19,9 +15,13 @@ export interface LengthInfoProps { * @param remainingCharacters The number of characters that are still available in this document * @param charactersInDocument The total number of characters in the document */ -export const RemainingCharactersInfo: React.FC = ({ remainingCharacters, charactersInDocument }) => { +export const RemainingCharactersInfo: React.FC = () => { const { t } = useTranslation() + const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength) + const contentLength = useApplicationState((state) => state.noteDetails.markdownContent.plain.length) + const remainingCharacters = useMemo(() => maxDocumentLength - contentLength, [contentLength, maxDocumentLength]) + const remainingCharactersClass = useMemo(() => { if (remainingCharacters <= 0) { return 'text-danger' @@ -42,7 +42,7 @@ export const RemainingCharactersInfo: React.FC = ({ remainingCh } }, [remainingCharacters, t]) - const translationOptions = useMemo(() => ({ length: charactersInDocument }), [charactersInDocument]) + const translationOptions = useMemo(() => ({ length: contentLength }), [contentLength]) return ( diff --git a/src/components/editor-page/editor-pane/status-bar/selected-characters.tsx b/src/components/editor-page/editor-pane/status-bar/selected-characters.tsx new file mode 100644 index 000000000..7c9ba41f6 --- /dev/null +++ b/src/components/editor-page/editor-pane/status-bar/selected-characters.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment, useMemo } from 'react' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { SeparatorDash } from './separator-dash' +import { Trans, useTranslation } from 'react-i18next' + +/** + * Shows the total number of selected characters. + */ +export const SelectedCharacters: React.FC = () => { + useTranslation() + + const selection = useApplicationState((state) => state.noteDetails.selection) + const count = useMemo( + () => (selection.to === undefined ? undefined : selection.to - selection.from), + [selection.from, selection.to] + ) + const countTranslationOptions = useMemo(() => ({ count }), [count]) + + return count === undefined ? null : ( + + + + + + + ) +} diff --git a/src/components/editor-page/editor-pane/status-bar/selected-lines.tsx b/src/components/editor-page/editor-pane/status-bar/selected-lines.tsx new file mode 100644 index 000000000..d5c1c0821 --- /dev/null +++ b/src/components/editor-page/editor-pane/status-bar/selected-lines.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment, useMemo } from 'react' +import { SeparatorDash } from './separator-dash' +import { Trans, useTranslation } from 'react-i18next' +import { useLineBasedFromPosition, useLineBasedToPosition } from '../hooks/use-line-based-position' + +/** + * Shows the total number of selected lines. + */ +export const SelectedLines: React.FC = () => { + useTranslation() + + const from = useLineBasedFromPosition() + const to = useLineBasedToPosition() + + const count = useMemo(() => (to ? to?.line - from.line + 1 : 0), [from.line, to]) + + const countTranslationOptions = useMemo(() => ({ count }), [count]) + + return count <= 1 ? null : ( + + + + + + + ) +} 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 2255bc272..bac989d91 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 @@ -9,56 +9,25 @@ import styles from './status-bar.module.scss' import { RemainingCharactersInfo } from './remaining-characters-info' import { NumberOfLinesInDocumentInfo } from './number-of-lines-in-document-info' 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: CursorPosition - selectedColumns: number - selectedLines: number - linesInDocument: number - charactersInDocument: number - remainingCharacters: number -} - -export const defaultState: StatusBarInfo = { - position: { line: 0, character: 0 }, - selectedColumns: 0, - selectedLines: 0, - linesInDocument: 0, - charactersInDocument: 0, - remainingCharacters: 0 -} +import { SelectedCharacters } from './selected-characters' +import { SelectedLines } from './selected-lines' /** * Shows additional information about the document length and the current selection. */ export const StatusBar: React.FC = () => { - const statusBarInfo = useCreateStatusBarInfo() - return (
- - 0}> - - - - 1}> - - - + + +
- + - +
) diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-boolean-property.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-boolean-property.tsx deleted file mode 100644 index 699e92af9..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-boolean-property.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { EditorConfiguration } from 'codemirror' -import type { ChangeEvent } from 'react' -import React, { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { mergeEditorPreferences } from '../../../../../redux/editor/methods' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' -import type { EditorPreferenceProperty } from './editor-preference-property' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' - -export interface EditorPreferenceBooleanProps { - property: EditorPreferenceProperty -} - -export const EditorPreferenceBooleanProperty: React.FC = ({ property }) => { - const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '') - - const { t } = useTranslation() - const selectItem = useCallback( - (event: ChangeEvent) => { - const selectedItem: boolean = event.target.value === 'true' - - mergeEditorPreferences({ - [property]: selectedItem - } as EditorConfiguration) - }, - [property] - ) - - const i18nPrefix = `editor.modal.preferences.${property}` - - return ( - - - - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-input.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-input.tsx deleted file mode 100644 index 54fcbbab5..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-input.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import React from 'react' -import { Form } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' - -export enum EditorPreferenceInputType { - SELECT, - BOOLEAN, - NUMBER -} - -export interface EditorPreferenceInputProps { - property: string - type: EditorPreferenceInputType - onChange: React.ChangeEventHandler - value?: string | number | string[] -} - -export const EditorPreferenceInput: React.FC = ({ - property, - type, - onChange, - value, - children -}) => { - useTranslation() - return ( - - - - - - {children} - - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-ligatures-select.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-ligatures-select.tsx deleted file mode 100644 index d4a45a284..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-ligatures-select.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { ChangeEvent } from 'react' -import React, { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { setEditorLigatures } from '../../../../../redux/editor/methods' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' - -export const EditorPreferenceLigaturesSelect: React.FC = () => { - const ligaturesEnabled = useApplicationState((state) => Boolean(state.editorConfig.ligatures).toString()) - const saveLigatures = useCallback((event: ChangeEvent) => { - const ligaturesActivated: boolean = event.target.value === 'true' - setEditorLigatures(ligaturesActivated) - }, []) - const { t } = useTranslation() - - return ( - - - - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-number-property.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-number-property.tsx deleted file mode 100644 index f43c62998..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-number-property.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { EditorConfiguration } from 'codemirror' -import type { ChangeEvent } from 'react' -import React, { useCallback } from 'react' -import { mergeEditorPreferences } from '../../../../../redux/editor/methods' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' -import type { EditorPreferenceProperty } from './editor-preference-property' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' - -export interface EditorPreferenceNumberProps { - property: EditorPreferenceProperty -} - -export const EditorPreferenceNumberProperty: React.FC = ({ property }) => { - const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '') - - const selectItem = useCallback( - (event: ChangeEvent) => { - const selectedItem: number = Number.parseInt(event.target.value) - - mergeEditorPreferences({ - [property]: selectedItem - } as EditorConfiguration) - }, - [property] - ) - - return ( - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-property.ts b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-property.ts deleted file mode 100644 index f25a09634..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-property.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export enum EditorPreferenceProperty { - KEYMAP = 'keyMap', - THEME = 'theme', - INDENT_WITH_TABS = 'indentWithTabs', - INDENT_UNIT = 'indentUnit', - SPELL_CHECK = 'spellcheck' -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-select-property.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-select-property.tsx deleted file mode 100644 index 2405dbdfe..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-select-property.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { EditorConfiguration } from 'codemirror' -import type { ChangeEvent } from 'react' -import React, { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { mergeEditorPreferences } from '../../../../../redux/editor/methods' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' -import type { EditorPreferenceProperty } from './editor-preference-property' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' - -export interface EditorPreferenceSelectPropertyProps { - property: EditorPreferenceProperty - selections: string[] -} - -export const EditorPreferenceSelectProperty: React.FC = ({ - property, - selections -}) => { - const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '') - - const { t } = useTranslation() - - const selectItem = useCallback( - (event: ChangeEvent) => { - const selectedItem: string = event.target.value - - mergeEditorPreferences({ - [property]: selectedItem - } as EditorConfiguration) - }, - [property] - ) - - const i18nPrefix = `editor.modal.preferences.${property}` - - return ( - - {selections.map((selection) => ( - - ))} - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx deleted file mode 100644 index e51ff2bfa..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { ChangeEvent } from 'react' -import React, { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' -import { setEditorSmartPaste } from '../../../../../redux/editor/methods' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' - -export const EditorPreferenceSmartPasteSelect: React.FC = () => { - const smartPasteEnabled = useApplicationState((state) => Boolean(state.editorConfig.smartPaste).toString()) - const saveSmartPaste = useCallback((event: ChangeEvent) => { - const smartPasteActivated: boolean = event.target.value === 'true' - setEditorSmartPaste(smartPasteActivated) - }, []) - const { t } = useTranslation() - - return ( - - - - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx deleted file mode 100644 index bb62e3f0d..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import React, { Fragment, useState } from 'react' -import { Button, Form, ListGroup } from 'react-bootstrap' -import { useTranslation } from 'react-i18next' -import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon' -import { CommonModal } from '../../../../common/modals/common-modal' -import { ShowIf } from '../../../../common/show-if/show-if' -import { EditorPreferenceBooleanProperty } from './editor-preference-boolean-property' -import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' -import { EditorPreferenceLigaturesSelect } from './editor-preference-ligatures-select' -import { EditorPreferenceNumberProperty } from './editor-preference-number-property' -import { EditorPreferenceProperty } from './editor-preference-property' -import { EditorPreferenceSelectProperty } from './editor-preference-select-property' -import { EditorPreferenceSmartPasteSelect } from './editor-preference-smart-paste-select' -import { useApplicationState } from '../../../../../hooks/common/use-application-state' - -export const EditorPreferences: React.FC = () => { - const { t } = useTranslation() - const [showModal, setShowModal] = useState(false) - const indentWithTabs = useApplicationState((state) => state.editorConfig.preferences.indentWithTabs ?? false) - - return ( - - - setShowModal(false)} - title={'editor.modal.preferences.title'} - showCloseButton={true} - titleIcon={'wrench'}> -
- - - - - - - - - - - - - - - - - - - - - - - alert('This feature is not yet implemented.')} - property={EditorPreferenceProperty.SPELL_CHECK} - type={EditorPreferenceInputType.SELECT}> - - - - - -
-
-
- ) -} 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 351037273..d46bdd451 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 @@ -6,7 +6,6 @@ import React from 'react' 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' @@ -46,9 +45,6 @@ export const ToolBar: React.FC = () => { - - - ) } 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 f7742a45f..5540fdbea 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 @@ -13,20 +13,19 @@ import { Mock } from 'ts-mockery' describe('Check whether cursor is in codefence', () => { const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState') - const mockRedux = (content: string, line: number): void => { + const mockRedux = (content: string, from: number): void => { const contentLines = content.split('\n') getGlobalStateMocked.mockImplementation(() => Mock.from({ noteDetails: { ...initialState, selection: { - from: { - line: line, - character: 0 - } + from }, - markdownContentLines: contentLines, - markdownContent: content + markdownContent: { + plain: content, + lines: contentLines + } } }) ) @@ -46,22 +45,22 @@ describe('Check whether cursor is in codefence', () => { }) it('returns true with one open codefence directly above', () => { - mockRedux('```\n', 1) + mockRedux('```\n', 4) expect(isCursorInCodeFence()).toBe(true) }) it('returns true with one open codefence and empty lines above', () => { - mockRedux('```\n\n\n', 3) + mockRedux('```\n\n\n', 5) expect(isCursorInCodeFence()).toBe(true) }) it('returns false with one completed codefence above', () => { - mockRedux('```\n\n```\n', 3) + mockRedux('```\n\n```\n', 8) expect(isCursorInCodeFence()).toBe(false) }) it('returns true with one completed and one open codefence above', () => { - mockRedux('```\n\n```\n\n```\n\n', 6) + mockRedux('```\n\n```\n\n```\n\n', 13) 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 49aa2ddd0..30e2d65e2 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 @@ -10,10 +10,8 @@ import { getGlobalState } from '../../../../../redux' * 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 - ) + const noteDetails = getGlobalState().noteDetails + const lines = noteDetails.markdownContent.plain.slice(0, noteDetails.selection.from).split('\n') return countCodeFenceLinesUntilIndex(lines) % 2 === 1 } 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 bab0726f6..58da7afd3 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 @@ -22,21 +22,20 @@ export interface PasteEvent { } /** - * 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. + * Checks if the given {@link DataTransfer clipboard data} contains a text formatted table + * and inserts it into the markdown content. This happens only if smart paste is activated. * - * @param event The {@link PasteEvent} from the browser + * @param clipboardData The {@link DataTransfer} from the paste event * @return {@code true} if the event was processed. {@code false} otherwise */ -export const handleTablePaste = (event: PasteEvent): boolean => { +export const handleTablePaste = (clipboardData: DataTransfer): boolean => { if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) { return false } - return Optional.ofNullable(event.clipboardData.getData('text')) - .filter((pasteText) => !!pasteText && isTable(pasteText)) - .map((pasteText) => convertClipboardTableToMarkdown(pasteText)) + return Optional.ofNullable(clipboardData.getData('text')) + .filter(isTable) + .map(convertClipboardTableToMarkdown) .map((markdownTable) => { replaceSelection(markdownTable) return true @@ -47,12 +46,12 @@ export const handleTablePaste = (event: PasteEvent): boolean => { /** * Checks if the given {@link PasteEvent paste event} contains files and uploads them. * - * @param event The {@link PasteEvent} from the browser + * @param clipboardData The {@link DataTransfer} from the paste event * @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) +export const handleFilePaste = (clipboardData: DataTransfer): boolean => { + return Optional.of(clipboardData.files) + .filter((files) => files.length > 0) .map((files) => { handleUpload(files[0]) return true diff --git a/src/components/editor-page/renderer-pane/render-iframe.tsx b/src/components/editor-page/renderer-pane/render-iframe.tsx index 6f42211b7..db2657267 100644 --- a/src/components/editor-page/renderer-pane/render-iframe.tsx +++ b/src/components/editor-page/renderer-pane/render-iframe.tsx @@ -86,7 +86,7 @@ export const RenderIframe: React.FC = ({ ) useEditorReceiveHandler( - CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER, + CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE, useCallback(() => onMakeScrollSource?.(), [onMakeScrollSource]) ) diff --git a/src/components/render-page/iframe-markdown-renderer.tsx b/src/components/render-page/iframe-markdown-renderer.tsx index be52113f2..92b78a852 100644 --- a/src/components/render-page/iframe-markdown-renderer.tsx +++ b/src/components/render-page/iframe-markdown-renderer.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import type { ScrollState } from '../editor-page/synced-scroll/scroll-props' import type { BaseConfiguration } from './window-post-message-communicator/rendering-message' import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message' @@ -27,26 +27,50 @@ export const IframeMarkdownRenderer: React.FC = () => { const communicator = useRendererToEditorCommunicator() - const countWordsInRenderedDocument = useCallback(() => { - const documentContainer = document.querySelector('[data-word-count-target]') - communicator.sendMessageToOtherSide({ - type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED, - words: documentContainer ? countWords(documentContainer) : 0 - }) - }, [communicator]) + const sendScrolling = useRef(false) - useRendererReceiveHandler(CommunicationMessageType.SET_BASE_CONFIGURATION, (values) => - setBaseConfiguration(values.baseConfiguration) + useRendererReceiveHandler( + CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE, + useCallback(() => { + sendScrolling.current = false + }, []) ) - useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) => - setMarkdownContentLines(values.content) + + useRendererReceiveHandler( + CommunicationMessageType.SET_BASE_CONFIGURATION, + useCallback((values) => setBaseConfiguration(values.baseConfiguration), []) ) - useRendererReceiveHandler(CommunicationMessageType.SET_DARKMODE, (values) => setDarkMode(values.activated)) - useRendererReceiveHandler(CommunicationMessageType.SET_SCROLL_STATE, (values) => setScrollState(values.scrollState)) - useRendererReceiveHandler(CommunicationMessageType.SET_FRONTMATTER_INFO, (values) => - setFrontmatterInfo(values.frontmatterInfo) + + useRendererReceiveHandler( + CommunicationMessageType.SET_MARKDOWN_CONTENT, + useCallback((values) => setMarkdownContentLines(values.content), []) + ) + + useRendererReceiveHandler( + CommunicationMessageType.SET_DARKMODE, + useCallback((values) => setDarkMode(values.activated), []) + ) + + useRendererReceiveHandler( + CommunicationMessageType.SET_SCROLL_STATE, + useCallback((values) => setScrollState(values.scrollState), []) + ) + + useRendererReceiveHandler( + CommunicationMessageType.SET_FRONTMATTER_INFO, + useCallback((values) => setFrontmatterInfo(values.frontmatterInfo), []) + ) + + useRendererReceiveHandler( + CommunicationMessageType.GET_WORD_COUNT, + useCallback(() => { + const documentContainer = document.querySelector('[data-word-count-target]') + communicator.sendMessageToOtherSide({ + type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED, + words: documentContainer ? countWords(documentContainer) : 0 + }) + }, [communicator]) ) - useRendererReceiveHandler(CommunicationMessageType.GET_WORD_COUNT, () => countWordsInRenderedDocument()) const onTaskCheckedChange = useCallback( (lineInMarkdown: number, checked: boolean) => { @@ -70,13 +94,17 @@ export const IframeMarkdownRenderer: React.FC = () => { ) const onMakeScrollSource = useCallback(() => { + sendScrolling.current = true communicator.sendMessageToOtherSide({ - type: CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER + type: CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE }) }, [communicator]) const onScroll = useCallback( (scrollState: ScrollState) => { + if (!sendScrolling.current) { + return + } communicator.sendMessageToOtherSide({ type: CommunicationMessageType.SET_SCROLL_STATE, scrollState diff --git a/src/components/render-page/window-post-message-communicator/hooks/use-editor-receive-handler.ts b/src/components/render-page/window-post-message-communicator/hooks/use-editor-receive-handler.ts index 9ca0adc1a..ffebe0ef6 100644 --- a/src/components/render-page/window-post-message-communicator/hooks/use-editor-receive-handler.ts +++ b/src/components/render-page/window-post-message-communicator/hooks/use-editor-receive-handler.ts @@ -7,7 +7,7 @@ import { useEffect } from 'react' import type { CommunicationMessages, RendererToEditorMessageType } from '../rendering-message' import { useEditorToRendererCommunicator } from '../../../editor-page/render-context/editor-to-renderer-communicator-context-provider' -import type { Handler } from '../window-post-message-communicator' +import type { MaybeHandler } from '../window-post-message-communicator' /** * Sets the handler for the given message type in the current editor to renderer communicator. @@ -17,7 +17,7 @@ import type { Handler } from '../window-post-message-communicator' */ export const useEditorReceiveHandler = ( messageType: R, - handler: Handler + handler: MaybeHandler ): void => { const editorToRendererCommunicator = useEditorToRendererCommunicator() useEffect(() => { diff --git a/src/components/render-page/window-post-message-communicator/hooks/use-renderer-receive-handler.ts b/src/components/render-page/window-post-message-communicator/hooks/use-renderer-receive-handler.ts index d8ae29491..96c242a9d 100644 --- a/src/components/render-page/window-post-message-communicator/hooks/use-renderer-receive-handler.ts +++ b/src/components/render-page/window-post-message-communicator/hooks/use-renderer-receive-handler.ts @@ -9,6 +9,11 @@ import type { CommunicationMessages, EditorToRendererMessageType } from '../rend import type { Handler } from '../window-post-message-communicator' import { useRendererToEditorCommunicator } from '../../../editor-page/render-context/renderer-to-editor-communicator-context-provider' +export type CommunicationMessageHandler = Handler< + CommunicationMessages, + MESSAGE_TYPE +> + /** * Sets the handler for the given message type in the current renderer to editor communicator. * @@ -17,7 +22,7 @@ import { useRendererToEditorCommunicator } from '../../../editor-page/render-con */ export const useRendererReceiveHandler = ( messageType: MESSAGE_TYPE, - handler: Handler + handler: CommunicationMessageHandler ): void => { const editorToRendererCommunicator = useRendererToEditorCommunicator() useEffect(() => { diff --git a/src/components/render-page/window-post-message-communicator/rendering-message.ts b/src/components/render-page/window-post-message-communicator/rendering-message.ts index 033e6ab7a..b1d624c5f 100644 --- a/src/components/render-page/window-post-message-communicator/rendering-message.ts +++ b/src/components/render-page/window-post-message-communicator/rendering-message.ts @@ -12,7 +12,8 @@ export enum CommunicationMessageType { SET_DARKMODE = 'SET_DARKMODE', ON_TASK_CHECKBOX_CHANGE = 'ON_TASK_CHECKBOX_CHANGE', ON_FIRST_HEADING_CHANGE = 'ON_FIRST_HEADING_CHANGE', - SET_SCROLL_SOURCE_TO_RENDERER = 'SET_SCROLL_SOURCE_TO_RENDERER', + ENABLE_RENDERER_SCROLL_SOURCE = 'ENABLE_RENDERER_SCROLL_SOURCE', + DISABLE_RENDERER_SCROLL_SOURCE = 'DISABLE_RENDERER_SCROLL_SOURCE', SET_SCROLL_STATE = 'SET_SCROLL_STATE', IMAGE_CLICKED = 'IMAGE_CLICKED', ON_HEIGHT_CHANGE = 'ON_HEIGHT_CHANGE', @@ -23,8 +24,8 @@ export enum CommunicationMessageType { IMAGE_UPLOAD = 'IMAGE_UPLOAD' } -export interface NoPayloadMessage { - type: CommunicationMessageType.RENDERER_READY | CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER +export interface NoPayloadMessage { + type: TYPE } export interface SetDarkModeMessage { @@ -97,7 +98,9 @@ export interface OnWordCountCalculatedMessage { } export type CommunicationMessages = - | NoPayloadMessage + | NoPayloadMessage + | NoPayloadMessage + | NoPayloadMessage | SetDarkModeMessage | SetBaseUrlMessage | GetWordCountMessage @@ -118,10 +121,11 @@ export type EditorToRendererMessageType = | CommunicationMessageType.SET_BASE_CONFIGURATION | CommunicationMessageType.GET_WORD_COUNT | CommunicationMessageType.SET_FRONTMATTER_INFO + | CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE export type RendererToEditorMessageType = | CommunicationMessageType.RENDERER_READY - | CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER + | CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE | CommunicationMessageType.ON_FIRST_HEADING_CHANGE | CommunicationMessageType.ON_TASK_CHECKBOX_CHANGE | CommunicationMessageType.SET_SCROLL_STATE diff --git a/src/components/render-page/window-post-message-communicator/window-post-message-communicator.ts b/src/components/render-page/window-post-message-communicator/window-post-message-communicator.ts index 4d36379db..b0cef7678 100644 --- a/src/components/render-page/window-post-message-communicator/window-post-message-communicator.ts +++ b/src/components/render-page/window-post-message-communicator/window-post-message-communicator.ts @@ -11,12 +11,14 @@ import type { Logger } from '../../../utils/logger' */ export class IframeCommunicatorSendingError extends Error {} -export type Handler = - | ((values: Extract>) => void) - | undefined +export type Handler = ( + values: Extract> +) => void + +export type MaybeHandler = Handler | undefined export type HandlerMap = Partial<{ - [key in MESSAGE_TYPE]: Handler + [key in MESSAGE_TYPE]: MaybeHandler }> export interface PostMessage { @@ -108,8 +110,9 @@ export abstract class WindowPostMessageCommunicator< * @param messageType The message type for which the handler should be called * @param handler The handler that processes messages with the given message type. */ - public setHandler(messageType: R, handler: Handler): void { - this.handlers[messageType] = handler as Handler + public setHandler(messageType: R, handler: MaybeHandler): void { + this.log.debug('Set handler for', messageType) + this.handlers[messageType] = handler as MaybeHandler } /** diff --git a/src/hooks/common/use-note-markdown-content.ts b/src/hooks/common/use-note-markdown-content.ts index 54b1e52b9..3ee1bda08 100644 --- a/src/hooks/common/use-note-markdown-content.ts +++ b/src/hooks/common/use-note-markdown-content.ts @@ -11,5 +11,5 @@ import { useApplicationState } from './use-application-state' * @return the markdown content of the note */ export const useNoteMarkdownContent = (): string => { - return useApplicationState((state) => state.noteDetails.markdownContent) + return useApplicationState((state) => state.noteDetails.markdownContent.plain) } diff --git a/src/hooks/common/use-trimmed-note-markdown-content-without-frontmatter.ts b/src/hooks/common/use-trimmed-note-markdown-content-without-frontmatter.ts index f2ed91364..9e46fd06f 100644 --- a/src/hooks/common/use-trimmed-note-markdown-content-without-frontmatter.ts +++ b/src/hooks/common/use-trimmed-note-markdown-content-without-frontmatter.ts @@ -15,8 +15,8 @@ export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => { const maxLength = useApplicationState((state) => state.config.maxDocumentLength) const markdownContent = useApplicationState( (state) => ({ - lines: state.noteDetails.markdownContentLines, - content: state.noteDetails.markdownContent + lines: state.noteDetails.markdownContent.lines, + content: state.noteDetails.markdownContent.plain }), equal ) diff --git a/src/pages/n/[id].tsx b/src/pages/n/[id].tsx index ef08cc87d..b47c205ff 100644 --- a/src/pages/n/[id].tsx +++ b/src/pages/n/[id].tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Suspense, useCallback, useMemo, useRef, useState } from 'react' +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode' import { setCheckboxInMarkdownContent, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' @@ -32,6 +32,7 @@ import type { NextPage } from 'next' import { isClientSideRendering } from '../../utils/is-client-side-rendering' import { LoadingScreen } from '../../components/application-loader/loading-screen/loading-screen' import { NoteAndAppTitleHead } from '../../components/layout/note-and-app-title-head' +import equal from 'fast-deep-equal' const EditorPane = React.lazy(() => import('../../components/editor-page/editor-pane/editor-pane')) @@ -64,22 +65,30 @@ export const EditorPage: NextPage = () => { (newScrollState: ScrollState) => { if (scrollSource.current === ScrollSource.RENDERER && editorSyncScroll) { setScrollState((old) => { - const newState = { editorScrollState: newScrollState, rendererScrollState: old.rendererScrollState } - log.debug('Set scroll state because of renderer scroll', newState) - return newState + const newState: DualScrollState = { + editorScrollState: newScrollState, + rendererScrollState: old.rendererScrollState + } + return equal(newState, old) ? old : newState }) } }, [editorSyncScroll] ) + useEffect(() => { + log.debug('New scroll state', scrollState, scrollSource) + }, [scrollState]) + const onEditorScroll = useCallback( (newScrollState: ScrollState) => { if (scrollSource.current === ScrollSource.EDITOR && editorSyncScroll) { setScrollState((old) => { - const newState = { rendererScrollState: newScrollState, editorScrollState: old.editorScrollState } - log.debug('Set scroll state because of editor scroll', newState) - return newState + const newState: DualScrollState = { + rendererScrollState: newScrollState, + editorScrollState: old.editorScrollState + } + return equal(newState, old) ? old : newState }) } }, @@ -95,13 +104,17 @@ export const EditorPage: NextPage = () => { useUpdateLocalHistoryEntry(!error && !loading) const setRendererToScrollSource = useCallback(() => { - scrollSource.current = ScrollSource.RENDERER - log.debug('Make renderer scroll source') + if (scrollSource.current !== ScrollSource.RENDERER) { + scrollSource.current = ScrollSource.RENDERER + log.debug('Make renderer scroll source') + } }, []) const setEditorToScrollSource = useCallback(() => { - scrollSource.current = ScrollSource.EDITOR - log.debug('Make editor scroll source') + if (scrollSource.current !== ScrollSource.EDITOR) { + scrollSource.current = ScrollSource.EDITOR + log.debug('Make editor scroll source') + } }, []) const leftPane = useMemo( diff --git a/src/redux/editor/methods.ts b/src/redux/editor/methods.ts index 573946971..b7dd7c77b 100644 --- a/src/redux/editor/methods.ts +++ b/src/redux/editor/methods.ts @@ -4,13 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { EditorConfiguration } from 'codemirror' import { store } from '..' import type { EditorMode } from '../../components/editor-page/app-bar/editor-view-mode' import type { EditorConfig, SetEditorLigaturesAction, - SetEditorPreferencesAction, SetEditorSmartPasteAction, SetEditorSyncScrollAction, SetEditorViewModeAction @@ -72,11 +70,3 @@ export const setEditorSmartPaste = (smartPaste: boolean): void => { } store.dispatch(action) } - -export const mergeEditorPreferences = (preferences: EditorConfiguration): void => { - const action: SetEditorPreferencesAction = { - type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES, - preferences - } - store.dispatch(action) -} diff --git a/src/redux/editor/reducers.ts b/src/redux/editor/reducers.ts index e55e4dddf..de6d5a831 100644 --- a/src/redux/editor/reducers.ts +++ b/src/redux/editor/reducers.ts @@ -14,13 +14,7 @@ const initialState: EditorConfig = { editorMode: EditorMode.BOTH, ligatures: true, syncScroll: true, - smartPaste: true, - preferences: { - theme: 'one-dark', - keyMap: 'sublime', - indentUnit: 4, - indentWithTabs: false - } + smartPaste: true } const getInitialState = (): EditorConfig => { @@ -61,16 +55,6 @@ export const EditorConfigReducer: Reducer = ( } saveToLocalStorage(newState) return newState - case EditorConfigActionType.MERGE_EDITOR_PREFERENCES: - newState = { - ...state, - preferences: { - ...state.preferences, - ...action.preferences - } - } - saveToLocalStorage(newState) - return newState default: return state } diff --git a/src/redux/editor/types.ts b/src/redux/editor/types.ts index 25ad05916..de522bfda 100644 --- a/src/redux/editor/types.ts +++ b/src/redux/editor/types.ts @@ -4,26 +4,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { EditorConfiguration } from 'codemirror' import type { Action } from 'redux' import type { EditorMode } from '../../components/editor-page/app-bar/editor-view-mode' export enum EditorConfigActionType { SET_EDITOR_VIEW_MODE = 'editor/view-mode/set', SET_SYNC_SCROLL = 'editor/syncScroll/set', - MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge', SET_LIGATURES = 'editor/preferences/setLigatures', SET_SMART_PASTE = 'editor/preferences/setSmartPaste' } -export interface CursorPosition { - line: number - character: number -} - export interface CursorSelection { - from: CursorPosition - to?: CursorPosition + from: number + to?: number } export interface EditorConfig { @@ -31,7 +24,6 @@ export interface EditorConfig { syncScroll: boolean ligatures: boolean smartPaste: boolean - preferences: EditorConfiguration } export type EditorConfigActions = @@ -39,7 +31,6 @@ export type EditorConfigActions = | SetEditorLigaturesAction | SetEditorSmartPasteAction | SetEditorViewModeAction - | SetEditorPreferencesAction export interface SetEditorSyncScrollAction extends Action { type: EditorConfigActionType.SET_SYNC_SCROLL @@ -60,8 +51,3 @@ export interface SetEditorViewModeAction extends Action type: EditorConfigActionType.SET_EDITOR_VIEW_MODE mode: EditorMode } - -export interface SetEditorPreferencesAction extends Action { - type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES - preferences: EditorConfiguration -} 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 index 948068649..0d5fcea08 100644 --- a/src/redux/note-details/build-state-from-updated-markdown-content.ts +++ b/src/redux/note-details/build-state-from-updated-markdown-content.ts @@ -10,6 +10,7 @@ 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' +import { calculateLineStartIndexes } from './calculate-line-start-indexes' /** * Copies a {@link NoteDetails} but with another markdown content. @@ -40,20 +41,27 @@ const buildStateFromMarkdownContentAndLines = ( markdownContentLines: string[] ): NoteDetails => { const frontmatterExtraction = extractFrontmatter(markdownContentLines) + const lineStartIndexes = calculateLineStartIndexes(markdownContentLines) if (frontmatterExtraction.isPresent) { return buildStateFromFrontmatterUpdate( { ...state, - markdownContent: markdownContent, - markdownContentLines: markdownContentLines + markdownContent: { + plain: markdownContent, + lines: markdownContentLines, + lineStartIndexes + } }, frontmatterExtraction ) } else { return { ...state, - markdownContent: markdownContent, - markdownContentLines: markdownContentLines, + markdownContent: { + plain: markdownContent, + lines: markdownContentLines, + lineStartIndexes + }, rawFrontmatter: '', noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading), frontmatter: initialState.frontmatter, diff --git a/src/redux/note-details/calculate-line-start-indexes.test.ts b/src/redux/note-details/calculate-line-start-indexes.test.ts new file mode 100644 index 000000000..1bdc3cdf5 --- /dev/null +++ b/src/redux/note-details/calculate-line-start-indexes.test.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { calculateLineStartIndexes } from './calculate-line-start-indexes' + +describe('calculateLineStartIndexes', () => { + it('works with an empty list', () => { + expect(calculateLineStartIndexes([])).toEqual([]) + }) + it('works with an non empty list', () => { + expect(calculateLineStartIndexes(['a', 'bc', 'def', 'ghij', 'klmno', 'pqrstu', 'vwxyz'])).toEqual([ + 0, 2, 5, 9, 14, 20, 27 + ]) + }) + it('works with an non empty list with empty lines', () => { + expect(calculateLineStartIndexes(['', '', ''])).toEqual([0, 1, 2]) + }) +}) diff --git a/src/redux/note-details/calculate-line-start-indexes.ts b/src/redux/note-details/calculate-line-start-indexes.ts new file mode 100644 index 000000000..efb91d121 --- /dev/null +++ b/src/redux/note-details/calculate-line-start-indexes.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Calculates the absolute start position of every line. + * + * @param markdownContentLines The lines of the document + * @returns the calculated line starts + */ +export const calculateLineStartIndexes = (markdownContentLines: string[]): number[] => { + return markdownContentLines.reduce((state, line, lineIndex, lines) => { + const lastIndex = lineIndex === 0 ? 0 : state[lineIndex - 1] + lines[lineIndex - 1].length + 1 + return [...state, lastIndex] + }, [] as number[]) +} 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 index 30a4b3088..925dfa03e 100644 --- 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 @@ -7,21 +7,21 @@ 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 type { 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 prependLinesOfSelectionModule from './formatters/prepend-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 markdownContentMock = 'input' const cursorSelectionMock = Mock.of() const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection') - const wrapSelectionMockResponse = Mock.of() + const wrapSelectionMockResponse = Mock.of<[string, CursorSelection]>() const changeCursorsToWholeLineIfNoToCursorMock = jest.spyOn( changeCursorsToWholeLineIfNoToCursorModule, @@ -29,24 +29,24 @@ describe('apply format type to markdown lines', () => { ) const changeCursorsToWholeLineIfNoToCursorMockResponse = Mock.of() - const replaceLinesOfSelectionMock = jest.spyOn(replaceLinesOfSelectionModule, 'replaceLinesOfSelection') + const prependLinesOfSelectionMock = jest.spyOn(prependLinesOfSelectionModule, 'prependLinesOfSelection') const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection') - const replaceSelectionMockResponse = Mock.of() + const replaceSelectionMockResponse = Mock.of<[string, CursorSelection]>() const addLinkMock = jest.spyOn(addLinkModule, 'addLink') - const addLinkMockResponse = Mock.of() + const addLinkMockResponse = Mock.of<[string, CursorSelection]>() beforeAll(() => { wrapSelectionMock.mockReturnValue(wrapSelectionMockResponse) changeCursorsToWholeLineIfNoToCursorMock.mockReturnValue(changeCursorsToWholeLineIfNoToCursorMockResponse) - replaceLinesOfSelectionMock.mockImplementation( + prependLinesOfSelectionMock.mockImplementation( ( - lines: string[], + markdownContent: string, selection: CursorSelection, - replacer: (line: string, lineIndex: number) => string - ): string[] => { - return lines.map(replacer) + generatePrefix: (line: string, lineIndexInBlock: number) => string + ): [string, CursorSelection] => { + return [generatePrefix(markdownContent, 0) + markdownContent, selection] } ) replaceSelectionMock.mockReturnValue(replaceSelectionMockResponse) @@ -58,57 +58,53 @@ describe('apply format type to markdown lines', () => { }) it('can process the format type bold', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.BOLD) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.BOLD) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '**', '**') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '**', '**') }) it('can process the format type italic', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.ITALIC) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.ITALIC) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '*', '*') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '*', '*') }) it('can process the format type strikethrough', () => { - const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, - cursorSelectionMock, - FormatType.STRIKETHROUGH - ) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.STRIKETHROUGH) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~~', '~~') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '~~', '~~') }) it('can process the format type underline', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.UNDERLINE) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.UNDERLINE) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '++', '++') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '++', '++') }) it('can process the format type subscript', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUBSCRIPT) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.SUBSCRIPT) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '~', '~') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '~', '~') }) it('can process the format type superscript', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.SUPERSCRIPT) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.SUPERSCRIPT) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '^', '^') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '^', '^') }) it('can process the format type highlight', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.HIGHLIGHT) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.HIGHLIGHT) expect(result).toBe(wrapSelectionMockResponse) - expect(wrapSelectionMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '==', '==') + expect(wrapSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '==', '==') }) it('can process the format type code fence', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.CODE_FENCE) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.CODE_FENCE) expect(result).toBe(wrapSelectionMockResponse) - expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock) + expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentMock, cursorSelectionMock) expect(wrapSelectionMock).toBeCalledWith( - markdownContentLinesMock, + markdownContentMock, changeCursorsToWholeLineIfNoToCursorMockResponse, '```\n', '\n```' @@ -116,91 +112,83 @@ describe('apply format type to markdown lines', () => { }) 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()) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.UNORDERED_LIST) + expect(result).toEqual(['- input', cursorSelectionMock]) + expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, 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()) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.ORDERED_LIST) + expect(result).toEqual(['1. input', cursorSelectionMock]) + expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, 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()) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.CHECK_LIST) + expect(result).toEqual(['- [ ] input', cursorSelectionMock]) + expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, 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()) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.QUOTES) + expect(result).toEqual(['> input', cursorSelectionMock]) + expect(prependLinesOfSelectionMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, expect.anything()) }) it('can process the format type horizontal line with only from cursor', () => { - const fromCursor = Mock.of() + const randomCursorPosition = 138743857 const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, - { from: fromCursor }, + markdownContentMock, + { from: randomCursorPosition }, FormatType.HORIZONTAL_LINE ) expect(result).toEqual(replaceSelectionMockResponse) - expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n----`) + expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: randomCursorPosition }, `\n----`) }) it('can process the format type horizontal line with from and to cursor', () => { - const fromCursor = Mock.of() - const toCursor = Mock.of() + const fromCursor = Math.random() + const toCursor = Math.random() const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, + markdownContentMock, { from: fromCursor, to: toCursor }, FormatType.HORIZONTAL_LINE ) expect(result).toEqual(replaceSelectionMockResponse) - expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n----`) + expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { 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) + const fromCursor = Math.random() + const result = applyFormatTypeToMarkdownLines(markdownContentMock, { from: fromCursor }, FormatType.COMMENT) expect(result).toEqual(replaceSelectionMockResponse) - expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: fromCursor }, `\n> []`) + expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: fromCursor }, `\n> []`) }) it('can process the format type comment with from and to cursor', () => { - const fromCursor = Mock.of() - const toCursor = Mock.of() + const fromCursor = 0 + const toCursor = 1 const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, + markdownContentMock, { from: fromCursor, to: toCursor }, FormatType.COMMENT ) expect(result).toEqual(replaceSelectionMockResponse) - expect(replaceSelectionMock).toBeCalledWith(markdownContentLinesMock, { from: toCursor }, `\n> []`) + expect(replaceSelectionMock).toBeCalledWith(markdownContentMock, { from: toCursor }, `\n> []`) }) it('can process the format type collapsible block', () => { const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, + markdownContentMock, cursorSelectionMock, FormatType.COLLAPSIBLE_BLOCK ) expect(result).toBe(wrapSelectionMockResponse) - expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock) + expect(changeCursorsToWholeLineIfNoToCursorMock).toBeCalledWith(markdownContentMock, cursorSelectionMock) expect(wrapSelectionMock).toBeCalledWith( - markdownContentLinesMock, + markdownContentMock, changeCursorsToWholeLineIfNoToCursorMockResponse, ':::spoiler Toggle label\n', '\n:::' @@ -208,30 +196,26 @@ describe('apply format type to markdown lines', () => { }) it('can process the format type header level with existing level', () => { - const inputLines = ['# text'] + const inputLines = '# text' const result = applyFormatTypeToMarkdownLines(inputLines, cursorSelectionMock, FormatType.HEADER_LEVEL) - expect(result).toEqual(['## text']) - expect(replaceLinesOfSelectionMock).toBeCalledWith(inputLines, cursorSelectionMock, expect.anything()) + expect(result).toEqual(['## text', cursorSelectionMock]) + expect(prependLinesOfSelectionMock).toBeCalledWith(inputLines, cursorSelectionMock, expect.anything()) }) it('can process the format type link', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.LINK) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.LINK) expect(result).toEqual(addLinkMockResponse) - expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock) + expect(addLinkMock).toBeCalledWith(markdownContentMock, cursorSelectionMock) }) it('can process the format type image link', () => { - const result = applyFormatTypeToMarkdownLines(markdownContentLinesMock, cursorSelectionMock, FormatType.IMAGE_LINK) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, FormatType.IMAGE_LINK) expect(result).toEqual(addLinkMockResponse) - expect(addLinkMock).toBeCalledWith(markdownContentLinesMock, cursorSelectionMock, '!') + expect(addLinkMock).toBeCalledWith(markdownContentMock, cursorSelectionMock, '!') }) it('can process an unknown format type ', () => { - const result = applyFormatTypeToMarkdownLines( - markdownContentLinesMock, - cursorSelectionMock, - 'UNKNOWN' as FormatType - ) - expect(result).toEqual(markdownContentLinesMock) + const result = applyFormatTypeToMarkdownLines(markdownContentMock, cursorSelectionMock, 'UNKNOWN' as FormatType) + expect(result).toEqual([markdownContentMock, cursorSelectionMock]) }) }) 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 index 5585808c9..fca550813 100644 --- 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 @@ -7,70 +7,68 @@ 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 { prependLinesOfSelection } from './formatters/prepend-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[], + markdownContent: string, selection: CursorSelection, type: FormatType -): string[] => { +): [string, CursorSelection] => { switch (type) { case FormatType.BOLD: - return wrapSelection(markdownContentLines, selection, '**', '**') + return wrapSelection(markdownContent, selection, '**', '**') case FormatType.ITALIC: - return wrapSelection(markdownContentLines, selection, '*', '*') + return wrapSelection(markdownContent, selection, '*', '*') case FormatType.STRIKETHROUGH: - return wrapSelection(markdownContentLines, selection, '~~', '~~') + return wrapSelection(markdownContent, selection, '~~', '~~') case FormatType.UNDERLINE: - return wrapSelection(markdownContentLines, selection, '++', '++') + return wrapSelection(markdownContent, selection, '++', '++') case FormatType.SUBSCRIPT: - return wrapSelection(markdownContentLines, selection, '~', '~') + return wrapSelection(markdownContent, selection, '~', '~') case FormatType.SUPERSCRIPT: - return wrapSelection(markdownContentLines, selection, '^', '^') + return wrapSelection(markdownContent, selection, '^', '^') case FormatType.HIGHLIGHT: - return wrapSelection(markdownContentLines, selection, '==', '==') + return wrapSelection(markdownContent, selection, '==', '==') case FormatType.CODE_FENCE: return wrapSelection( - markdownContentLines, - changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection), + markdownContent, + changeCursorsToWholeLineIfNoToCursor(markdownContent, selection), '```\n', '\n```' ) case FormatType.UNORDERED_LIST: - return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- ${line}`) + return prependLinesOfSelection(markdownContent, selection, () => `- `) case FormatType.ORDERED_LIST: - return replaceLinesOfSelection( - markdownContentLines, + return prependLinesOfSelection( + markdownContent, selection, - (line, lineIndexInBlock) => `${lineIndexInBlock + 1}. ${line}` + (line, lineIndexInBlock) => `${lineIndexInBlock + 1}. ` ) case FormatType.CHECK_LIST: - return replaceLinesOfSelection(markdownContentLines, selection, (line) => `- [ ] ${line}`) + return prependLinesOfSelection(markdownContent, selection, () => `- [ ] `) case FormatType.QUOTES: - return replaceLinesOfSelection(markdownContentLines, selection, (line) => `> ${line}`) + return prependLinesOfSelection(markdownContent, selection, () => `> `) case FormatType.HEADER_LEVEL: - return replaceLinesOfSelection(markdownContentLines, selection, (line) => - line.startsWith('#') ? `#${line}` : `# ${line}` - ) + return prependLinesOfSelection(markdownContent, selection, (line) => (line.startsWith('#') ? `#` : `# `)) case FormatType.HORIZONTAL_LINE: - return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n----') + return replaceSelection(markdownContent, { from: selection.to ?? selection.from }, '\n----') case FormatType.COMMENT: - return replaceSelection(markdownContentLines, { from: selection.to ?? selection.from }, '\n> []') + return replaceSelection(markdownContent, { from: selection.to ?? selection.from }, '\n> []') case FormatType.COLLAPSIBLE_BLOCK: return wrapSelection( - markdownContentLines, - changeCursorsToWholeLineIfNoToCursor(markdownContentLines, selection), + markdownContent, + changeCursorsToWholeLineIfNoToCursor(markdownContent, selection), ':::spoiler Toggle label\n', '\n:::' ) case FormatType.LINK: - return addLink(markdownContentLines, selection) + return addLink(markdownContent, selection) case FormatType.IMAGE_LINK: - return addLink(markdownContentLines, selection, '!') + return addLink(markdownContent, selection, '!') default: - return markdownContentLines + return [markdownContent, selection] } } 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 index 0879a9f8f..2fe0259e5 100644 --- a/src/redux/note-details/format-selection/formatters/add-link.test.ts +++ b/src/redux/note-details/format-selection/formatters/add-link.test.ts @@ -9,91 +9,78 @@ 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://)']) + const actual = addLink('', { from: 0 }, '') + expect(actual).toEqual(['[](https://)', { from: 0, to: 12 }]) }) it('inserts a link into a line', () => { - const actual = addLink(['aa'], { from: { line: 0, character: 1 } }, '') - expect(actual).toEqual(['a[](https://)a']) + const actual = addLink('aa', { from: 1 }, '') + expect(actual).toEqual(['a[](https://)a', { from: 1, to: 13 }]) }) it('inserts a link with a prefix', () => { - const actual = addLink([''], { from: { line: 0, character: 0 } }, 'prefix') - expect(actual).toEqual(['prefix[](https://)']) + const actual = addLink('', { from: 0 }, 'prefix') + expect(actual).toEqual(['prefix[](https://)', { from: 0, to: 18 }]) }) }) describe('with a normal text selected', () => { it('wraps the selection', () => { const actual = addLink( - ['a'], + 'a', { - from: { line: 0, character: 0 }, - to: { - line: 0, - character: 1 - } + from: 0, + to: 1 }, '' ) - expect(actual).toEqual(['[a](https://)']) + expect(actual).toEqual(['[a](https://)', { from: 0, to: 13 }]) }) 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']) + const actual = addLink('aba', { from: 1, to: 2 }, '') + expect(actual).toEqual(['a[b](https://)a', { from: 1, to: 14 }]) }) 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://)']) + const actual = addLink('a', { from: 0, to: 1 }, 'prefix') + expect(actual).toEqual(['prefix[a](https://)', { from: 0, to: 19 }]) }) 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://)']) + const actual = addLink('a\nb\nc', { from: 0, to: 5 }, '') + expect(actual).toEqual(['[a\nb\nc](https://)', { from: 0, to: 17 }]) }) }) describe('with a url selected', () => { it('wraps the selection', () => { const actual = addLink( - ['https://google.com'], + 'https://google.com', { - from: { line: 0, character: 0 }, - to: { - line: 0, - character: 18 - } + from: 0, + to: 18 }, '' ) - expect(actual).toEqual(['[](https://google.com)']) + expect(actual).toEqual(['[](https://google.com)', { from: 0, to: 22 }]) }) it('wraps the selection with a prefix', () => { const actual = addLink( - ['https://google.com'], + 'https://google.com', { - from: { line: 0, character: 0 }, - to: { - line: 0, - character: 18 - } + from: 0, + to: 18 }, 'prefix' ) - expect(actual).toEqual(['prefix[](https://google.com)']) + expect(actual).toEqual(['prefix[](https://google.com)', { from: 0, to: 28 }]) }) 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://)']) + const actual = addLink('a\nhttps://google.com\nc', { from: 0, to: 22 }, '') + expect(actual).toEqual(['[a\nhttps://google.com\nc](https://)', { from: 0, to: 34 }]) }) }) }) diff --git a/src/redux/note-details/format-selection/formatters/add-link.ts b/src/redux/note-details/format-selection/formatters/add-link.ts index d1e228fa8..6c5a0bde7 100644 --- a/src/redux/note-details/format-selection/formatters/add-link.ts +++ b/src/redux/note-details/format-selection/formatters/add-link.ts @@ -15,29 +15,23 @@ 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 markdownContent The content 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[] => { +export const addLink = ( + markdownContent: string, + selection: CursorSelection, + prefix = '' +): [string, CursorSelection] => { 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 selectedText = markdownContent.slice(from, to) + const link = buildLink(selectedText, prefix) + const newContent = stringSplice(markdownContent, selection.from, link, selectedText.length) + return [newContent, { from, to: from + link.length }] } const buildLink = (selectedText: string, prefix: string): string => { diff --git a/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.test.ts b/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.test.ts new file mode 100644 index 000000000..7d00697c1 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { prependLinesOfSelection } from './prepend-lines-of-selection' + +describe('replace lines of selection', () => { + it('replaces only the from-cursor line if no to-cursor is present', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc', + { + from: 2 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + expect(actual).toStrictEqual(['a\ntext_0_b\nc', { from: 2, to: 10 }]) + }) + + it('replaces only one line if from-cursor and to-cursor are in the same line', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc', + { + from: 2, + to: 2 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + expect(actual).toStrictEqual(['a\ntext_0_b\nc', { from: 2, to: 10 }]) + }) + + it('replaces multiple lines', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc\nd\ne', + { + from: 2, + to: 6 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + expect(actual).toEqual(['a\ntext_0_b\ntext_1_c\ntext_2_d\ne', { from: 2, to: 28 }]) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.ts b/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.ts new file mode 100644 index 000000000..31a163ff4 --- /dev/null +++ b/src/redux/note-details/format-selection/formatters/prepend-lines-of-selection.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CursorSelection } from '../../../editor/types' +import { searchForEndOfLine, searchForStartOfLine } from './utils/change-cursors-to-whole-line-if-no-to-cursor' +import { stringSplice } from './utils/string-splice' + +/** + * 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 prependLinesOfSelection = ( + markdownContentLines: string, + selection: CursorSelection, + generatePrefix: (line: string, lineIndexInBlock: number) => string +): [string, CursorSelection] => { + let currentContent = markdownContentLines + let toIndex = selection.to ?? selection.from + let currentIndex = selection.from + let indexInBlock = 0 + let newStartOfSelection = selection.from + let newEndOfSelection = toIndex + while (currentIndex <= toIndex && currentIndex < currentContent.length) { + const startOfLine = searchForStartOfLine(currentContent, currentIndex) + if (startOfLine < newStartOfSelection) { + newStartOfSelection = startOfLine + } + const endOfLine = searchForEndOfLine(currentContent, currentIndex) + const line = currentContent.slice(startOfLine, endOfLine) + const replacement = generatePrefix(line, indexInBlock) + indexInBlock += 1 + currentContent = stringSplice(currentContent, startOfLine, replacement) + toIndex += replacement.length + const newEndOfLine = endOfLine + replacement.length + currentIndex = newEndOfLine + 1 + if (newEndOfLine > newEndOfSelection) { + newEndOfSelection = newEndOfLine + } + } + return [currentContent, { from: newStartOfSelection, to: newEndOfSelection }] +} 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 deleted file mode 100644 index 8f2b15463..000000000 --- a/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 deleted file mode 100644 index 261a7c296..000000000 --- a/src/redux/note-details/format-selection/formatters/replace-lines-of-selection.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 index ec47d1af2..f28028ff2 100644 --- a/src/redux/note-details/format-selection/formatters/replace-selection.test.ts +++ b/src/redux/note-details/format-selection/formatters/replace-selection.test.ts @@ -9,69 +9,48 @@ 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'], + 'text1', { - from: { - line: 0, - character: 2 - } + from: 2 }, 'text2' ) - expect(actual).toEqual(['tetext2xt1']) + expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }]) }) it('inserts a text if from-cursor and to-cursor are the same', () => { const actual = replaceSelection( - ['text1'], + 'text1', { - from: { - line: 0, - character: 2 - }, - to: { - line: 0, - character: 2 - } + from: 2, + to: 2 }, 'text2' ) - expect(actual).toEqual(['tetext2xt1']) + expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }]) }) it('replaces a single line text', () => { const actual = replaceSelection( - ['text1', 'text2', 'text3'], + 'text1\ntext2\ntext3', { - from: { - line: 1, - character: 1 - }, - to: { - line: 1, - character: 2 - } + from: 7, + to: 8 }, 'text4' ) - expect(actual).toEqual(['text1', 'ttext4xt2', 'text3']) + expect(actual).toEqual(['text1\nttext4xt2\ntext3', { from: 7, to: 12 }]) }) it('replaces a multi line text', () => { const actual = replaceSelection( - ['text1', 'text2', 'text3'], + 'text1\ntext2\ntext3', { - from: { - line: 0, - character: 2 - }, - to: { - line: 2, - character: 3 - } + from: 2, + to: 15 }, 'text4' ) - expect(actual).toEqual(['tetext4', 't3']) + expect(actual).toEqual(['tetext4t3', { from: 2, to: 7 }]) }) }) diff --git a/src/redux/note-details/format-selection/formatters/replace-selection.ts b/src/redux/note-details/format-selection/formatters/replace-selection.ts index e5bc12202..f683115d2 100644 --- a/src/redux/note-details/format-selection/formatters/replace-selection.ts +++ b/src/redux/note-details/format-selection/formatters/replace-selection.ts @@ -5,87 +5,25 @@ */ import { stringSplice } from './utils/string-splice' -import type { CursorPosition, CursorSelection } from '../../../editor/types' +import type { 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 markdownContent The content 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[], + markdownContent: string, selection: CursorSelection, insertText: string -): string[] => { +): [string, CursorSelection] => { 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 - } + const newContent = stringSplice(markdownContent, fromCursor, insertText, toCursor - fromCursor) + return [newContent, { from: fromCursor, to: insertText.length + fromCursor }] } 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 index 17c9e57fd..caf24a3e8 100644 --- 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 @@ -4,57 +4,92 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { changeCursorsToWholeLineIfNoToCursor } from './change-cursors-to-whole-line-if-no-to-cursor' +import { + changeCursorsToWholeLineIfNoToCursor, + searchForEndOfLine, + searchForStartOfLine +} 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 - } + from: 0, + to: 0 } - expect(changeCursorsToWholeLineIfNoToCursor([], givenSelection)).toEqual(givenSelection) + expect(changeCursorsToWholeLineIfNoToCursor('', givenSelection)).toEqual(givenSelection) }) - it(`returns the corrected selection if to cursor isn't present and referred line does exist`, () => { + it(`returns the corrected selection if cursor is in a line`, () => { const givenSelection = { - from: { - line: 0, - character: 123 - } + from: 9 } const expectedSelection: CursorSelection = { - from: { - line: 0, - character: 0 - }, - to: { - line: 0, - character: 27 - } + from: 6, + to: 14 } - expect(changeCursorsToWholeLineIfNoToCursor([`I'm a friendly test string!`], givenSelection)).toEqual( + expect(changeCursorsToWholeLineIfNoToCursor(`I'm a\nfriendly\ntest string!`, givenSelection)).toEqual( expectedSelection ) }) - it(`fails if to cursor isn't present and referred line doesn't exist`, () => { + it(`returns the corrected selection if cursor is out of bounds`, () => { const givenSelection = { - from: { - line: 1, - character: 123 - } + from: 123 } - expect(() => changeCursorsToWholeLineIfNoToCursor([''], givenSelection)).toThrow() + const expectedSelection: CursorSelection = { + from: 0, + to: 27 + } + + expect(changeCursorsToWholeLineIfNoToCursor(`I'm a friendly test string!`, givenSelection)).toEqual( + expectedSelection + ) + }) +}) + +describe('searchForStartOfLine', () => { + it('finds the start of the string', () => { + expect(searchForStartOfLine('a', 1)).toBe(0) + }) + it('finds the start of the string if the index is lower out of bounds', () => { + expect(searchForStartOfLine('a', -100)).toBe(0) + }) + it('finds the start of the string if the index is upper out of bounds', () => { + expect(searchForStartOfLine('a', 100)).toBe(0) + }) + it('finds the start of a line', () => { + expect(searchForStartOfLine('a\nb', 3)).toBe(2) + }) + it('finds the start of a line if the index is lower out of bounds', () => { + expect(searchForStartOfLine('a\nb', -100)).toBe(0) + }) + it('finds the start of a line if the index is upper out of bounds', () => { + expect(searchForStartOfLine('a\nb', 100)).toBe(2) + }) +}) + +describe('searchForEndOfLine', () => { + it('finds the end of the string', () => { + expect(searchForEndOfLine('a', 1)).toBe(1) + }) + it('finds the end of the string if the index is lower out of bounds', () => { + expect(searchForEndOfLine('a', -100)).toBe(1) + }) + it('finds the end of the string if the index is upper out of bounds', () => { + expect(searchForEndOfLine('a', 100)).toBe(1) + }) + it('finds the start of a line', () => { + expect(searchForEndOfLine('a\nb', 2)).toBe(3) + }) + it('finds the start of a line if the index is lower out of bounds', () => { + expect(searchForEndOfLine('a\nb', -100)).toBe(1) + }) + it('finds the start of a line if the index is upper out of bounds', () => { + expect(searchForEndOfLine('a\nb', 100)).toBe(3) }) }) 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 index 5cd40b967..fc6ff1621 100644 --- 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 @@ -5,31 +5,66 @@ */ 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 + * @param markdownContent The markdown content that is used to calculate the start and end position of the line + * @param selection The selection that is in the line whose start and end index should be calculated * @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[], + markdownContent: 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.`)) +): CursorSelection => { + if (selection.to !== undefined) { + return selection + } + + const newFrom = searchForStartOfLine(markdownContent, selection.from) + const newTo = searchForEndOfLine(markdownContent, selection.from) + + return { + from: newFrom, + to: newTo + } +} + +/** + * Finds the position of the first character after the nearest + * new line before the given start position. + * + * @param content The content that should be looked through + * @param startPosition The position from which the search should start + * @return The found new line character or the start of the content if no new line could be found + */ +export const searchForStartOfLine = (content: string, startPosition: number): number => { + const adjustedStartPosition = Math.min(Math.max(0, startPosition), content.length) + + for (let characterIndex = adjustedStartPosition; characterIndex > 0; characterIndex -= 1) { + if (content.slice(characterIndex - 1, characterIndex) === '\n') { + return characterIndex + } + } + return 0 +} + +/** + * Finds the position of the last character before the nearest + * new line after the given start position. + * + * @param content The content that should be looked through + * @param startPosition The position from which the search should start + * @return The found new line character or the end of the content if no new line could be found + */ +export const searchForEndOfLine = (content: string, startPosition: number): number => { + const adjustedStartPosition = Math.min(Math.max(0, startPosition), content.length) + + for (let characterIndex = adjustedStartPosition; characterIndex < content.length; characterIndex += 1) { + if (content.slice(characterIndex, characterIndex + 1) === '\n') { + return characterIndex + } + } + return content.length +} 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 index 9e178a58b..c2a0e7893 100644 --- a/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts +++ b/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts @@ -9,57 +9,42 @@ 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'], + 'a\nb\nc', { - from: { - line: 0, - character: 0 - } + from: 0 }, 'before', 'after' ) - expect(actual).toEqual(['a', 'b', 'c']) + expect(actual).toStrictEqual(['a\nb\nc', { from: 0 }]) }) it(`wraps the selected text in the same line`, () => { const actual = wrapSelection( - ['a', 'b', 'c'], + 'a\nb\nc', { - from: { - line: 0, - character: 0 - }, - to: { - line: 0, - character: 1 - } + from: 0, + to: 1 }, 'before', 'after' ) - expect(actual).toEqual(['beforeaafter', 'b', 'c']) + expect(actual).toStrictEqual(['beforeaafter\nb\nc', { from: 0, to: 12 }]) }) it(`wraps the selected text in different lines`, () => { const actual = wrapSelection( - ['a', 'b', 'c'], + 'a\nb\nc', { - from: { - line: 0, - character: 0 - }, - to: { - line: 2, - character: 1 - } + from: 0, + to: 5 }, 'before', 'after' ) - expect(actual).toEqual(['beforea', 'b', 'cafter']) + expect(actual).toStrictEqual(['beforea\nb\ncafter', { from: 0, to: 16 }]) }) }) diff --git a/src/redux/note-details/format-selection/formatters/wrap-selection.ts b/src/redux/note-details/format-selection/formatters/wrap-selection.ts index e33836035..53997d675 100644 --- a/src/redux/note-details/format-selection/formatters/wrap-selection.ts +++ b/src/redux/note-details/format-selection/formatters/wrap-selection.ts @@ -10,7 +10,7 @@ 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 markdownContent 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 @@ -18,30 +18,19 @@ import type { CursorSelection } from '../../../editor/types' * @return the modified copy of lines */ export const wrapSelection = ( - markdownContentLines: string[], + markdownContent: string, selection: CursorSelection, symbolStart: string, symbolEnd: string -): string[] => { +): [string, CursorSelection] => { if (selection.to === undefined) { - return markdownContentLines + return [markdownContent, selection] } 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 - } - }) + const afterToModify = stringSplice(markdownContent, to, symbolEnd) + const afterFromModify = stringSplice(afterToModify, from, symbolStart) + return [afterFromModify, { from, to: to + symbolEnd.length + symbolStart.length }] } diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts index f8d5907a2..c9dfdbc18 100644 --- a/src/redux/note-details/initial-state.ts +++ b/src/redux/note-details/initial-state.ts @@ -18,9 +18,12 @@ export const initialSlideOptions: SlideOptions = { } export const initialState: NoteDetails = { - markdownContent: '', - markdownContentLines: [], - selection: { from: { line: 0, character: 0 } }, + markdownContent: { + plain: '', + lines: [], + lineStartIndexes: [] + }, + selection: { from: 0 }, rawFrontmatter: '', frontmatterRendererInfo: { frontmatterInvalid: false, diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index 692fc5408..058b19ce8 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -19,7 +19,7 @@ import type { UpdateTaskListCheckboxAction } from './types' import { NoteDetailsActionType } from './types' -import type { CursorPosition, CursorSelection } from '../editor/types' +import type { CursorSelection } from '../editor/types' /** * Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part. @@ -83,35 +83,12 @@ export const replaceInMarkdownContent = (replaceable: string, replacement: strin } 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 + selection } 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, 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 index 4db3cbfb5..37a09f5ca 100644 --- 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 @@ -36,19 +36,15 @@ describe('build state from add table at cursor', () => { const actual = buildStateFromAddTableAtCursor( { ...initialState, - markdownContentLines: ['a', 'b', 'c'], - markdownContent: 'a\nb\nc', + markdownContent: { plain: 'a\nb\nc', lines: ['a', 'b', 'c'], lineStartIndexes: [0, 2, 4] }, selection: { - from: { - line: 1, - character: 0 - } + from: 2 } }, 3, 3 ) - expect(actual.markdownContent).toEqual( + expect(actual.markdownContent.plain).toEqual( 'a\n\n| # 1 | # 2 | # 3 |\n' + '| ---- | ---- | ---- |\n' + '| Text | Text | Text |\n' + 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 index 9c5174868..9efe8d2e6 100644 --- 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 @@ -5,7 +5,7 @@ */ import type { NoteDetails } from '../types/note-details' -import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content' +import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content' import { replaceSelection } from '../format-selection/formatters/replace-selection' import { createNumberRangeArray } from '../../../components/common/number-range/number-range' @@ -19,10 +19,16 @@ import { createNumberRangeArray } from '../../../components/common/number-range/ */ 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) + const [newContent, newSelection] = replaceSelection( + state.markdownContent.plain, + { from: state.selection.to ?? state.selection.from }, + table ) + const newState = buildStateFromUpdatedMarkdownContent(state, newContent) + return { + ...newState, + selection: newSelection + } } /** 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 index aba180d8f..3f07dd44a 100644 --- 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 @@ -26,7 +26,10 @@ describe('build state from replace in markdown content', () => { }) it('updates the markdown content with the replacement', () => { - const startState = { ...initialState, markdownContent: 'replaceable' } + const startState: NoteDetails = { + ...initialState, + markdownContent: { ...initialState.markdownContent, plain: '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 index ec617eafd..e54af0c18 100644 --- 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 @@ -35,5 +35,5 @@ export const buildStateFromReplaceInMarkdownContent = ( replaceable: string, replacement: string ): NoteDetails => { - return buildStateFromUpdatedMarkdownContent(state, replaceAll(state.markdownContent, replaceable, replacement)) + return buildStateFromUpdatedMarkdownContent(state, replaceAll(state.markdownContent.plain, 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 index d62459526..6f257bf92 100644 --- 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 @@ -13,47 +13,55 @@ import { initialState } from '../initial-state' import type { CursorSelection } from '../../editor/types' describe('build state from replace selection', () => { - const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn( + const buildStateFromUpdatedMarkdownContentMock = jest.spyOn( buildStateFromUpdatedMarkdownContentLinesModule, - 'buildStateFromUpdatedMarkdownContentLines' + 'buildStateFromUpdatedMarkdownContent' ) const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection') - const mockedNoteDetails = Mock.of() - const mockedReplacedLines = ['replaced'] + const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails + const mockedFormattedContent = 'formatted' + const mockedCursor = Mock.of() beforeAll(() => { - buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails) - replaceSelectionMock.mockImplementation(() => mockedReplacedLines) + buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails) + replaceSelectionMock.mockImplementation(() => [mockedFormattedContent, mockedCursor]) }) afterAll(() => { - buildStateFromUpdatedMarkdownContentLinesMock.mockReset() + buildStateFromUpdatedMarkdownContentMock.mockReset() replaceSelectionMock.mockReset() }) it('builds a new state with the provided cursor', () => { - const originalLines = ['original'] - const startState = { ...initialState, markdownContentLines: originalLines } + const originalLines = 'original' + const startState = { + ...initialState, + markdownContent: { plain: originalLines, lines: [originalLines], lineStartIndexes: [0] } + } const customCursor = Mock.of() const textReplacement = 'replacement' const result = buildStateFromReplaceSelection(startState, 'replacement', customCursor) - expect(result).toBe(mockedNoteDetails) - expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines) + expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor }) + expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent) expect(replaceSelectionMock).toHaveBeenCalledWith(originalLines, customCursor, textReplacement) }) it('builds a new state with the state cursor', () => { - const originalLines = ['original'] + const originalLines = 'original' const selection = Mock.of() - const startState = { ...initialState, markdownContentLines: originalLines, selection } + const startState: NoteDetails = { + ...initialState, + markdownContent: { plain: originalLines, lines: [originalLines], lineStartIndexes: [0] }, + selection + } const textReplacement = 'replacement' const result = buildStateFromReplaceSelection(startState, 'replacement') - expect(result).toBe(mockedNoteDetails) - expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedReplacedLines) + expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor }) + expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent) 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 index fd353b066..9d5526bcb 100644 --- a/src/redux/note-details/reducers/build-state-from-replace-selection.ts +++ b/src/redux/note-details/reducers/build-state-from-replace-selection.ts @@ -6,12 +6,18 @@ import type { NoteDetails } from '../types/note-details' import type { CursorSelection } from '../../editor/types' -import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content' +import { buildStateFromUpdatedMarkdownContent } 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) + const [newContent, newSelection] = replaceSelection( + state.markdownContent.plain, + cursorSelection ? cursorSelection : state.selection, + text ) + const newState = buildStateFromUpdatedMarkdownContent(state, newContent) + return { + ...newState, + selection: newSelection + } } 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 index 11216429b..44944f51b 100644 --- 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 @@ -14,34 +14,38 @@ import { FormatType } from '../types' import type { CursorSelection } from '../../editor/types' describe('build state from selection format', () => { - const buildStateFromUpdatedMarkdownContentLinesMock = jest.spyOn( + const buildStateFromUpdatedMarkdownContentMock = jest.spyOn( buildStateFromUpdatedMarkdownContentLinesModule, - 'buildStateFromUpdatedMarkdownContentLines' + 'buildStateFromUpdatedMarkdownContent' ) - const mockedNoteDetails = Mock.of() + const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails const applyFormatTypeToMarkdownLinesMock = jest.spyOn( applyFormatTypeToMarkdownLinesModule, 'applyFormatTypeToMarkdownLines' ) - const mockedFormattedLines = ['formatted'] + const mockedFormattedContent = 'formatted' + const mockedCursor = Mock.of() beforeAll(() => { - buildStateFromUpdatedMarkdownContentLinesMock.mockImplementation(() => mockedNoteDetails) - applyFormatTypeToMarkdownLinesMock.mockImplementation(() => mockedFormattedLines) + buildStateFromUpdatedMarkdownContentMock.mockImplementation(() => mockedNoteDetails) + applyFormatTypeToMarkdownLinesMock.mockImplementation(() => [mockedFormattedContent, mockedCursor]) }) afterAll(() => { - buildStateFromUpdatedMarkdownContentLinesMock.mockReset() + buildStateFromUpdatedMarkdownContentMock.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 originalContent = 'original' + const startState: NoteDetails = { + ...initialState, + markdownContent: { ...initialState.markdownContent, plain: originalContent }, + selection: mockedCursor + } const result = buildStateFromSelectionFormat(startState, FormatType.BOLD) - expect(result).toBe(mockedNoteDetails) - expect(buildStateFromUpdatedMarkdownContentLinesMock).toHaveBeenCalledWith(startState, mockedFormattedLines) - expect(applyFormatTypeToMarkdownLinesMock).toHaveBeenCalledWith(originalLines, customCursor, FormatType.BOLD) + expect(result).toStrictEqual({ content: 'mocked', selection: mockedCursor }) + expect(buildStateFromUpdatedMarkdownContentMock).toHaveBeenCalledWith(startState, mockedFormattedContent) + expect(applyFormatTypeToMarkdownLinesMock).toHaveBeenCalledWith(originalContent, mockedCursor, 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 index c7e457e5b..a4bf13124 100644 --- a/src/redux/note-details/reducers/build-state-from-selection-format.ts +++ b/src/redux/note-details/reducers/build-state-from-selection-format.ts @@ -6,12 +6,14 @@ import type { NoteDetails } from '../types/note-details' import type { FormatType } from '../types' -import { buildStateFromUpdatedMarkdownContentLines } from '../build-state-from-updated-markdown-content' +import { buildStateFromUpdatedMarkdownContent } 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) - ) + const [newContent, newSelection] = applyFormatTypeToMarkdownLines(state.markdownContent.plain, state.selection, type) + const newState = buildStateFromUpdatedMarkdownContent(state, newContent) + return { + ...newState, + selection: newSelection + } } 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 index 8cd42d75e..206760106 100644 --- 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 @@ -118,10 +118,12 @@ describe('build state from set note data from server', () => { slideOptions: initialSlideOptions }, noteTitle: '', - selection: { from: { line: 0, character: 0 } }, - - markdownContent: 'line1\nline2', - markdownContentLines: ['line1', 'line2'], + selection: { from: 0 }, + markdownContent: { + plain: 'line1\nline2', + lines: ['line1', 'line2'], + lineStartIndexes: [0, 6] + }, firstHeading: '', rawFrontmatter: '', id: 'id', 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 index 78d2c512d..1b5cc3561 100644 --- 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 @@ -9,6 +9,7 @@ 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' +import { calculateLineStartIndexes } from '../calculate-line-start-indexes' /** * Builds a {@link NoteDetails} redux state from a DTO received as an API response. @@ -17,7 +18,7 @@ import { DateTime } from 'luxon' */ export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => { const newState = convertNoteDtoToNoteDetails(dto) - return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent) + return buildStateFromUpdatedMarkdownContent(newState, newState.markdownContent.plain) } /** @@ -27,10 +28,14 @@ export const buildStateFromServerDto = (dto: NoteDto): NoteDetails => { * @return The NoteDetails object corresponding to the DTO. */ const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => { + const newLines = note.content.split('\n') return { ...initialState, - markdownContent: note.content, - markdownContentLines: note.content.split('\n'), + markdownContent: { + plain: note.content, + lines: newLines, + lineStartIndexes: calculateLineStartIndexes(newLines) + }, rawFrontmatter: '', id: note.metadata.id, createTime: DateTime.fromISO(note.metadata.createTime), 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 index 4504056cc..b62f2a073 100644 --- 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 @@ -28,14 +28,20 @@ describe('build state from task list update', () => { 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 startState: NoteDetails = { + ...initialState, + markdownContent: { ...initialState.markdownContent, lines: 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 startState: NoteDetails = { + ...initialState, + markdownContent: { ...initialState.markdownContent, lines: markdownContentLines } + } const result = buildStateFromTaskListUpdate(startState, 1, true) expect(result).toBe(mockedNoteDetails) expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [ @@ -46,7 +52,10 @@ describe('build state from task list update', () => { }) it(`can change the state of a task to unchecked`, () => { - const startState = { ...initialState, markdownContentLines: markdownContentLines } + const startState: NoteDetails = { + ...initialState, + markdownContent: { ...initialState.markdownContent, lines: markdownContentLines } + } const result = buildStateFromTaskListUpdate(startState, 2, false) expect(result).toBe(mockedNoteDetails) expect(buildStateFromUpdatedMarkdownContentLinesMock).toBeCalledWith(startState, [ 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 index a82e75f93..2039c01fb 100644 --- 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 @@ -21,7 +21,7 @@ export const buildStateFromTaskListUpdate = ( changedLineIndex: number, checkboxChecked: boolean ): NoteDetails => { - const lines = [...state.markdownContentLines] + const lines = [...state.markdownContent.lines] return Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex])) .map((results) => { const [, beforeCheckbox, afterCheckbox] = results 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 index b9657694a..9abda2ab5 100644 --- 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 @@ -8,8 +8,28 @@ import type { NoteDetails } from '../types/note-details' import type { CursorSelection } from '../../editor/types' export const buildStateFromUpdateCursorPosition = (state: NoteDetails, selection: CursorSelection): NoteDetails => { + const correctedSelection = isFromAfterTo(selection) + ? { + to: selection.from, + from: selection.to as number + } + : selection + return { ...state, - selection + selection: correctedSelection } } + +/** + * 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 + } + return selection.from > selection.to +} diff --git a/src/redux/note-details/types/note-details.ts b/src/redux/note-details/types/note-details.ts index fdb16c8d6..aa8962702 100644 --- a/src/redux/note-details/types/note-details.ts +++ b/src/redux/note-details/types/note-details.ts @@ -13,8 +13,11 @@ import type { CursorSelection } from '../../editor/types' * Redux state containing the currently loaded note with its content and metadata. */ export interface NoteDetails { - markdownContent: string - markdownContentLines: string[] + markdownContent: { + plain: string + lines: string[] + lineStartIndexes: number[] + } selection: CursorSelection rawFrontmatter: string frontmatter: NoteFrontmatter diff --git a/yarn.lock b/yarn.lock index b19956ed5..4095eb2e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,491 @@ __metadata: languageName: node linkType: hard +"@codemirror/autocomplete@npm:^0.19.0": + version: 0.19.12 + resolution: "@codemirror/autocomplete@npm:0.19.12" + dependencies: + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.4 + "@codemirror/text": ^0.19.2 + "@codemirror/tooltip": ^0.19.12 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.0 + checksum: f57dfe7b911e9dd928a589d72d487c84f74281e3a899120f14e857a48f4c9af109ae1df9f7e0e5959c77aedcfeafa74a428c832d1cd8cb0adde2d3e2daf6fec8 + languageName: node + linkType: hard + +"@codemirror/basic-setup@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/basic-setup@npm:0.19.1" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/closebrackets": ^0.19.0 + "@codemirror/commands": ^0.19.0 + "@codemirror/comment": ^0.19.0 + "@codemirror/fold": ^0.19.0 + "@codemirror/gutter": ^0.19.0 + "@codemirror/highlight": ^0.19.0 + "@codemirror/history": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/lint": ^0.19.0 + "@codemirror/matchbrackets": ^0.19.0 + "@codemirror/rectangular-selection": ^0.19.0 + "@codemirror/search": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.31 + checksum: b949099135aff0a987168fcfe6bda546d8f783b32377dcf16c2c91b9f24ef34820a183854247494dc63ae3daf93eea46870afa769f573e77503613b0a617638c + languageName: node + linkType: hard + +"@codemirror/closebrackets@npm:^0.19.0": + version: 0.19.0 + resolution: "@codemirror/closebrackets@npm:0.19.0" + dependencies: + "@codemirror/language": ^0.19.0 + "@codemirror/rangeset": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/text": ^0.19.0 + "@codemirror/view": ^0.19.0 + checksum: bef67b514f6fe9161b0d3d6d8b3c3c9e176aff0ae514949c768fc66bb7efa1535367708df681d3ba0e87781c79da1d6dbf081e379c702ea996627d09c5399f90 + languageName: node + linkType: hard + +"@codemirror/commands@npm:^0.19.0": + version: 0.19.8 + resolution: "@codemirror/commands@npm:0.19.8" + dependencies: + "@codemirror/language": ^0.19.0 + "@codemirror/matchbrackets": ^0.19.0 + "@codemirror/state": ^0.19.2 + "@codemirror/text": ^0.19.6 + "@codemirror/view": ^0.19.22 + "@lezer/common": ^0.15.0 + checksum: 296f7564e71c07680da0ade9b73db67d68dd845ede22d2ba9a2a83f8b3a5429953ccd22a11d96cf2543425d416a15d1f026fb5c74a2e80522cf1779cc7cd13ff + languageName: node + linkType: hard + +"@codemirror/comment@npm:^0.19.0": + version: 0.19.0 + resolution: "@codemirror/comment@npm:0.19.0" + dependencies: + "@codemirror/state": ^0.19.0 + "@codemirror/text": ^0.19.0 + "@codemirror/view": ^0.19.0 + checksum: 42849434baaf328268107c94ece2da481b7bde00bfd7998b5418d42503a1cebb926aaf5730a1c78f12f5c73eccf6e8a64d42f4c8744f2c5594e217c177824b00 + languageName: node + linkType: hard + +"@codemirror/fold@npm:^0.19.0": + version: 0.19.3 + resolution: "@codemirror/fold@npm:0.19.3" + dependencies: + "@codemirror/gutter": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/rangeset": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.22 + checksum: 975204fea85ff48dd59faa99022015fa363598f91d62d423a6aac23469040ab2f6e6efb2a98497cb99866efd6a22ffe8380b445a382556a8c8ba81cdd4707aa2 + languageName: node + linkType: hard + +"@codemirror/gutter@npm:^0.19.0, @codemirror/gutter@npm:^0.19.4": + version: 0.19.9 + resolution: "@codemirror/gutter@npm:0.19.9" + dependencies: + "@codemirror/rangeset": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.23 + checksum: 948e4bdeddfdd2f824412aa8a2cc43915444e948c310ee113faca4a988e98b6b02bea72f8849481adf82a5021b00d6a8ee2bdf0b105864de0e8aa417b41a9ed1 + languageName: node + linkType: hard + +"@codemirror/highlight@npm:^0.19.0, @codemirror/highlight@npm:^0.19.6, @codemirror/highlight@npm:^0.19.7": + version: 0.19.7 + resolution: "@codemirror/highlight@npm:0.19.7" + dependencies: + "@codemirror/language": ^0.19.0 + "@codemirror/rangeset": ^0.19.0 + "@codemirror/state": ^0.19.3 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.0 + style-mod: ^4.0.0 + checksum: 8be9d2d900501b483aa108fbd58e4cc628d01b6b5150e4f0242c1e779fd20b930f69c2da8d2eb5468712e01135808f900e44500c76fb0a838538c69c9aa31a96 + languageName: node + linkType: hard + +"@codemirror/history@npm:^0.19.0": + version: 0.19.2 + resolution: "@codemirror/history@npm:0.19.2" + dependencies: + "@codemirror/state": ^0.19.2 + "@codemirror/view": ^0.19.0 + checksum: c9d794289ea0b493b11a24df487a8de14afb7f8aef502bfaa9a8dda48e01c172c769ae76209743e4cb2d5937df0e64bea1295f07722b571a858d7417b21cc4f8 + languageName: node + linkType: hard + +"@codemirror/lang-cpp@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/lang-cpp@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@lezer/cpp": ^0.15.0 + checksum: 7337c2891f73d7ca1746dd8eaef52d71484b9709a0b88ae559ee63d9e768f56cd52885273d8d4c8994ade53afdcea3a71bc7186b2d68ddef74087d23dec880be + languageName: node + linkType: hard + +"@codemirror/lang-css@npm:^0.19.0": + version: 0.19.3 + resolution: "@codemirror/lang-css@npm:0.19.3" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/highlight": ^0.19.6 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@lezer/css": ^0.15.2 + checksum: f0436672332ef3b5ca9475ee0562deb39a1b8291170076b5eb746f7a6bc2f7770c19284e6820f2d0e376e1acf7b11046d6f252b436d33046b8ac4aca8c52c10c + languageName: node + linkType: hard + +"@codemirror/lang-html@npm:^0.19.0": + version: 0.19.4 + resolution: "@codemirror/lang-html@npm:0.19.4" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/highlight": ^0.19.6 + "@codemirror/lang-css": ^0.19.0 + "@codemirror/lang-javascript": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/html": ^0.15.0 + checksum: 8fdb90502b1c2d91a6216a9b0baf3dfa0e3430c782eb286bbe24d5339630d4cb066595f60d475c50a55ee08d8805cf2d3de3e02659fc2a3240144ffa48dcc848 + languageName: node + linkType: hard + +"@codemirror/lang-java@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/lang-java@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@lezer/java": ^0.15.0 + checksum: 424ebb9f919ed6ea6ed835309d2fa28c94d147fa5628f830f025d2407739bf211e0af2d26d1e1ce9a5d22b7a927f9bbcf70db3361d5b159e0e2d1b80313d6f89 + languageName: node + linkType: hard + +"@codemirror/lang-javascript@npm:^0.19.0": + version: 0.19.7 + resolution: "@codemirror/lang-javascript@npm:0.19.7" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/highlight": ^0.19.7 + "@codemirror/language": ^0.19.0 + "@codemirror/lint": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.0 + "@lezer/javascript": ^0.15.1 + checksum: 9020de11ebdb48322f02f0ba56854783329e6f4fb9bb037fe995714c87f2bfb2964157d642ef4cb9997f9ac86b101f178659530c73202026be2954132b9db0df + languageName: node + linkType: hard + +"@codemirror/lang-json@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/lang-json@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@lezer/json": ^0.15.0 + checksum: d67b82389e94db7b98c8b57e0b947f15d8c7e3cedf24bdb8d47431330ddf1740febc1a5d93b878968b25f251ce94f17279abae61ba43c18686d1dc0701223658 + languageName: node + linkType: hard + +"@codemirror/lang-markdown@npm:0.19.5": + version: 0.19.5 + resolution: "@codemirror/lang-markdown@npm:0.19.5" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/lang-html": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.3 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/markdown": ^0.15.0 + checksum: 931b1721245b25d0098da279094fe9c3e23e5a9adfa47801665e09bb7ac6bdcb58f5d226726f35932d4a2d280951b237c030647958f14a3bd20e72ef05c8e882 + languageName: node + linkType: hard + +"@codemirror/lang-markdown@npm:^0.19.0": + version: 0.19.6 + resolution: "@codemirror/lang-markdown@npm:0.19.6" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/lang-html": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.3 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/markdown": ^0.15.0 + checksum: 078511be96d9489e4b7de921452ca03946430ee1f0b070e9bfc58a4ce7610ba5475b69ad85ff1852ace807f2ef1ed0768307b680b6ed2c9bfc7cfab4e95ceefe + languageName: node + linkType: hard + +"@codemirror/lang-php@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/lang-php@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/lang-html": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/php": ^0.15.0 + checksum: 9b96e05c51e56b71404024bfb2515764e37dc6d94d49518036bf996a35f1d506d52d864f61b0aa0223009226719af3362bbc987227cf6eb7f1f091d7e37a939b + languageName: node + linkType: hard + +"@codemirror/lang-python@npm:^0.19.0": + version: 0.19.4 + resolution: "@codemirror/lang-python@npm:0.19.4" + dependencies: + "@codemirror/highlight": ^0.19.7 + "@codemirror/language": ^0.19.0 + "@lezer/python": ^0.15.0 + checksum: 99091a291fd40393527657e685a8ed44adb50a52812724746aa2307da42aed2cca24e9ec67a3844d97399df8690d7e5e2444d7a964f4214aa84883b990413168 + languageName: node + linkType: hard + +"@codemirror/lang-rust@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/lang-rust@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@lezer/rust": ^0.15.0 + checksum: 4438f2e7387d0e161e758b982e19dbb501c77e49a7cea9286829ee6f110196312b756cb32cd97a368b6588e7e39f35d2ad92b6e522e5ba32d83389f67e962a52 + languageName: node + linkType: hard + +"@codemirror/lang-sql@npm:^0.19.0": + version: 0.19.4 + resolution: "@codemirror/lang-sql@npm:0.19.4" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@lezer/lr": ^0.15.0 + checksum: 8db2379687f6b899e0033e2b2333a311084bef7fec5c1b1ae7d081c383052ef19131fff32624ffacc66c252262e95115d35a5dca3bb61a26cf9965e3aa211ae3 + languageName: node + linkType: hard + +"@codemirror/lang-wast@npm:^0.19.0": + version: 0.19.0 + resolution: "@codemirror/lang-wast@npm:0.19.0" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@lezer/lr": ^0.15.0 + checksum: f1a8a4c28f79ae33c36e6b250096d3bc7a375bc2d6bba25d7911b4fda24f6f55482649beab79998aa4b80c8ad4356649c96b6a2e8f3b0d2f600f5b48d6a79889 + languageName: node + linkType: hard + +"@codemirror/lang-xml@npm:^0.19.0": + version: 0.19.2 + resolution: "@codemirror/lang-xml@npm:0.19.2" + dependencies: + "@codemirror/autocomplete": ^0.19.0 + "@codemirror/highlight": ^0.19.6 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/xml": ^0.15.0 + checksum: 23cb756aa36682726f3decb44ba71b8b524d18cce7ee1f90207b18bb8aa69d8678a3126ad90760f4a93ed83fc91cbe33780a6d8622d737f6208b0d6470262517 + languageName: node + linkType: hard + +"@codemirror/language-data@npm:0.19.1": + version: 0.19.1 + resolution: "@codemirror/language-data@npm:0.19.1" + dependencies: + "@codemirror/lang-cpp": ^0.19.0 + "@codemirror/lang-css": ^0.19.0 + "@codemirror/lang-html": ^0.19.0 + "@codemirror/lang-java": ^0.19.0 + "@codemirror/lang-javascript": ^0.19.0 + "@codemirror/lang-json": ^0.19.0 + "@codemirror/lang-markdown": ^0.19.0 + "@codemirror/lang-php": ^0.19.0 + "@codemirror/lang-python": ^0.19.0 + "@codemirror/lang-rust": ^0.19.0 + "@codemirror/lang-sql": ^0.19.0 + "@codemirror/lang-wast": ^0.19.0 + "@codemirror/lang-xml": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/legacy-modes": ^0.19.0 + "@codemirror/stream-parser": ^0.19.0 + checksum: 0c0a06f3dc1e1a5c4f3d5c5de102be0ca6f587fae624e1d0f99ecf0f173591a2b86f8f2e6e12918f7bb48ad89acdbecaa33758549357b8ca7412e7afbaa2120d + languageName: node + linkType: hard + +"@codemirror/language@npm:^0.19.0": + version: 0.19.7 + resolution: "@codemirror/language@npm:0.19.7" + dependencies: + "@codemirror/state": ^0.19.0 + "@codemirror/text": ^0.19.0 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.5 + "@lezer/lr": ^0.15.0 + checksum: 5dacd9d138eb4fa361ea9927d13badd94b1f64b15c403bb2af4fa178f782df41cc85e18f57f89e11014c1b7333f1d4a4ebe8da5762bfc4e7a49219dedf0d3f7b + languageName: node + linkType: hard + +"@codemirror/legacy-modes@npm:^0.19.0": + version: 0.19.0 + resolution: "@codemirror/legacy-modes@npm:0.19.0" + dependencies: + "@codemirror/stream-parser": ^0.19.0 + checksum: 8ad6235f443ef7218651ab21b7b407712ddceaa158c74f7698f5fe507fb1edcc60382318fe2413294716e8b395f568bbdd985436f2d3b3699abbb9e17456614a + languageName: node + linkType: hard + +"@codemirror/lint@npm:^0.19.0": + version: 0.19.3 + resolution: "@codemirror/lint@npm:0.19.3" + dependencies: + "@codemirror/gutter": ^0.19.4 + "@codemirror/panel": ^0.19.0 + "@codemirror/rangeset": ^0.19.1 + "@codemirror/state": ^0.19.4 + "@codemirror/tooltip": ^0.19.5 + "@codemirror/view": ^0.19.0 + crelt: ^1.0.5 + checksum: 189142209efb844fec3e4ca0658a8bb8f553411a9353d1c6902463e7fa986684de6bd640c9c4eab641a56784eb00fb59d97a57e200d4af8e39c0e2f8a44920d2 + languageName: node + linkType: hard + +"@codemirror/matchbrackets@npm:^0.19.0": + version: 0.19.3 + resolution: "@codemirror/matchbrackets@npm:0.19.3" + dependencies: + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.0 + "@lezer/common": ^0.15.0 + checksum: 101bc22c8d9c62941318e56f8ea010732cf8a282b642deabd61ef11bab925be1b12ef10dbd848cc345278f42d3b473fa15a82ff300c96121ae6014a967654e04 + languageName: node + linkType: hard + +"@codemirror/panel@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/panel@npm:0.19.1" + dependencies: + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.0 + checksum: 57040e27f1943698e84468fe5270d830f49243f29863bf3f1246fed84659fba49ba890387d22c81ceaa0eb91cc856f32a7f8c0486df88a58e9538b19c5a87504 + languageName: node + linkType: hard + +"@codemirror/rangeset@npm:^0.19.0, @codemirror/rangeset@npm:^0.19.1, @codemirror/rangeset@npm:^0.19.5": + version: 0.19.6 + resolution: "@codemirror/rangeset@npm:0.19.6" + dependencies: + "@codemirror/state": ^0.19.0 + checksum: f7b9ff54ac514a5c67dea1689c7f227906b46643007da76e93045ea163bd863c823a35ded4d33ba8ab1d085cb562c67134b2bf9165ffc14a9f44fbf3d85afa43 + languageName: node + linkType: hard + +"@codemirror/rectangular-selection@npm:^0.19.0": + version: 0.19.1 + resolution: "@codemirror/rectangular-selection@npm:0.19.1" + dependencies: + "@codemirror/state": ^0.19.0 + "@codemirror/text": ^0.19.4 + "@codemirror/view": ^0.19.0 + checksum: 63b7d8d1efaa551bbb12bf8baf3218d6e00fcd62a1b9648b0c02420c1c008fc7451a3e68e881ab9ce3e1d7e76126a224a47fe67cf8461e2da6b165a93a1d7213 + languageName: node + linkType: hard + +"@codemirror/search@npm:^0.19.0": + version: 0.19.6 + resolution: "@codemirror/search@npm:0.19.6" + dependencies: + "@codemirror/panel": ^0.19.0 + "@codemirror/rangeset": ^0.19.0 + "@codemirror/state": ^0.19.3 + "@codemirror/text": ^0.19.0 + "@codemirror/view": ^0.19.34 + crelt: ^1.0.5 + checksum: 1313b389b1f7b0282ab988d338fcadbd9025765d2e85d7de90dec43477241b1f31b4ab118506c2ff1821086256f3c50a570baa4a1abdfd1909c79d0f34f3776b + languageName: node + linkType: hard + +"@codemirror/state@npm:^0.19.0, @codemirror/state@npm:^0.19.2, @codemirror/state@npm:^0.19.3, @codemirror/state@npm:^0.19.4, @codemirror/state@npm:^0.19.5": + version: 0.19.6 + resolution: "@codemirror/state@npm:0.19.6" + dependencies: + "@codemirror/text": ^0.19.0 + checksum: 65bee46d76c0b55b10ed4818cbb77267a6c75dff3c8cc04e83056a79a1d36e79d7b8bf750d4695238ac28fe792d6329939fd725839f8314eee34146941cae344 + languageName: node + linkType: hard + +"@codemirror/stream-parser@npm:^0.19.0": + version: 0.19.5 + resolution: "@codemirror/stream-parser@npm:0.19.5" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/language": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/text": ^0.19.0 + "@lezer/common": ^0.15.0 + "@lezer/lr": ^0.15.0 + checksum: 3a1edef98def985e31f9d1be3669bebc7bb14c41d0e69bd23e57868b67c1f473b7713cba1c45638e4453faf99e84888df2d4a3ebb183ea1db9795a367fde93bc + languageName: node + linkType: hard + +"@codemirror/text@npm:^0.19.0, @codemirror/text@npm:^0.19.2, @codemirror/text@npm:^0.19.4, @codemirror/text@npm:^0.19.6": + version: 0.19.6 + resolution: "@codemirror/text@npm:0.19.6" + checksum: 685e46c1f0114a216081b7a070460e1b0db9c51b0a2b361e9ed90e5ea2ed89d86a7a834b76f7c63b27fd192809d9414e7a15e0d186bd15cdb5d4f85639d434f0 + languageName: node + linkType: hard + +"@codemirror/theme-one-dark@npm:0.19.1, @codemirror/theme-one-dark@npm:^0.19.1": + version: 0.19.1 + resolution: "@codemirror/theme-one-dark@npm:0.19.1" + dependencies: + "@codemirror/highlight": ^0.19.0 + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.0 + checksum: 3eee2c4c3d88a0efcd23311d32ff1ff80424fdaa641d71f9b4cdd9c286c0fef8c0e693317d35c1add5a3dad4c4962defb25dc5174cb92a1cbd76f1f792db57fe + languageName: node + linkType: hard + +"@codemirror/tooltip@npm:^0.19.12, @codemirror/tooltip@npm:^0.19.5": + version: 0.19.13 + resolution: "@codemirror/tooltip@npm:0.19.13" + dependencies: + "@codemirror/state": ^0.19.0 + "@codemirror/view": ^0.19.0 + checksum: 00e0554510aa6545efb201ce9a7925d13122c78455429ec26c220ff6c9de480728e16ad3bb7e451ceee1c1e1968e2c7168c38f7ffb64b5e096b4a504fe135494 + languageName: node + linkType: hard + +"@codemirror/view@npm:^0.19.0, @codemirror/view@npm:^0.19.15, @codemirror/view@npm:^0.19.22, @codemirror/view@npm:^0.19.23, @codemirror/view@npm:^0.19.31, @codemirror/view@npm:^0.19.34": + version: 0.19.42 + resolution: "@codemirror/view@npm:0.19.42" + dependencies: + "@codemirror/rangeset": ^0.19.5 + "@codemirror/state": ^0.19.3 + "@codemirror/text": ^0.19.0 + style-mod: ^4.0.0 + w3c-keyname: ^2.2.4 + checksum: 7a09b5c057b3754110e69b44c82773efce2d1c64a58a4b97a11230a8d86109d29fb2a9d31a8351468bf1123ae122e23c6d50b7553579ee012ce32b293e5b15c4 + languageName: node + linkType: hard + "@cspotcode/source-map-consumer@npm:0.8.0": version: 0.8.0 resolution: "@cspotcode/source-map-consumer@npm:0.8.0" @@ -1686,6 +2171,9 @@ __metadata: version: 0.0.0-use.local resolution: "@hedgedoc/react-client@workspace:." dependencies: + "@codemirror/lang-markdown": 0.19.5 + "@codemirror/language-data": 0.19.1 + "@codemirror/theme-one-dark": 0.19.1 "@fontsource/source-sans-pro": 4.5.3 "@hedgedoc/html-to-react": 1.1.1 "@hedgedoc/markdown-it-image-size": 1.0.3 @@ -1698,7 +2186,6 @@ __metadata: "@testing-library/jest-dom": 5.16.2 "@testing-library/react": 12.1.2 "@testing-library/user-event": 13.5.0 - "@types/codemirror": 5.60.5 "@types/d3-graphviz": 2.6.7 "@types/diff": 5.0.2 "@types/dompurify": 2.3.3 @@ -1716,13 +2203,14 @@ __metadata: "@types/uuid": 8.3.4 "@typescript-eslint/eslint-plugin": 5.11.0 "@typescript-eslint/parser": 5.11.0 + "@uiw/react-codemirror": 4.3.3 abcjs: 6.0.0-beta.38 bootstrap: 4.6.1 - codemirror: 5.65.1 copy-webpack-plugin: 10.2.4 cross-env: 7.0.3 cypress: 9.4.1 cypress-commands: 2.0.1 + cypress-fill-command: 1.0.2 d3-graphviz: 3.2.0 diff: 5.0.0 dompurify: 2.3.5 @@ -1775,7 +2263,6 @@ __metadata: react: 17.0.2 react-bootstrap: 1.6.4 react-bootstrap-typeahead: 5.2.1 - react-codemirror2: 7.2.1 react-diff-viewer: 3.1.1 react-dom: 17.0.2 react-i18next: 11.15.4 @@ -2104,6 +2591,121 @@ __metadata: languageName: node linkType: hard +"@lezer/common@npm:^0.15.0, @lezer/common@npm:^0.15.5": + version: 0.15.11 + resolution: "@lezer/common@npm:0.15.11" + checksum: 5cabce5493b9392bb54816d6b921dae20d154b175423479b408e990fdf572fd2ed77a6b2df0ed6ef26d779eeb66ec737d10aa2312e1ffecbcec22e14b19f7be3 + languageName: node + linkType: hard + +"@lezer/cpp@npm:^0.15.0": + version: 0.15.2 + resolution: "@lezer/cpp@npm:0.15.2" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 03bb70190f762d474935fc97466aad2c6f8ab37f59ded369623f25718fa11e3ece035a778566ee9c065f2d38b9b0e63187ae18fec9958d37610f807d9db1109a + languageName: node + linkType: hard + +"@lezer/css@npm:^0.15.2": + version: 0.15.2 + resolution: "@lezer/css@npm:0.15.2" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: c3c4256ddaa6573508459dbf082ce70e385c851018ed2b0da637d4abb02ddfaaaa407393bbd2bf4804c2480d2c43f7f6273ce89704dc5979783f46b7bf6b5848 + languageName: node + linkType: hard + +"@lezer/html@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/html@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: b391f07159e30f37e5fb039a279a6002c119cab0833c2331590e237a23e547bc737ec858b708cc8b17a0f30fafe2db56e47abf6fa9f021a9b38937af30d366fd + languageName: node + linkType: hard + +"@lezer/java@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/java@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 4c0823280770186a92fc00fb0cf63925a07c1630c53bdae6976292a6be9e88b7f21352b27c97e112d376e89117b0ff3ee153c86f0b0e8fb928ebe8068596c09b + languageName: node + linkType: hard + +"@lezer/javascript@npm:^0.15.1": + version: 0.15.3 + resolution: "@lezer/javascript@npm:0.15.3" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 395e0cb52e6eb6ff5869eb216872941d728220f8624851cc8da8cab23c331de943d13c64c15c48145ace4833076bc0088c3f6e890c159a9a7284ca48ebe7565b + languageName: node + linkType: hard + +"@lezer/json@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/json@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 993e68394258090db03830b1c1671cbc811e219ddb00e31a21bd08a4c5f1f579237d7488b47c5cc17c25210ae98be234277d809f918136e5e4e74cd9e7b2f866 + languageName: node + linkType: hard + +"@lezer/lr@npm:^0.15.0": + version: 0.15.8 + resolution: "@lezer/lr@npm:0.15.8" + dependencies: + "@lezer/common": ^0.15.0 + checksum: e741225d6ac9cf08f8016bad49622fbd4a4e0d20c2e8c2b38a0abf0ddca69c58275b0ebdb9d5dde2905cf84f6977bc302f7ed5e5ba42c23afa27e9e65b900f36 + languageName: node + linkType: hard + +"@lezer/markdown@npm:^0.15.0": + version: 0.15.4 + resolution: "@lezer/markdown@npm:0.15.4" + dependencies: + "@lezer/common": ^0.15.0 + checksum: 91d4b56151fe868e8b1d5dba20c3b9af7cc39973c05d0b85561431fa61bfbca7488e1b71559146494b43764c8edf32ebae465c8f48152bea307ba9f391f422a4 + languageName: node + linkType: hard + +"@lezer/php@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/php@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 2295b30d9137962c63b1aca35f5ebfeae6de3035afdf574fc7156b043dc7d9a9bfbb8a1056cc29bf034b943d4cdc578bdda9edd223109ba2fdc7cda5e8fdf9b5 + languageName: node + linkType: hard + +"@lezer/python@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/python@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: fe6f42b5350d691012771dc47cca4c4cba0d9236f27a11a049887f51696e050346d5d8cfd32abcd32ff84f9c8c6d20376af61139edd95fbb13696bc22497a17a + languageName: node + linkType: hard + +"@lezer/rust@npm:^0.15.0": + version: 0.15.0 + resolution: "@lezer/rust@npm:0.15.0" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 3ebcf6df77453fdd0f88f2e0ce3c6ce5dbe4250030e3e41810942cb371a02bcad07596224234c0102b2133b671be1ce827c065b9894d8a3dcaadd914aa332a0f + languageName: node + linkType: hard + +"@lezer/xml@npm:^0.15.0": + version: 0.15.1 + resolution: "@lezer/xml@npm:0.15.1" + dependencies: + "@lezer/lr": ^0.15.0 + checksum: 133253003342fed05b29d655adb527889ba1787c54849d4491ba8c4a94631279eb43fbb23abdf4aa61ed365ca0ba718cab02fd29c2f587a9439f4d1972135735 + languageName: node + linkType: hard + "@mapbox/node-pre-gyp@npm:^1.0.5": version: 1.0.8 resolution: "@mapbox/node-pre-gyp@npm:1.0.8" @@ -3572,15 +4174,6 @@ __metadata: languageName: node linkType: hard -"@types/codemirror@npm:5.60.5": - version: 5.60.5 - resolution: "@types/codemirror@npm:5.60.5" - dependencies: - "@types/tern": "*" - checksum: 423b6378b6415b626d9f54d37d69477f96940b30a695d4ec36402feb59a96f7ab42d1c17b8472fbb222a0966f6061deee234e37bcd3e34c75836e989dc95929d - languageName: node - linkType: hard - "@types/d3-array@npm:^2": version: 2.12.3 resolution: "@types/d3-array@npm:2.12.3" @@ -4354,15 +4947,6 @@ __metadata: languageName: node linkType: hard -"@types/tern@npm:*": - version: 0.23.4 - resolution: "@types/tern@npm:0.23.4" - dependencies: - "@types/estree": "*" - checksum: d8fd304f147ed08f1d075f09cb8d440b7f785f69c9ec5c30eadf98132fe3f58f3b1bbbd11283858bb261afac5a2039c1c255f290b0f6e4a47cd6a746f30a6aa8 - languageName: node - linkType: hard - "@types/testing-library__jest-dom@npm:^5.9.1": version: 5.14.2 resolution: "@types/testing-library__jest-dom@npm:5.14.2" @@ -4657,6 +5241,23 @@ __metadata: languageName: node linkType: hard +"@uiw/react-codemirror@npm:4.3.3": + version: 4.3.3 + resolution: "@uiw/react-codemirror@npm:4.3.3" + dependencies: + "@babel/runtime": ^7.16.3 + "@codemirror/basic-setup": ^0.19.0 + "@codemirror/state": ^0.19.5 + "@codemirror/theme-one-dark": ^0.19.1 + "@codemirror/view": ^0.19.15 + peerDependencies: + "@babel/runtime": ">=7.11.0" + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: f66b22dccbf64cebd206ecb5be3d83f8d9ba900f672cd95c2fe07ba4d11df7fb02e46afbcfe0957232556c7e3749ee464cda1efd8a1bc6b182a61ad98db4c825 + languageName: node + linkType: hard + "@vercel/nft@npm:^0.17.0": version: 0.17.4 resolution: "@vercel/nft@npm:0.17.4" @@ -6364,13 +6965,6 @@ __metadata: languageName: node linkType: hard -"codemirror@npm:5.65.1": - version: 5.65.1 - resolution: "codemirror@npm:5.65.1" - checksum: 19f615f2c61fbae36bcc0b426aa9f485420b2d70c49092d69e86f49ab04b579512b642706f64a374c9bd56498edec641e16524ed3d2dbbd77ce12077e3bf2d14 - languageName: node - linkType: hard - "collect-v8-coverage@npm:^1.0.0": version: 1.0.1 resolution: "collect-v8-coverage@npm:1.0.1" @@ -6878,6 +7472,13 @@ __metadata: languageName: node linkType: hard +"crelt@npm:^1.0.5": + version: 1.0.5 + resolution: "crelt@npm:1.0.5" + checksum: 04a618c5878e12a14a9a328a49ff6e37bed76abb88b72e661c56b5f161d8a9aca133650da6bcbc5224ad1f7f43a69325627f209e92a21002986d52a8f844b367 + languageName: node + linkType: hard + "cron-parser@npm:^4.1.0, cron-parser@npm:^4.2.1": version: 4.2.1 resolution: "cron-parser@npm:4.2.1" @@ -7037,6 +7638,13 @@ __metadata: languageName: node linkType: hard +"cypress-fill-command@npm:1.0.2": + version: 1.0.2 + resolution: "cypress-fill-command@npm:1.0.2" + checksum: 172ffd8fb8e60af7f6dc2cbe3a1ea3535d1ceafb76863e82cf51ea0d229c2e384fd3ae1a635661a2f19c610e12b1ccd57eefa7522402c466934dc1cd063fd3fa + languageName: node + linkType: hard + "cypress@npm:9.4.1": version: 9.4.1 resolution: "cypress@npm:9.4.1" @@ -16447,16 +17055,6 @@ __metadata: languageName: node linkType: hard -"react-codemirror2@npm:7.2.1": - version: 7.2.1 - resolution: "react-codemirror2@npm:7.2.1" - peerDependencies: - codemirror: 5.x - react: ">=15.5 <=16.x" - checksum: 5c524a073167fbd89429a32f6ccbda83b78890231dd743219fb95335c2fd4c90fecc906fe9410a26b42c3baea62e00800d0e4e85c67965ab05234b6f7d4dd82d - languageName: node - linkType: hard - "react-diff-viewer@npm:3.1.1": version: 3.1.1 resolution: "react-diff-viewer@npm:3.1.1" @@ -18344,6 +18942,13 @@ __metadata: languageName: node linkType: hard +"style-mod@npm:^4.0.0": + version: 4.0.0 + resolution: "style-mod@npm:4.0.0" + checksum: c19f73d660a94244f0715180a6141bf75d05e5b156cc956ba11970b83cd303c3f7edafe5fb61a3192da6186cc008bdcdd803a979070f9b64e13046463644043c + languageName: node + linkType: hard + "styled-jsx@npm:5.0.0": version: 5.0.0 resolution: "styled-jsx@npm:5.0.0" @@ -20081,6 +20686,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.4": + version: 2.2.4 + resolution: "w3c-keyname@npm:2.2.4" + checksum: 890180452bdd7d25f05deb97b3c0839911264432a7c5dd8dc4c9d9b6384237a66d66b7c2a145440b607826d5aa61cd90098cfffcf50b50c0e4c2259b0d208038 + languageName: node + linkType: hard + "w3c-xmlserializer@npm:^2.0.0": version: 2.0.0 resolution: "w3c-xmlserializer@npm:2.0.0"