Upgrade to CodeMirror 6 (#1787)

Upgrade to CodeMirror 6

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-02-13 12:14:01 +01:00 committed by GitHub
parent 1a09bfa5f1
commit 6a6f6105b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 1906 additions and 2615 deletions

View file

@ -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('<d')
cy.get('.CodeMirror-hints').should('be.visible')
cy.get('@codeinput').type('{enter}')
cy.get('.CodeMirror-hints').should('not.exist')
cy.get('.CodeMirror-activeline').contains('</details>') // after selecting the hint, the last line of the inserted suggestion is active
cy.getMarkdownBody().find('details').should('exist')
})
it('via doubleclick', () => {
cy.setCodemirrorContent('<d')
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('</details>')
cy.getMarkdownBody().find('details').should('exist')
})
})
})

View file

@ -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}`)
})
})

View file

@ -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}`)
})
})

View file

@ -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', ':)')
})
})

View file

@ -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]')
})
})
})

View file

@ -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<Element>
setCodemirrorContent(value: string): Chainable<Element>
}
}
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)
}
})

View file

@ -20,7 +20,7 @@
// ***********************************************************
import 'cypress-commands'
import './checkLinks'
import './check-links'
import './config'
import './fill'
import './get-by-id'

View file

@ -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
})
})

View file

@ -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}}",

View file

@ -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",

View file

@ -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<highlightJsImport | null> => {
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<string[]> => {
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<Hints | null> => {
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
}

View file

@ -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 = /^(<d(?:e|et|eta|etai|etail|etails)?)$/
const collapsibleBlockHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = ['<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>']
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
}

View file

@ -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<Hints | null> => {
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
}

View file

@ -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<Emoji[]> => {
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<Hints | null> => {
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
}

View file

@ -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<Hints | null> => {
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
}

View file

@ -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<Hints | null> => {
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
}

View file

@ -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<Hints | null>
}
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
]

View file

@ -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<Hints | null> => {
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
}

View file

@ -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<Hints | null> => {
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
}

View file

@ -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<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const markdownContent = useNoteMarkdownContent()
const editor = useRef<Editor>()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const codeMirrorRef = useRef<ReactCodeMirrorRef | null>(null)
const onPaste = useOnEditorPasteCallback()
const onEditorScroll = useOnEditorScroll(onScroll)
useApplyScrollState(editor, scrollState)
useApplyScrollState(codeMirrorRef, scrollState)
const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => {
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<boolean>(false)
const onFocus = useCallback(() => {
editorFocus.current = true
if (editor.current) {
onCursorActivity(editor.current)
}
}, [editor, onCursorActivity])
const onBlur = useCallback(() => {
editorFocus.current = false
}, [])
const cursorActivity = useCallback(
(editor: Editor) => {
if (editorFocus.current) {
onCursorActivity(editor)
}
},
[onCursorActivity]
const codeMirrorClassName = useMemo(
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
[ligaturesEnabled]
)
const { t } = useTranslation()
return (
<div className={`d-flex flex-column h-100 position-relative`} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarning />
<ToolBar />
<ExtendedCodemirror
className={`overflow-hidden w-100 flex-fill`}
<ReactCodeMirror
placeholder={t('editor.placeholder')}
extensions={extensions}
width={'100%'}
height={'100%'}
maxHeight={'100%'}
maxWidth={'100%'}
basicSetup={true}
className={codeMirrorClassName}
theme={oneDark}
value={markdownContent}
options={codeMirrorOptions}
onPaste={onPaste}
onDrop={onDrop}
onCursorActivity={cursorActivity}
editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange}
onScroll={onEditorScroll}
onFocus={onFocus}
onBlur={onBlur}
ligatures={ligaturesEnabled}
onChange={onBeforeChange}
ref={codeMirrorRef}
/>
<StatusBar />
</div>

View file

@ -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'

View file

@ -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 {
& {
.extendedCodemirror {
:global(.cm-editor .cm-line) {
@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;
}
}
&.no-ligatures {
:global {
.CodeMirror {
.CodeMirror-line, .CodeMirror-line-like {
font-feature-settings: inherit;
}
.CodeMirror-line, .CodeMirror-line-like {
:global(.cm-editor .cm-line) {
font-variant-ligatures: none;
}
}
}
}
}

View file

@ -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<IControlledCodeMirror, 'onChange'> {
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<ExtendedCodemirrorProps> = ({ className, ligatures, ...props }) => {
return (
<Controlled
className={`${className ?? ''} ${ligatures ? '' : styles['no-ligatures']} ${styles['extended-codemirror']}`}
onChange={onChange}
{...props}
/>
)
}

View file

@ -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]
)
}

View file

@ -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
}
}
}),
[]
)
}

View file

@ -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]
)
}

View file

@ -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<Editor | undefined>,
editorRef: MutableRefObject<ReactCodeMirrorRef | null>,
scrollState?: ScrollState
): void => {
const lastScrollPosition = useRef<number>()
const lastScrollPosition = useRef<ScrollState>()
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])
}

View file

@ -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<boolean> 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<boolean>] => {
const focusReference = useRef<boolean>(false)
const codeMirrorExtension = useMemo(
() =>
EditorView.domEventHandlers({
blur: () => {
focusReference.current = false
},
focus: () => {
focusReference.current = true
}
}),
[]
)
return [codeMirrorExtension, focusReference]
}

View file

@ -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<EditorConfiguration>(
() => ({
...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]
)
}

View file

@ -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])
}

View file

@ -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) {
export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): 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 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
})
}, [])
const firstSelection = viewUpdate.state.selection.main
const newCursorPos = {
from: firstSelection.from,
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
}
updateCursorPositions(newCursorPos)
}),
[editorFocused]
)
}

View file

@ -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])
}

View file

@ -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<ScrollState>()
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]
}, [])
}

View file

@ -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])
}
}, [])
}

View file

@ -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
}
}, [])
}

View file

@ -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<ScrollState>()
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]
)
}

View file

@ -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<ExtractResult> => {
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]

View file

@ -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
}
}

View file

@ -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<CursorPositionInfoProps> = ({ 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 : (
<span>
<Trans i18nKey={'editor.statusBar.cursor'} values={translationOptions} />
</span>

View file

@ -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<LinesInDocumentInfoProps> = ({ 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 (
<span>

View file

@ -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<LengthInfoProps> = ({ 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<LengthInfoProps> = ({ remainingCh
}
}, [remainingCharacters, t])
const translationOptions = useMemo(() => ({ length: charactersInDocument }), [charactersInDocument])
const translationOptions = useMemo(() => ({ length: contentLength }), [contentLength])
return (
<span {...cypressId('remainingCharacters')} title={lengthTooltip} className={remainingCharactersClass}>

View file

@ -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 : (
<Fragment>
<SeparatorDash />
<span>
<Trans i18nKey={`editor.statusBar.selection.characters`} values={countTranslationOptions} />
</span>
</Fragment>
)
}

View file

@ -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 : (
<Fragment>
<SeparatorDash />
<span>
<Trans i18nKey={`editor.statusBar.selection.lines`} values={countTranslationOptions} />
</span>
</Fragment>
)
}

View file

@ -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 (
<div className={`d-flex flex-row ${styles['status-bar']} px-2`}>
<div>
<CursorPositionInfo cursorPosition={statusBarInfo.position} />
<ShowIf condition={statusBarInfo.selectedLines === 1 && statusBarInfo.selectedColumns > 0}>
<SeparatorDash />
<SelectionInfo count={statusBarInfo.selectedColumns} translationKey={'column'} />
</ShowIf>
<ShowIf condition={statusBarInfo.selectedLines > 1}>
<SeparatorDash />
<SelectionInfo count={statusBarInfo.selectedLines} translationKey={'line'} />
</ShowIf>
<CursorPositionInfo />
<SelectedCharacters />
<SelectedLines />
</div>
<div className='ml-auto'>
<NumberOfLinesInDocumentInfo numberOfLinesInDocument={statusBarInfo.linesInDocument} />
<NumberOfLinesInDocumentInfo />
<SeparatorDash />
<RemainingCharactersInfo
remainingCharacters={statusBarInfo.remainingCharacters}
charactersInDocument={statusBarInfo.charactersInDocument}
/>
<RemainingCharactersInfo />
</div>
</div>
)

View file

@ -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<EditorPreferenceBooleanProps> = ({ property }) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const { t } = useTranslation()
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: boolean = event.target.value === 'true'
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
const i18nPrefix = `editor.modal.preferences.${property}`
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.SELECT}
value={preference}>
<option value={'true'}>{t(`${i18nPrefix}.on`)}</option>
<option value={'false'}>{t(`${i18nPrefix}.off`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -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<HTMLSelectElement>
value?: string | number | string[]
}
export const EditorPreferenceInput: React.FC<EditorPreferenceInputProps> = ({
property,
type,
onChange,
value,
children
}) => {
useTranslation()
return (
<Form.Group controlId={`editor-pref-${property}`}>
<Form.Label>
<Trans
i18nKey={`editor.modal.preferences.${property}${type === EditorPreferenceInputType.NUMBER ? '' : '.label'}`}
/>
</Form.Label>
<Form.Control
as={type === EditorPreferenceInputType.NUMBER ? 'input' : 'select'}
size='sm'
value={value}
onChange={onChange}
type={type === EditorPreferenceInputType.NUMBER ? 'number' : ''}>
{children}
</Form.Control>
</Form.Group>
)
}

View file

@ -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<HTMLSelectElement>) => {
const ligaturesActivated: boolean = event.target.value === 'true'
setEditorLigatures(ligaturesActivated)
}, [])
const { t } = useTranslation()
return (
<EditorPreferenceInput
onChange={saveLigatures}
value={ligaturesEnabled}
property={'ligatures'}
type={EditorPreferenceInputType.BOOLEAN}>
<option value='true'>{t(`common.yes`)}</option>
<option value='false'>{t(`common.no`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -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<EditorPreferenceNumberProps> = ({ property }) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: number = Number.parseInt(event.target.value)
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.NUMBER}
value={preference}
/>
)
}

View file

@ -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'
}

View file

@ -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<EditorPreferenceSelectPropertyProps> = ({
property,
selections
}) => {
const preference = useApplicationState((state) => state.editorConfig.preferences[property]?.toString() ?? '')
const { t } = useTranslation()
const selectItem = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const selectedItem: string = event.target.value
mergeEditorPreferences({
[property]: selectedItem
} as EditorConfiguration)
},
[property]
)
const i18nPrefix = `editor.modal.preferences.${property}`
return (
<EditorPreferenceInput
onChange={selectItem}
property={property}
type={EditorPreferenceInputType.SELECT}
value={preference}>
{selections.map((selection) => (
<option key={selection} value={selection}>
{t(`${i18nPrefix}.${selection}`)}
</option>
))}
</EditorPreferenceInput>
)
}

View file

@ -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<HTMLSelectElement>) => {
const smartPasteActivated: boolean = event.target.value === 'true'
setEditorSmartPaste(smartPasteActivated)
}, [])
const { t } = useTranslation()
return (
<EditorPreferenceInput
onChange={saveSmartPaste}
value={smartPasteEnabled}
property={'smartPaste'}
type={EditorPreferenceInputType.BOOLEAN}>
<option value='true'>{t(`common.yes`)}</option>
<option value='false'>{t(`common.no`)}</option>
</EditorPreferenceInput>
)
}

View file

@ -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 (
<Fragment>
<Button variant='light' onClick={() => setShowModal(true)} title={t('editor.editorToolbar.preferences')}>
<ForkAwesomeIcon icon='wrench' />
</Button>
<CommonModal
show={showModal}
onHide={() => setShowModal(false)}
title={'editor.modal.preferences.title'}
showCloseButton={true}
titleIcon={'wrench'}>
<Form>
<ListGroup>
<ListGroup.Item>
<EditorPreferenceSelectProperty
property={EditorPreferenceProperty.THEME}
selections={['one-dark', 'neat']}
/>
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceSelectProperty
property={EditorPreferenceProperty.KEYMAP}
selections={['sublime', 'emacs', 'vim']}
/>
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceBooleanProperty property={EditorPreferenceProperty.INDENT_WITH_TABS} />
</ListGroup.Item>
<ShowIf condition={!indentWithTabs}>
<ListGroup.Item>
<EditorPreferenceNumberProperty property={EditorPreferenceProperty.INDENT_UNIT} />
</ListGroup.Item>
</ShowIf>
<ListGroup.Item>
<EditorPreferenceLigaturesSelect />
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceSmartPasteSelect />
</ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceInput
onChange={() => alert('This feature is not yet implemented.')}
property={EditorPreferenceProperty.SPELL_CHECK}
type={EditorPreferenceInputType.SELECT}>
<option value='off'>Off</option>
<option value='en'>English</option>
</EditorPreferenceInput>
</ListGroup.Item>
</ListGroup>
</Form>
</CommonModal>
</Fragment>
)
}

View file

@ -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 = () => {
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
<EmojiPickerButton />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<EditorPreferences />
</ButtonGroup>
</ButtonToolbar>
)
}

View file

@ -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<ApplicationState>({
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)
})
})

View file

@ -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
}

View file

@ -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

View file

@ -86,7 +86,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
)
useEditorReceiveHandler(
CommunicationMessageType.SET_SCROLL_SOURCE_TO_RENDERER,
CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE,
useCallback(() => onMakeScrollSource?.(), [onMakeScrollSource])
)

View file

@ -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 sendScrolling = useRef<boolean>(false)
useRendererReceiveHandler(
CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE,
useCallback(() => {
sendScrolling.current = false
}, [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_BASE_CONFIGURATION,
useCallback((values) => setBaseConfiguration(values.baseConfiguration), [])
)
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.SET_BASE_CONFIGURATION, (values) =>
setBaseConfiguration(values.baseConfiguration)
)
useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) =>
setMarkdownContentLines(values.content)
)
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.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

View file

@ -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 = <R extends RendererToEditorMessageType>(
messageType: R,
handler: Handler<CommunicationMessages, R>
handler: MaybeHandler<CommunicationMessages, R>
): void => {
const editorToRendererCommunicator = useEditorToRendererCommunicator()
useEffect(() => {

View file

@ -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<MESSAGE_TYPE extends EditorToRendererMessageType> = 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 = <MESSAGE_TYPE extends EditorToRendererMessageType>(
messageType: MESSAGE_TYPE,
handler: Handler<CommunicationMessages, MESSAGE_TYPE>
handler: CommunicationMessageHandler<MESSAGE_TYPE>
): void => {
const editorToRendererCommunicator = useRendererToEditorCommunicator()
useEffect(() => {

View file

@ -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 extends CommunicationMessageType> {
type: TYPE
}
export interface SetDarkModeMessage {
@ -97,7 +98,9 @@ export interface OnWordCountCalculatedMessage {
}
export type CommunicationMessages =
| NoPayloadMessage
| NoPayloadMessage<CommunicationMessageType.RENDERER_READY>
| NoPayloadMessage<CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE>
| NoPayloadMessage<CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE>
| 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

View file

@ -11,12 +11,14 @@ import type { Logger } from '../../../utils/logger'
*/
export class IframeCommunicatorSendingError extends Error {}
export type Handler<MESSAGES, MESSAGE_TYPE extends string> =
| ((values: Extract<MESSAGES, PostMessage<MESSAGE_TYPE>>) => void)
| undefined
export type Handler<MESSAGES, MESSAGE_TYPE extends string> = (
values: Extract<MESSAGES, PostMessage<MESSAGE_TYPE>>
) => void
export type MaybeHandler<MESSAGES, MESSAGE_TYPE extends string> = Handler<MESSAGES, MESSAGE_TYPE> | undefined
export type HandlerMap<MESSAGES, MESSAGE_TYPE extends string> = Partial<{
[key in MESSAGE_TYPE]: Handler<MESSAGES, MESSAGE_TYPE>
[key in MESSAGE_TYPE]: MaybeHandler<MESSAGES, MESSAGE_TYPE>
}>
export interface PostMessage<MESSAGE_TYPE extends string> {
@ -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<R extends RECEIVE_TYPE>(messageType: R, handler: Handler<MESSAGES, R>): void {
this.handlers[messageType] = handler as Handler<MESSAGES, RECEIVE_TYPE>
public setHandler<R extends RECEIVE_TYPE>(messageType: R, handler: MaybeHandler<MESSAGES, R>): void {
this.log.debug('Set handler for', messageType)
this.handlers[messageType] = handler as MaybeHandler<MESSAGES, RECEIVE_TYPE>
}
/**

View file

@ -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)
}

View file

@ -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
)

View file

@ -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(() => {
if (scrollSource.current !== ScrollSource.RENDERER) {
scrollSource.current = ScrollSource.RENDERER
log.debug('Make renderer scroll source')
}
}, [])
const setEditorToScrollSource = useCallback(() => {
if (scrollSource.current !== ScrollSource.EDITOR) {
scrollSource.current = ScrollSource.EDITOR
log.debug('Make editor scroll source')
}
}, [])
const leftPane = useMemo(

View file

@ -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)
}

View file

@ -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<EditorConfig, EditorConfigActions> = (
}
saveToLocalStorage(newState)
return newState
case EditorConfigActionType.MERGE_EDITOR_PREFERENCES:
newState = {
...state,
preferences: {
...state.preferences,
...action.preferences
}
}
saveToLocalStorage(newState)
return newState
default:
return state
}

View file

@ -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<EditorConfigActionType> {
type: EditorConfigActionType.SET_SYNC_SCROLL
@ -60,8 +51,3 @@ export interface SetEditorViewModeAction extends Action<EditorConfigActionType>
type: EditorConfigActionType.SET_EDITOR_VIEW_MODE
mode: EditorMode
}
export interface SetEditorPreferencesAction extends Action<EditorConfigActionType> {
type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES
preferences: EditorConfiguration
}

View file

@ -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,

View file

@ -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])
})
})

View file

@ -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[])
}

View file

@ -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<CursorSelection>()
const wrapSelectionMock = jest.spyOn(wrapSelectionModule, 'wrapSelection')
const wrapSelectionMockResponse = Mock.of<string[]>()
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<CursorSelection>()
const replaceLinesOfSelectionMock = jest.spyOn(replaceLinesOfSelectionModule, 'replaceLinesOfSelection')
const prependLinesOfSelectionMock = jest.spyOn(prependLinesOfSelectionModule, 'prependLinesOfSelection')
const replaceSelectionMock = jest.spyOn(replaceSelectionModule, 'replaceSelection')
const replaceSelectionMockResponse = Mock.of<string[]>()
const replaceSelectionMockResponse = Mock.of<[string, CursorSelection]>()
const addLinkMock = jest.spyOn(addLinkModule, 'addLink')
const addLinkMockResponse = Mock.of<string[]>()
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<CursorPosition>()
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<CursorPosition>()
const toCursor = Mock.of<CursorPosition>()
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<CursorPosition>()
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<CursorPosition>()
const toCursor = Mock.of<CursorPosition>()
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])
})
})

View file

@ -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]
}
}

View file

@ -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 }])
})
})
})

View file

@ -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 selectedText = markdownContent.slice(from, to)
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 newContent = stringSplice(markdownContent, selection.from, link, selectedText.length)
return [newContent, { from, to: from + link.length }]
}
const buildLink = (selectedText: string, prefix: string): string => {

View file

@ -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 }])
})
})

View file

@ -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 }]
}

View file

@ -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'])
})
})

View file

@ -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)
}
})
}

View file

@ -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 }])
})
})

View file

@ -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 }]
}

View file

@ -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)
})
})

View file

@ -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
): 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
}
}))
.orElseThrow(() => new Error(`No line with index ${selection.from.line} found.`))

View file

@ -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 }])
})
})

View file

@ -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 }]
}

View file

@ -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,

View file

@ -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,

View file

@ -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' +

View file

@ -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
}
}
/**

View file

@ -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')

View file

@ -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))
}

View file

@ -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<NoteDetails>()
const mockedReplacedLines = ['replaced']
const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails
const mockedFormattedContent = 'formatted'
const mockedCursor = Mock.of<CursorSelection>()
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<CursorSelection>()
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<CursorSelection>()
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)
})
})

View file

@ -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
}
}

View file

@ -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<NoteDetails>()
const mockedNoteDetails = { content: 'mocked' } as unknown as NoteDetails
const applyFormatTypeToMarkdownLinesMock = jest.spyOn(
applyFormatTypeToMarkdownLinesModule,
'applyFormatTypeToMarkdownLines'
)
const mockedFormattedLines = ['formatted']
const mockedFormattedContent = 'formatted'
const mockedCursor = Mock.of<CursorSelection>()
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<CursorSelection>()
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)
})
})

View file

@ -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
}
}

View file

@ -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',

View file

@ -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),

View file

@ -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, [

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more