mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 07:04:45 -04:00
Upgrade to CodeMirror 6 (#1787)
Upgrade to CodeMirror 6 Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
1a09bfa5f1
commit
6a6f6105b9
103 changed files with 1906 additions and 2615 deletions
|
@ -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('')
|
||||
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('')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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(``)
|
||||
cy.get('.cm-line').contains(``)
|
||||
})
|
||||
|
||||
it('via paste', () => {
|
||||
|
@ -61,13 +48,13 @@ describe('File upload', () => {
|
|||
getData: (_: string) => ''
|
||||
}
|
||||
}
|
||||
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
||||
cy.get('.CodeMirror-activeline').contains(``)
|
||||
cy.get('.cm-content').trigger('paste', pasteEvent)
|
||||
cy.get('.cm-line').contains(``)
|
||||
})
|
||||
})
|
||||
|
||||
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(``)
|
||||
cy.get('.cm-line').contains(``)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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', ':)')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
// ***********************************************************
|
||||
|
||||
import 'cypress-commands'
|
||||
import './checkLinks'
|
||||
import './check-links'
|
||||
import './config'
|
||||
import './fill'
|
||||
import './get-by-id'
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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][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
|
||||
}
|
|
@ -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
|
||||
]
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -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]
|
||||
}, [])
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
}, [])
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}, [])
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
21
src/redux/note-details/calculate-line-start-indexes.test.ts
Normal file
21
src/redux/note-details/calculate-line-start-indexes.test.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { 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])
|
||||
})
|
||||
})
|
18
src/redux/note-details/calculate-line-start-indexes.ts
Normal file
18
src/redux/note-details/calculate-line-start-indexes.ts
Normal 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[])
|
||||
}
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 }])
|
||||
})
|
||||
})
|
|
@ -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 }]
|
||||
}
|
|
@ -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'])
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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 }])
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 }]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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.`))
|
||||
|
|
|
@ -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 }])
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 }]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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' +
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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, [
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue