mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 14:44:43 -04:00
Add image placeholder and upload indicating frame (#1666)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de> Co-authored-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
parent
58fecc0b3a
commit
d4251519e2
37 changed files with 908 additions and 72 deletions
|
@ -74,6 +74,8 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
- The history page supports URL parameters that allow bookmarking of a specific search of tags filter.
|
- The history page supports URL parameters that allow bookmarking of a specific search of tags filter.
|
||||||
- Users can change the pinning state of a note directly from the editor.
|
- Users can change the pinning state of a note directly from the editor.
|
||||||
- Note information dialog containing word count, revision count, last editor and creation time.
|
- Note information dialog containing word count, revision count, last editor and creation time.
|
||||||
|
- Image tags with placeholder urls (`https://`) will be replaced with a placeholder frame.
|
||||||
|
- Images that are currently uploading will be rendered as "uploading".
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
@ -16,16 +16,16 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', '```abnf')
|
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains('```abnf')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span > span').should('have.text', '```')
|
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('```')
|
||||||
cy.getMarkdownBody().findById('highlighted-code-block').should('exist')
|
cy.getMarkdownBody().findById('highlighted-code-block').should('exist')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent('```')
|
cy.setCodemirrorContent('```')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', '```abnf')
|
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains('```abnf')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span > span').should('have.text', '```')
|
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('```')
|
||||||
cy.getMarkdownBody().findById('highlighted-code-block').should('exist')
|
cy.getMarkdownBody().findById('highlighted-code-block').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -36,17 +36,17 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', ':::success')
|
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains(':::success')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span > span').should('have.text', '::: ')
|
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('::: ')
|
||||||
cy.getMarkdownBody().find('div.alert').should('exist')
|
cy.getMarkdownBody().find('.alert').should('exist')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent(':::')
|
cy.setCodemirrorContent(':::')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span > span').should('have.text', ':::success')
|
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line').contains(':::success')
|
||||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span > span').should('have.text', '::: ')
|
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('::: ')
|
||||||
cy.getMarkdownBody().find('div.alert').should('exist')
|
cy.getMarkdownBody().find('.alert').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -57,13 +57,13 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ':hedgehog:')
|
cy.get('.CodeMirror-activeline').contains(':hedgehog:')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent(':hedg')
|
cy.setCodemirrorContent(':hedg')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ':hedgehog:')
|
cy.get('.CodeMirror-activeline').contains(':hedgehog:')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -73,13 +73,13 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ':fa-facebook:')
|
cy.get('.CodeMirror-activeline').contains(':fa-facebook:')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent(':fa-face')
|
cy.setCodemirrorContent(':fa-face')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ':fa-facebook:')
|
cy.get('.CodeMirror-activeline').contains(':fa-facebook:')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -90,14 +90,14 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '# ')
|
cy.get('.CodeMirror-activeline').contains('# ')
|
||||||
cy.getMarkdownBody().find('h1').should('have.text', '\n ')
|
cy.getMarkdownBody().find('h1').should('have.text', '\n ')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent('#')
|
cy.setCodemirrorContent('#')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '# ')
|
cy.get('.CodeMirror-activeline').contains('# ')
|
||||||
cy.getMarkdownBody().find('h1').should('have.text', '\n ')
|
cy.getMarkdownBody().find('h1').should('have.text', '\n ')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -108,23 +108,15 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '')
|
cy.get('.CodeMirror-activeline').contains('')
|
||||||
cy.getMarkdownBody()
|
cy.getMarkdownBody().find('.image-drop').should('exist')
|
||||||
.find('p > img')
|
|
||||||
.should('have.attr', 'alt', 'image alt')
|
|
||||||
.should('have.attr', 'src', 'https://')
|
|
||||||
.should('have.attr', 'title', 'title')
|
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent('!')
|
cy.setCodemirrorContent('!')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '')
|
cy.get('.CodeMirror-activeline').contains('')
|
||||||
cy.getMarkdownBody()
|
cy.getMarkdownBody().find('.image-drop').should('exist')
|
||||||
.find('p > img')
|
|
||||||
.should('have.attr', 'alt', 'image alt')
|
|
||||||
.should('have.attr', 'src', 'https://')
|
|
||||||
.should('have.attr', 'title', 'title')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -134,7 +126,7 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '[link text](https:// "title") ')
|
cy.get('.CodeMirror-activeline').contains('[link text](https:// "title") ')
|
||||||
cy.getMarkdownBody()
|
cy.getMarkdownBody()
|
||||||
.find('p > a')
|
.find('p > a')
|
||||||
.should('have.text', 'link text')
|
.should('have.text', 'link text')
|
||||||
|
@ -145,7 +137,7 @@ describe('Autocompletion works for', () => {
|
||||||
cy.setCodemirrorContent('[')
|
cy.setCodemirrorContent('[')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '[link text](https:// "title") ')
|
cy.get('.CodeMirror-activeline').contains('[link text](https:// "title") ')
|
||||||
cy.getMarkdownBody()
|
cy.getMarkdownBody()
|
||||||
.find('p > a')
|
.find('p > a')
|
||||||
.should('have.text', 'link text')
|
.should('have.text', 'link text')
|
||||||
|
@ -160,14 +152,14 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '{%pdf https:// %}')
|
cy.get('.CodeMirror-activeline').contains('{%pdf https:// %}')
|
||||||
cy.getMarkdownBody().find('p').should('exist')
|
cy.getMarkdownBody().find('p').should('exist')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent('{')
|
cy.setCodemirrorContent('{')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '{%pdf https:// %}')
|
cy.get('.CodeMirror-activeline').contains('{%pdf https:// %}')
|
||||||
cy.getMarkdownBody().find('p').should('exist')
|
cy.getMarkdownBody().find('p').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -178,14 +170,14 @@ describe('Autocompletion works for', () => {
|
||||||
cy.get('.CodeMirror-hints').should('exist')
|
cy.get('.CodeMirror-hints').should('exist')
|
||||||
cy.get('@codeinput').type('{enter}')
|
cy.get('@codeinput').type('{enter}')
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '</details>') // after selecting the hint, the last line of the inserted suggestion is active
|
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')
|
cy.getMarkdownBody().find('details').should('exist')
|
||||||
})
|
})
|
||||||
it('via doubleclick', () => {
|
it('via doubleclick', () => {
|
||||||
cy.setCodemirrorContent('<d')
|
cy.setCodemirrorContent('<d')
|
||||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||||
cy.get('.CodeMirror-hints').should('not.exist')
|
cy.get('.CodeMirror-hints').should('not.exist')
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '</details>')
|
cy.get('.CodeMirror-activeline').contains('</details>')
|
||||||
cy.getMarkdownBody().find('details').should('exist')
|
cy.getMarkdownBody().find('details').should('exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -42,7 +42,7 @@ describe('File upload', () => {
|
||||||
it('via button', () => {
|
it('via button', () => {
|
||||||
cy.getById('editor-toolbar-upload-image-button').click()
|
cy.getById('editor-toolbar-upload-image-button').click()
|
||||||
cy.getById('editor-toolbar-upload-image-input').attachFile({ filePath: 'demo.png', mimeType: 'image/png' })
|
cy.getById('editor-toolbar-upload-image-input').attachFile({ filePath: 'demo.png', mimeType: 'image/png' })
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ``)
|
cy.get('.CodeMirror-activeline').contains(``)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('via paste', () => {
|
it('via paste', () => {
|
||||||
|
@ -54,7 +54,7 @@ describe('File upload', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ``)
|
cy.get('.CodeMirror-activeline').contains(``)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -68,7 +68,7 @@ describe('File upload', () => {
|
||||||
}
|
}
|
||||||
cy.get('.CodeMirror-scroll').trigger('dragenter', dropEvent)
|
cy.get('.CodeMirror-scroll').trigger('dragenter', dropEvent)
|
||||||
cy.get('.CodeMirror-scroll').trigger('drop', dropEvent)
|
cy.get('.CodeMirror-scroll').trigger('drop', dropEvent)
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', ``)
|
cy.get('.CodeMirror-activeline').contains(``)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -87,7 +87,7 @@ describe('File upload', () => {
|
||||||
cy.fixture('demo.png').then(() => {
|
cy.fixture('demo.png').then(() => {
|
||||||
cy.getById('editor-toolbar-upload-image-input').attachFile({ filePath: 'demo.png', mimeType: 'image/png' })
|
cy.getById('editor-toolbar-upload-image-input').attachFile({ filePath: 'demo.png', mimeType: 'image/png' })
|
||||||
})
|
})
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span > span').should('have.text', String.fromCharCode(8203)) //thanks codemirror....
|
cy.get('.CodeMirror-activeline').contains('![upload of demo.png failed]()')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets text paste still work', () => {
|
it('lets text paste still work', () => {
|
||||||
|
@ -98,6 +98,6 @@ describe('File upload', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
||||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `${testText}`)
|
cy.get('.CodeMirror-activeline').contains(`${testText}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -34,6 +34,9 @@
|
||||||
},
|
},
|
||||||
"clickShield": {
|
"clickShield": {
|
||||||
"previewHoverText": "Click to load content from {{target}}"
|
"previewHoverText": "Click to load content from {{target}}"
|
||||||
|
},
|
||||||
|
"uploadIndicator": {
|
||||||
|
"uploadMessage": "Uploading file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
|
@ -202,8 +205,12 @@
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"upload": {
|
"upload": {
|
||||||
"uploadFile": "Uploading file...{{fileName}}",
|
"uploadFile": {
|
||||||
"dropImage": "Drop Image to insert"
|
"withoutDescription": "Uploading file {{fileName}}",
|
||||||
|
"withDescription": "Uploading file {{fileName}} - {{description}}"
|
||||||
|
},
|
||||||
|
"dropImage": "Drop Image to insert",
|
||||||
|
"failed": "Error while uploading {{fileName}}"
|
||||||
},
|
},
|
||||||
"untitledNote": "Untitled",
|
"untitledNote": "Untitled",
|
||||||
"placeholder": "← Start by entering a title here\n===\nVisit the features page if you don't know what to do.\nHappy hacking :)",
|
"placeholder": "← Start by entering a title here\n===\nVisit the features page if you don't know what to do.\nHappy hacking :)",
|
||||||
|
@ -451,7 +458,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"clickToLoad": "Click to load"
|
"clickToLoad": "Click to load",
|
||||||
|
"placeholderImage": {
|
||||||
|
"placeholderText": "Placeholder",
|
||||||
|
"upload": "Upload image"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"views": {
|
"views": {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isMockMode } from '../../utils/test-modes'
|
import { isMockMode, isTestMode } from '../../utils/test-modes'
|
||||||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||||
|
|
||||||
export interface ImageProxyResponse {
|
export interface ImageProxyResponse {
|
||||||
|
@ -37,6 +37,13 @@ export const uploadFile = async (noteId: string, media: Blob): Promise<UploadedM
|
||||||
method: isMockMode() ? 'GET' : 'POST',
|
method: isMockMode() ? 'GET' : 'POST',
|
||||||
body: isMockMode() ? undefined : media
|
body: isMockMode() ? undefined : media
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isMockMode() && !isTestMode()) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
expectResponseCode(response, isMockMode() ? 200 : 201)
|
expectResponseCode(response, isMockMode() ? 200 : 201)
|
||||||
return (await response.json()) as Promise<UploadedMedia>
|
return (await response.json()) as Promise<UploadedMedia>
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,3 +18,5 @@ export const supportedMimeTypes: string[] = [
|
||||||
'image/tiff',
|
'image/tiff',
|
||||||
'image/webp'
|
'image/webp'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const acceptedMimeTypes = supportedMimeTypes.join(', ')
|
||||||
|
|
|
@ -23,10 +23,11 @@ import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
|
||||||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||||
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
|
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
|
||||||
|
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
|
||||||
|
|
||||||
const onChange = (editor: Editor) => {
|
const onChange = (editor: Editor) => {
|
||||||
|
const searchTerm = findWordAtCursor(editor)
|
||||||
for (const hinter of allHinters) {
|
for (const hinter of allHinters) {
|
||||||
const searchTerm = findWordAtCursor(editor)
|
|
||||||
if (hinter.wordRegExp.test(searchTerm.text)) {
|
if (hinter.wordRegExp.test(searchTerm.text)) {
|
||||||
editor.showHint({
|
editor.showHint({
|
||||||
hint: hinter.hint,
|
hint: hinter.hint,
|
||||||
|
@ -55,6 +56,8 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
||||||
|
|
||||||
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
|
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
|
||||||
|
|
||||||
|
useOnImageUploadFromRenderer(editor)
|
||||||
|
|
||||||
const onEditorDidMount = useCallback(
|
const onEditorDidMount = useCallback(
|
||||||
(mountedEditor: Editor) => {
|
(mountedEditor: Editor) => {
|
||||||
updateStatusBarInfo(mountedEditor)
|
updateStatusBarInfo(mountedEditor)
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findRegexMatchInText } from './find-regex-match-in-text'
|
||||||
|
|
||||||
|
describe('find regex index in line', function () {
|
||||||
|
it('finds the first occurrence', () => {
|
||||||
|
const result = findRegexMatchInText('aba', /a/g, 0)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect((result as RegExpMatchArray).index).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds another occurrence', () => {
|
||||||
|
const result = findRegexMatchInText('aba', /a/g, 1)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect((result as RegExpMatchArray).index).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails to find with a wrong regex', () => {
|
||||||
|
const result = findRegexMatchInText('aba', /c/g, 0)
|
||||||
|
expect(result).not.toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails to find with a negative wanted index', () => {
|
||||||
|
const result = findRegexMatchInText('aba', /a/g, -1)
|
||||||
|
expect(result).not.toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails to find if the index is to high', () => {
|
||||||
|
const result = findRegexMatchInText('aba', /a/g, 100)
|
||||||
|
expect(result).not.toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a regex against a given text and returns the n-th match of the regex.
|
||||||
|
*
|
||||||
|
* @param text The text that should be searched through
|
||||||
|
* @param regex The regex that should find matches in the text
|
||||||
|
* @param matchIndex The index of the match to find
|
||||||
|
* @return The regex match of the found occurrence or undefined if no match could be found
|
||||||
|
*/
|
||||||
|
export const findRegexMatchInText = (text: string, regex: RegExp, matchIndex: number): RegExpMatchArray | undefined => {
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let currentIndex = 0
|
||||||
|
for (const match of text.matchAll(regex)) {
|
||||||
|
if (currentIndex === matchIndex) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
currentIndex += 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEditorReceiveHandler } from '../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
|
||||||
|
import type { ImageUploadMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
|
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { store } from '../../../../redux'
|
||||||
|
import { handleUpload } from '../upload-handler'
|
||||||
|
import type { Editor, Position } from 'codemirror'
|
||||||
|
import { Logger } from '../../../../utils/logger'
|
||||||
|
import { findRegexMatchInText } from '../find-regex-match-in-text'
|
||||||
|
import Optional from 'optional-js'
|
||||||
|
|
||||||
|
const log = new Logger('useOnImageUpload')
|
||||||
|
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
|
||||||
|
*
|
||||||
|
* @param editor The {@link Editor codemirror editor} that should be used to change the markdown code
|
||||||
|
*/
|
||||||
|
export const useOnImageUploadFromRenderer = (editor: Editor | undefined): void => {
|
||||||
|
useEditorReceiveHandler(
|
||||||
|
CommunicationMessageType.IMAGE_UPLOAD,
|
||||||
|
useCallback(
|
||||||
|
(values: ImageUploadMessage) => {
|
||||||
|
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
|
||||||
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!dataUri.startsWith('data:image/')) {
|
||||||
|
log.error('Received uri is no data uri and image!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(dataUri)
|
||||||
|
.then((result) => result.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const file = new File([blob], fileName, { type: blob.type })
|
||||||
|
const { cursorFrom, cursorTo, description, additionalText } = Optional.ofNullable(lineIndex)
|
||||||
|
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
|
||||||
|
.orElseGet(() => calculateInsertAtCurrentCursorPosition(editor))
|
||||||
|
handleUpload(file, editor, cursorFrom, cursorTo, description, additionalText)
|
||||||
|
})
|
||||||
|
.catch((error) => log.error(error))
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractResult {
|
||||||
|
cursorFrom: Position
|
||||||
|
cursorTo: Position
|
||||||
|
description?: string
|
||||||
|
additionalText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the start and end cursor position of the right image placeholder in the current markdown content.
|
||||||
|
*
|
||||||
|
* @param lineIndex The index of the line to change in the current markdown content.
|
||||||
|
* @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 = store.getState().noteDetails.markdownContent.split('\n')
|
||||||
|
const lineAtIndex = currentMarkdownContentLines[lineIndex]
|
||||||
|
if (lineAtIndex === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], lineIndex, 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 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,
|
||||||
|
replacementIndexInLine = 0
|
||||||
|
): ExtractResult | undefined => {
|
||||||
|
const startOfImageTag = findRegexMatchInText(line, imageWithPlaceholderLinkRegex, replacementIndexInLine)
|
||||||
|
if (startOfImageTag === undefined || startOfImageTag.index === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursorFrom: {
|
||||||
|
ch: startOfImageTag.index,
|
||||||
|
line: lineIndex
|
||||||
|
},
|
||||||
|
cursorTo: {
|
||||||
|
ch: startOfImageTag.index + startOfImageTag[0].length,
|
||||||
|
line: lineIndex
|
||||||
|
},
|
||||||
|
description: startOfImageTag[1],
|
||||||
|
additionalText: startOfImageTag[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a fallback position that is the current editor cursor position.
|
||||||
|
* This wouldn't replace anything and only insert.
|
||||||
|
*
|
||||||
|
* @param editor The editor whose cursor should be used
|
||||||
|
*/
|
||||||
|
const calculateInsertAtCurrentCursorPosition = (editor: Editor): ExtractResult => {
|
||||||
|
const editorCursor = editor.getCursor()
|
||||||
|
return { cursorFrom: editorCursor, cursorTo: editorCursor }
|
||||||
|
}
|
|
@ -11,15 +11,13 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { UploadInput } from '../../sidebar/upload-input'
|
import { UploadInput } from '../../sidebar/upload-input'
|
||||||
import { handleUpload } from '../upload-handler'
|
import { handleUpload } from '../upload-handler'
|
||||||
import { supportedMimeTypes } from '../../../common/upload-image-mimetypes'
|
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
|
|
||||||
export interface UploadImageButtonProps {
|
export interface UploadImageButtonProps {
|
||||||
editor?: Editor
|
editor?: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptedMimeTypes = supportedMimeTypes.join(', ')
|
|
||||||
|
|
||||||
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
|
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const clickRef = useRef<() => void>()
|
const clickRef = useRef<() => void>()
|
||||||
|
|
|
@ -4,36 +4,57 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Editor } from 'codemirror'
|
import type { Editor, Position } from 'codemirror'
|
||||||
import { t } from 'i18next'
|
|
||||||
import { uploadFile } from '../../../api/media'
|
import { uploadFile } from '../../../api/media'
|
||||||
import { store } from '../../../redux'
|
import { store } from '../../../redux'
|
||||||
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
|
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
|
||||||
import { Logger } from '../../../utils/logger'
|
import { replaceInMarkdownContent } from '../../../redux/note-details/methods'
|
||||||
|
import { t } from 'i18next'
|
||||||
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
const log = new Logger('File Uploader Handler')
|
/**
|
||||||
|
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
|
||||||
export const handleUpload = (file: File, editor: Editor): void => {
|
*
|
||||||
|
* @param file The file to upload
|
||||||
|
* @param editor The editor that should be used to show the progress
|
||||||
|
* @param cursorFrom The position where the progress message should be placed
|
||||||
|
* @param cursorTo An optional position that should be used to replace content in the editor
|
||||||
|
* @param imageDescription The text that should be used in the description part of the resulting image tag
|
||||||
|
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
|
||||||
|
*/
|
||||||
|
export const handleUpload = (
|
||||||
|
file: File,
|
||||||
|
editor: Editor,
|
||||||
|
cursorFrom?: Position,
|
||||||
|
cursorTo?: Position,
|
||||||
|
imageDescription?: string,
|
||||||
|
additionalUrlText?: string
|
||||||
|
): void => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!supportedMimeTypes.includes(file.type)) {
|
if (!supportedMimeTypes.includes(file.type)) {
|
||||||
// this mimetype is not supported
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cursor = editor.getCursor()
|
const randomId = Math.random().toString(36).slice(7)
|
||||||
const uploadPlaceholder = `![${t('editor.upload.uploadFile', { fileName: file.name })}]()`
|
const uploadFileInfo =
|
||||||
|
imageDescription !== undefined
|
||||||
|
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: imageDescription })
|
||||||
|
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
||||||
|
|
||||||
|
const uploadPlaceholder = ``
|
||||||
const noteId = store.getState().noteDetails.id
|
const noteId = store.getState().noteDetails.id
|
||||||
const insertCode = (replacement: string) => {
|
const insertCode = (replacement: string) => {
|
||||||
editor.replaceRange(replacement, cursor, { line: cursor.line, ch: cursor.ch + uploadPlaceholder.length }, '+input')
|
replaceInMarkdownContent(uploadPlaceholder, replacement)
|
||||||
}
|
}
|
||||||
editor.replaceRange(uploadPlaceholder, cursor, cursor, '+input')
|
|
||||||
|
editor.replaceRange(uploadPlaceholder, cursorFrom ?? editor.getCursor(), cursorTo, '+input')
|
||||||
uploadFile(noteId, file)
|
uploadFile(noteId, file)
|
||||||
.then(({ link }) => {
|
.then(({ link }) => {
|
||||||
insertCode(``)
|
insertCode(``)
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
log.error('error while uploading file', error)
|
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
|
||||||
insertCode('')
|
insertCode(`![upload of ${file.name} failed]()`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
|
||||||
baseUrl,
|
baseUrl,
|
||||||
currentLineMarkers,
|
currentLineMarkers,
|
||||||
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []),
|
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []),
|
||||||
lineOffset,
|
lineOffset ?? 0,
|
||||||
onTaskCheckedChange,
|
onTaskCheckedChange,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
onTocChange
|
onTocChange
|
||||||
|
|
|
@ -78,6 +78,8 @@ export const useConvertMarkdownToReactDom = (
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const html = markdownIt.render(markdownCode)
|
const html = markdownIt.render(markdownCode)
|
||||||
|
|
||||||
|
htmlToReactTransformer.resetReplacers()
|
||||||
|
|
||||||
return convertHtmlToReact(html, {
|
return convertHtmlToReact(html, {
|
||||||
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
|
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
|
||||||
preprocessNodes: (document) => nodePreProcessor(document)
|
preprocessNodes: (document) => nodePreProcessor(document)
|
||||||
|
|
|
@ -40,6 +40,8 @@ import type { ImageClickHandler } from '../markdown-extension/image/proxy-image-
|
||||||
import type { TocAst } from 'markdown-it-toc-done-right'
|
import type { TocAst } from 'markdown-it-toc-done-right'
|
||||||
import type { MarkdownExtension } from '../markdown-extension/markdown-extension'
|
import type { MarkdownExtension } from '../markdown-extension/markdown-extension'
|
||||||
import { IframeCapsuleMarkdownExtension } from '../markdown-extension/iframe-capsule/iframe-capsule-markdown-extension'
|
import { IframeCapsuleMarkdownExtension } from '../markdown-extension/iframe-capsule/iframe-capsule-markdown-extension'
|
||||||
|
import { ImagePlaceholderMarkdownExtension } from '../markdown-extension/image-placeholder/image-placeholder-markdown-extension'
|
||||||
|
import { UploadIndicatingImageFrameMarkdownExtension } from '../markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a list of {@link MarkdownExtension markdown extensions} that is a combination of the common extensions and the given additional.
|
* Provides a list of {@link MarkdownExtension markdown extensions} that is a combination of the common extensions and the given additional.
|
||||||
|
@ -57,7 +59,7 @@ export const useMarkdownExtensions = (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
|
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
|
||||||
additionalExtensions: MarkdownExtension[],
|
additionalExtensions: MarkdownExtension[],
|
||||||
lineOffset?: number,
|
lineOffset: number,
|
||||||
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void,
|
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void,
|
||||||
onImageClick?: ImageClickHandler,
|
onImageClick?: ImageClickHandler,
|
||||||
onTocChange?: (ast?: TocAst) => void
|
onTocChange?: (ast?: TocAst) => void
|
||||||
|
@ -71,10 +73,12 @@ export const useMarkdownExtensions = (
|
||||||
new VegaLiteMarkdownExtension(),
|
new VegaLiteMarkdownExtension(),
|
||||||
new MarkmapMarkdownExtension(),
|
new MarkmapMarkdownExtension(),
|
||||||
new LinemarkerMarkdownExtension(
|
new LinemarkerMarkdownExtension(
|
||||||
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined,
|
lineOffset,
|
||||||
lineOffset
|
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined
|
||||||
),
|
),
|
||||||
new IframeCapsuleMarkdownExtension(),
|
new IframeCapsuleMarkdownExtension(),
|
||||||
|
new ImagePlaceholderMarkdownExtension(lineOffset),
|
||||||
|
new UploadIndicatingImageFrameMarkdownExtension(),
|
||||||
new GistMarkdownExtension(),
|
new GistMarkdownExtension(),
|
||||||
new YoutubeMarkdownExtension(),
|
new YoutubeMarkdownExtension(),
|
||||||
new VimeoMarkdownExtension(),
|
new VimeoMarkdownExtension(),
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type MarkdownIt from 'markdown-it/lib'
|
||||||
|
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link MarkdownIt.PluginSimple markdown it plugin} that adds the line number of the markdown code to every placeholder image.
|
||||||
|
*
|
||||||
|
* @param markdownIt The markdown it instance to which the plugin should be added
|
||||||
|
*/
|
||||||
|
export const addLineToPlaceholderImageTags: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
|
||||||
|
markdownIt.core.ruler.push('image-placeholder', (state) => {
|
||||||
|
state.tokens.forEach((token) => {
|
||||||
|
if (token.type !== 'inline') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.children?.forEach((childToken) => {
|
||||||
|
if (
|
||||||
|
childToken.type === 'image' &&
|
||||||
|
childToken.attrGet('src') === ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL
|
||||||
|
) {
|
||||||
|
const line = token.map?.[0]
|
||||||
|
if (line !== undefined) {
|
||||||
|
childToken.attrSet('data-line', String(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRendererToEditorCommunicator } from '../../../../editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
||||||
|
import { Logger } from '../../../../../utils/logger'
|
||||||
|
|
||||||
|
const log = new Logger('useOnImageUpload')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a {@link File} to a data url.
|
||||||
|
*
|
||||||
|
* @param file The file to convert
|
||||||
|
* @return The file content represented as data url
|
||||||
|
*/
|
||||||
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = (error) => reject(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
|
||||||
|
*
|
||||||
|
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||||
|
* @param placeholderIndexInLine The index of the placeholder in the markdown content line
|
||||||
|
*/
|
||||||
|
export const useOnImageUpload = (
|
||||||
|
lineIndex: number | undefined,
|
||||||
|
placeholderIndexInLine: number | undefined
|
||||||
|
): ((file: File) => void) => {
|
||||||
|
const communicator = useRendererToEditorCommunicator()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
readFileAsDataUrl(file)
|
||||||
|
.then((dataUri) => {
|
||||||
|
communicator.sendMessageToOtherSide({
|
||||||
|
type: CommunicationMessageType.IMAGE_UPLOAD,
|
||||||
|
dataUri,
|
||||||
|
fileName: file.name,
|
||||||
|
lineIndex,
|
||||||
|
placeholderIndexInLine
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error: ProgressEvent) => log.error('Error while uploading image', error))
|
||||||
|
},
|
||||||
|
[communicator, placeholderIndexInLine, lineIndex]
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { calculatePlaceholderContainerSize } from '../utils/build-placeholder-size-css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the style attribute for a placeholder container with width and height.
|
||||||
|
*
|
||||||
|
* @param width The wanted width
|
||||||
|
* @param height The wanted height
|
||||||
|
* @return The created style attributes
|
||||||
|
*/
|
||||||
|
export const usePlaceholderSizeStyle = (width?: string | number, height?: string | number): CSSProperties => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const [convertedWidth, convertedHeight] = calculatePlaceholderContainerSize(width, height)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${convertedWidth}px`,
|
||||||
|
height: `${convertedHeight}px`
|
||||||
|
}
|
||||||
|
}, [height, width])
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MarkdownExtension } from '../markdown-extension'
|
||||||
|
import { addLineToPlaceholderImageTags } from './add-line-to-placeholder-image-tags'
|
||||||
|
import type MarkdownIt from 'markdown-it/lib'
|
||||||
|
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||||
|
import { ImagePlaceholderReplacer } from './image-placeholder-replacer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A markdown extension that
|
||||||
|
*/
|
||||||
|
export class ImagePlaceholderMarkdownExtension extends MarkdownExtension {
|
||||||
|
public static readonly PLACEHOLDER_URL = 'https://'
|
||||||
|
|
||||||
|
constructor(private lineOffset: number) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||||
|
addLineToPlaceholderImageTags(markdownIt)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildReplacers(): ComponentReplacer[] {
|
||||||
|
return [new ImagePlaceholderReplacer(this.lineOffset)]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
|
||||||
|
import { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||||
|
import type { Element } from 'domhandler'
|
||||||
|
import { ImagePlaceholder } from './image-placeholder'
|
||||||
|
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces every image tag that has the {@link ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL placeholder url} with the {@link ImagePlaceholder image placeholder element}.
|
||||||
|
*/
|
||||||
|
export class ImagePlaceholderReplacer extends ComponentReplacer {
|
||||||
|
private countPerSourceLine = new Map<number, number>()
|
||||||
|
|
||||||
|
constructor(private lineOffset: number) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.countPerSourceLine = new Map<number, number>()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||||
|
if (node.name === 'img' && node.attribs && node.attribs.src === ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL) {
|
||||||
|
const lineIndex = Number(node.attribs['data-line'])
|
||||||
|
const indexInLine = this.countPerSourceLine.get(lineIndex) ?? 0
|
||||||
|
this.countPerSourceLine.set(lineIndex, indexInLine + 1)
|
||||||
|
return (
|
||||||
|
<ImagePlaceholder
|
||||||
|
alt={node.attribs.alt}
|
||||||
|
title={node.attribs.title}
|
||||||
|
width={node.attribs.width}
|
||||||
|
height={node.attribs.height}
|
||||||
|
lineIndex={isNaN(lineIndex) ? undefined : lineIndex + this.lineOffset}
|
||||||
|
placeholderIndexInLine={indexInLine}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*!
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
.image-drop {
|
||||||
|
@import "../../../../style/variables.light.scss";
|
||||||
|
border: 3px dashed $dark;
|
||||||
|
|
||||||
|
body.dark & {
|
||||||
|
@import "../../../../style/variables.dark.scss";
|
||||||
|
border-color: $dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 50ms, color 50ms;
|
||||||
|
|
||||||
|
.altText {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1 1;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||||
|
import './image-placeholder.scss'
|
||||||
|
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||||
|
import { useOnImageUpload } from './hooks/use-on-image-upload'
|
||||||
|
import { usePlaceholderSizeStyle } from './hooks/use-placeholder-size-style'
|
||||||
|
|
||||||
|
export interface PlaceholderImageFrameProps {
|
||||||
|
alt?: string
|
||||||
|
title?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
lineIndex?: number
|
||||||
|
placeholderIndexInLine?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a placeholder for an actual image with the possibility to upload images via button or drag'n'drop.
|
||||||
|
*
|
||||||
|
* @param alt The alt text of the image. Will be shown in the placeholder
|
||||||
|
* @param title The title text of the image. Will be shown in the placeholder
|
||||||
|
* @param width The width of the placeholder
|
||||||
|
* @param height The height of the placeholder
|
||||||
|
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||||
|
* @param placeholderIndexInLine The index of the placeholder in the markdown line
|
||||||
|
*/
|
||||||
|
export const ImagePlaceholder: React.FC<PlaceholderImageFrameProps> = ({
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
lineIndex,
|
||||||
|
placeholderIndexInLine
|
||||||
|
}) => {
|
||||||
|
useTranslation()
|
||||||
|
const fileInputReference = useRef<HTMLInputElement>(null)
|
||||||
|
const onImageUpload = useOnImageUpload(lineIndex, placeholderIndexInLine)
|
||||||
|
|
||||||
|
const [showDragStatus, setShowDragStatus] = useState(false)
|
||||||
|
|
||||||
|
const onDropHandler = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLSpanElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event?.dataTransfer?.files?.length > 0) {
|
||||||
|
onImageUpload(event.dataTransfer.files[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onImageUpload]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onDragOverHandler = useCallback((event: React.DragEvent<HTMLSpanElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setShowDragStatus(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragLeave = useCallback(() => {
|
||||||
|
setShowDragStatus(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onChangeHandler = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileList = event.target.files
|
||||||
|
if (!fileList || fileList.length < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onImageUpload(fileList[0])
|
||||||
|
},
|
||||||
|
[onImageUpload]
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadButtonClicked = useCallback(() => fileInputReference.current?.click(), [])
|
||||||
|
const containerStyle = usePlaceholderSizeStyle(width, height)
|
||||||
|
|
||||||
|
const containerDragClasses = useMemo(() => (showDragStatus ? 'bg-primary text-white' : 'text-dark'), [showDragStatus])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`image-drop d-inline-flex flex-column align-items-center ${containerDragClasses} p-1`}
|
||||||
|
style={containerStyle}
|
||||||
|
onDrop={onDropHandler}
|
||||||
|
onDragOver={onDragOverHandler}
|
||||||
|
onDragLeave={onDragLeave}>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
className='d-none'
|
||||||
|
accept={acceptedMimeTypes}
|
||||||
|
onChange={onChangeHandler}
|
||||||
|
ref={fileInputReference}
|
||||||
|
/>
|
||||||
|
<div className={'align-items-center flex-column justify-content-center flex-fill d-flex'}>
|
||||||
|
<div className={'d-flex flex-column'}>
|
||||||
|
<span className='my-2'>
|
||||||
|
<Trans i18nKey={'editor.embeddings.placeholderImage.placeholderText'} />
|
||||||
|
</span>
|
||||||
|
<span className={'altText'}>{alt ?? title ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size={'sm'} variant={'primary'} onClick={uploadButtonClicked}>
|
||||||
|
<ForkAwesomeIcon icon={'upload'} fixedWidth={true} className='my-2' />
|
||||||
|
<Trans i18nKey={'editor.embeddings.placeholderImage.upload'} className='my-2' />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { calculatePlaceholderContainerSize, parseSizeNumber } from './build-placeholder-size-css'
|
||||||
|
|
||||||
|
describe('parseSizeNumber', () => {
|
||||||
|
it('undefined', () => {
|
||||||
|
expect(parseSizeNumber(undefined)).toBe(undefined)
|
||||||
|
})
|
||||||
|
it('zero as number', () => {
|
||||||
|
expect(parseSizeNumber(0)).toBe(0)
|
||||||
|
})
|
||||||
|
it('positive number', () => {
|
||||||
|
expect(parseSizeNumber(234)).toBe(234)
|
||||||
|
})
|
||||||
|
it('negative number', () => {
|
||||||
|
expect(parseSizeNumber(-123)).toBe(-123)
|
||||||
|
})
|
||||||
|
it('zero as string', () => {
|
||||||
|
expect(parseSizeNumber('0')).toBe(0)
|
||||||
|
})
|
||||||
|
it('negative number as string', () => {
|
||||||
|
expect(parseSizeNumber('-123')).toBe(-123)
|
||||||
|
})
|
||||||
|
it('positive number as string', () => {
|
||||||
|
expect(parseSizeNumber('345')).toBe(345)
|
||||||
|
})
|
||||||
|
it('positive number with px as string', () => {
|
||||||
|
expect(parseSizeNumber('456px')).toBe(456)
|
||||||
|
})
|
||||||
|
it('negative number with px as string', () => {
|
||||||
|
expect(parseSizeNumber('-456px')).toBe(-456)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculatePlaceholderContainerSize', () => {
|
||||||
|
it('width undefined | height undefined', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(undefined, undefined)).toStrictEqual([500, 200])
|
||||||
|
})
|
||||||
|
it('width 200 | height undefined', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(200, undefined)).toStrictEqual([200, 80])
|
||||||
|
})
|
||||||
|
it('width undefined | height 100', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(undefined, 100)).toStrictEqual([250, 100])
|
||||||
|
})
|
||||||
|
it('width "0" | height 0', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize('0', 0)).toStrictEqual([0, 0])
|
||||||
|
})
|
||||||
|
it('width 0 | height "0"', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(0, '0')).toStrictEqual([0, 0])
|
||||||
|
})
|
||||||
|
it('width -345 | height 234', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(-345, 234)).toStrictEqual([-345, 234])
|
||||||
|
})
|
||||||
|
it('width 345 | height -234', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(345, -234)).toStrictEqual([345, -234])
|
||||||
|
})
|
||||||
|
it('width "-345" | height -234', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize('-345', -234)).toStrictEqual([-345, -234])
|
||||||
|
})
|
||||||
|
it('width -345 | height "-234"', () => {
|
||||||
|
expect(calculatePlaceholderContainerSize(-345, '-234')).toStrictEqual([-345, -234])
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
const regex = /^(-?[0-9]+)px$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspects the given value and checks if it is a number or a pixel size string.
|
||||||
|
*
|
||||||
|
* @param value The value to check
|
||||||
|
* @return the number representation of the string or undefined if it couldn't be parsed
|
||||||
|
*/
|
||||||
|
export const parseSizeNumber = (value: string | number | undefined): number | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexMatches = regex.exec(value)
|
||||||
|
if (regexMatches !== null) {
|
||||||
|
if (regexMatches && regexMatches.length > 1) {
|
||||||
|
return parseInt(regexMatches[1])
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
return parseInt(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the final width and height for a placeholder container.
|
||||||
|
* Every parameter that is empty will be defaulted using a 500:200 ratio.
|
||||||
|
*
|
||||||
|
* @param width The wanted width
|
||||||
|
* @param height The wanted height
|
||||||
|
* @return the calculated size
|
||||||
|
*/
|
||||||
|
export const calculatePlaceholderContainerSize = (
|
||||||
|
width: string | number | undefined,
|
||||||
|
height: string | number | undefined
|
||||||
|
): [width: number, height: number] => {
|
||||||
|
const defaultWidth = 500
|
||||||
|
const defaultHeight = 200
|
||||||
|
const ratio = defaultWidth / defaultHeight
|
||||||
|
|
||||||
|
const convertedWidth = parseSizeNumber(width)
|
||||||
|
const convertedHeight = parseSizeNumber(height)
|
||||||
|
|
||||||
|
if (convertedWidth === undefined && convertedHeight !== undefined) {
|
||||||
|
return [convertedHeight * ratio, convertedHeight]
|
||||||
|
} else if (convertedWidth !== undefined && convertedHeight === undefined) {
|
||||||
|
return [convertedWidth, convertedWidth * (1 / ratio)]
|
||||||
|
} else if (convertedWidth !== undefined && convertedHeight !== undefined) {
|
||||||
|
return [convertedWidth, convertedHeight]
|
||||||
|
} else {
|
||||||
|
return [defaultWidth, defaultHeight]
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ import type MarkdownIt from 'markdown-it'
|
||||||
export class LinemarkerMarkdownExtension extends MarkdownExtension {
|
export class LinemarkerMarkdownExtension extends MarkdownExtension {
|
||||||
public static readonly tagName = 'app-linemarker'
|
public static readonly tagName = 'app-linemarker'
|
||||||
|
|
||||||
constructor(private onLineMarkers?: (lineMarkers: LineMarkers[]) => void, private lineOffset?: number) {
|
constructor(private lineOffset: number, private onLineMarkers?: (lineMarkers: LineMarkers[]) => void) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import markdownItTaskLists from '@hedgedoc/markdown-it-task-lists'
|
||||||
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
|
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
|
||||||
*/
|
*/
|
||||||
export class TaskListMarkdownExtension extends MarkdownExtension {
|
export class TaskListMarkdownExtension extends MarkdownExtension {
|
||||||
constructor(private frontmatterLinesToSkip?: number, private onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
constructor(private frontmatterLinesToSkip: number, private onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,10 +18,10 @@ export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean
|
||||||
export class TaskListReplacer extends ComponentReplacer {
|
export class TaskListReplacer extends ComponentReplacer {
|
||||||
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
|
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
|
||||||
|
|
||||||
constructor(frontmatterLinesToSkip?: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
constructor(frontmatterLinesToSkip: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||||
super()
|
super()
|
||||||
this.onTaskCheckedChange = (lineInMarkdown, checked) => {
|
this.onTaskCheckedChange = (lineInMarkdown, checked) => {
|
||||||
if (onTaskCheckedChange === undefined || frontmatterLinesToSkip === undefined) {
|
if (onTaskCheckedChange === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onTaskCheckedChange(frontmatterLinesToSkip + lineInMarkdown, checked)
|
onTaskCheckedChange(frontmatterLinesToSkip + lineInMarkdown, checked)
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||||
|
import { usePlaceholderSizeStyle } from '../image-placeholder/hooks/use-placeholder-size-style'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export interface UploadIndicatingFrameProps {
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a placeholder frame for images that are currently uploaded.
|
||||||
|
*
|
||||||
|
* @param width The frame width
|
||||||
|
* @param height The frame height
|
||||||
|
*/
|
||||||
|
export const UploadIndicatingFrame: React.FC<UploadIndicatingFrameProps> = ({ width, height }) => {
|
||||||
|
const containerStyle = usePlaceholderSizeStyle(width, height)
|
||||||
|
useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className='image-drop d-inline-flex flex-column align-items-center justify-content-center bg-primary text-white p-4'
|
||||||
|
style={containerStyle}>
|
||||||
|
<span className={'h1 border-bottom-0 my-2'}>
|
||||||
|
<Trans i18nKey={'renderer.uploadIndicator.uploadMessage'} />
|
||||||
|
</span>
|
||||||
|
<ForkAwesomeIcon icon={'cog'} size={'5x'} fixedWidth={true} className='my-2 fa-spin' />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MarkdownExtension } from '../markdown-extension'
|
||||||
|
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||||
|
import { UploadIndicatingImageFrameReplacer } from './upload-indicating-image-frame-replacer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A markdown extension that shows {@link UploadIndicatingFrame} for images that are getting uploaded.
|
||||||
|
*/
|
||||||
|
export class UploadIndicatingImageFrameMarkdownExtension extends MarkdownExtension {
|
||||||
|
buildReplacers(): ComponentReplacer[] {
|
||||||
|
return [new UploadIndicatingImageFrameReplacer()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
|
||||||
|
import { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||||
|
import type { Element } from 'domhandler'
|
||||||
|
import { UploadIndicatingFrame } from './upload-indicating-frame'
|
||||||
|
|
||||||
|
const uploadIdRegex = /^upload-(.+)$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an image tag whose url is an upload-id with the {@link UploadIndicatingFrame upload indicating frame}.
|
||||||
|
*/
|
||||||
|
export class UploadIndicatingImageFrameReplacer extends ComponentReplacer {
|
||||||
|
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||||
|
if (node.name === 'img' && uploadIdRegex.test(node.attribs.src)) {
|
||||||
|
return <UploadIndicatingFrame width={node.attribs.width} height={node.attribs.height} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,6 +49,13 @@ export abstract class ComponentReplacer {
|
||||||
return node.children.map((value, index) => subNodeTransform(value, index))
|
return node.children.map((value, index) => subNodeTransform(value, index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should be used to reset the replacers internal state before rendering.
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
// left blank for overrides
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the current node should be altered or replaced and does if needed.
|
* Checks if the current node should be altered or replaced and does if needed.
|
||||||
*
|
*
|
||||||
|
|
|
@ -45,7 +45,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
|
||||||
baseUrl,
|
baseUrl,
|
||||||
undefined,
|
undefined,
|
||||||
useMemo(() => [new RevealMarkdownExtension()], []),
|
useMemo(() => [new RevealMarkdownExtension()], []),
|
||||||
lineOffset,
|
lineOffset ?? 0,
|
||||||
onTaskCheckedChange,
|
onTaskCheckedChange,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
onTocChange
|
onTocChange
|
||||||
|
|
|
@ -31,6 +31,13 @@ export class NodeToReactTransformer {
|
||||||
this.replacers = replacers
|
this.replacers = replacers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all replacers before rendering.
|
||||||
|
*/
|
||||||
|
public resetReplacers(): void {
|
||||||
|
this.replacers.forEach((replacer) => replacer.reset())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given {@link Node} to a react element.
|
* Converts the given {@link Node} to a react element.
|
||||||
*
|
*
|
||||||
|
|
|
@ -19,7 +19,8 @@ export enum CommunicationMessageType {
|
||||||
SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION',
|
SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION',
|
||||||
GET_WORD_COUNT = 'GET_WORD_COUNT',
|
GET_WORD_COUNT = 'GET_WORD_COUNT',
|
||||||
ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED',
|
ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED',
|
||||||
SET_FRONTMATTER_INFO = 'SET_FRONTMATTER_INFO'
|
SET_FRONTMATTER_INFO = 'SET_FRONTMATTER_INFO',
|
||||||
|
IMAGE_UPLOAD = 'IMAGE_UPLOAD'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoPayloadMessage {
|
export interface NoPayloadMessage {
|
||||||
|
@ -37,6 +38,14 @@ export interface ImageDetails {
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageUploadMessage {
|
||||||
|
type: CommunicationMessageType.IMAGE_UPLOAD
|
||||||
|
dataUri: string
|
||||||
|
fileName: string
|
||||||
|
lineIndex?: number
|
||||||
|
placeholderIndexInLine?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetBaseUrlMessage {
|
export interface SetBaseUrlMessage {
|
||||||
type: CommunicationMessageType.SET_BASE_CONFIGURATION
|
type: CommunicationMessageType.SET_BASE_CONFIGURATION
|
||||||
baseConfiguration: BaseConfiguration
|
baseConfiguration: BaseConfiguration
|
||||||
|
@ -100,6 +109,7 @@ export type CommunicationMessages =
|
||||||
| SetFrontmatterInfoMessage
|
| SetFrontmatterInfoMessage
|
||||||
| OnHeightChangeMessage
|
| OnHeightChangeMessage
|
||||||
| OnWordCountCalculatedMessage
|
| OnWordCountCalculatedMessage
|
||||||
|
| ImageUploadMessage
|
||||||
|
|
||||||
export type EditorToRendererMessageType =
|
export type EditorToRendererMessageType =
|
||||||
| CommunicationMessageType.SET_MARKDOWN_CONTENT
|
| CommunicationMessageType.SET_MARKDOWN_CONTENT
|
||||||
|
@ -118,6 +128,7 @@ export type RendererToEditorMessageType =
|
||||||
| CommunicationMessageType.IMAGE_CLICKED
|
| CommunicationMessageType.IMAGE_CLICKED
|
||||||
| CommunicationMessageType.ON_HEIGHT_CHANGE
|
| CommunicationMessageType.ON_HEIGHT_CHANGE
|
||||||
| CommunicationMessageType.ON_WORD_COUNT_CALCULATED
|
| CommunicationMessageType.ON_WORD_COUNT_CALCULATED
|
||||||
|
| CommunicationMessageType.IMAGE_UPLOAD
|
||||||
|
|
||||||
export enum RendererType {
|
export enum RendererType {
|
||||||
DOCUMENT = 'document',
|
DOCUMENT = 'document',
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import { store } from '..'
|
import { store } from '..'
|
||||||
import type { NoteDto } from '../../api/notes/types'
|
import type { NoteDto } from '../../api/notes/types'
|
||||||
import type {
|
import type {
|
||||||
|
ReplaceInMarkdownContentAction,
|
||||||
SetNoteDetailsFromServerAction,
|
SetNoteDetailsFromServerAction,
|
||||||
SetNoteDocumentContentAction,
|
SetNoteDocumentContentAction,
|
||||||
UpdateNoteTitleByFirstHeadingAction,
|
UpdateNoteTitleByFirstHeadingAction,
|
||||||
|
@ -49,6 +50,7 @@ export const updateNoteTitleByFirstHeading = (firstHeading?: string): void => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes a checkbox state in the note document content. Triggered when a checkbox in the rendering is clicked.
|
* Changes a checkbox state in the note document content. Triggered when a checkbox in the rendering is clicked.
|
||||||
|
*
|
||||||
* @param lineInDocumentContent The line in the document content to change.
|
* @param lineInDocumentContent The line in the document content to change.
|
||||||
* @param checked true if the checkbox is checked, false otherwise.
|
* @param checked true if the checkbox is checked, false otherwise.
|
||||||
*/
|
*/
|
||||||
|
@ -59,3 +61,17 @@ export const setCheckboxInMarkdownContent = (lineInDocumentContent: number, chec
|
||||||
changedLine: lineInDocumentContent
|
changedLine: lineInDocumentContent
|
||||||
} as UpdateTaskListCheckboxAction)
|
} as UpdateTaskListCheckboxAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a string in the markdown content in the global application state.
|
||||||
|
*
|
||||||
|
* @param replaceable The string that should be replaced
|
||||||
|
* @param replacement The replacement for the replaceable
|
||||||
|
*/
|
||||||
|
export const replaceInMarkdownContent = (replaceable: string, replacement: string): void => {
|
||||||
|
store.dispatch({
|
||||||
|
type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT,
|
||||||
|
placeholder: replaceable,
|
||||||
|
replacement
|
||||||
|
} as ReplaceInMarkdownContentAction)
|
||||||
|
}
|
||||||
|
|
|
@ -28,11 +28,29 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||||
return buildStateFromServerDto(action.dto)
|
return buildStateFromServerDto(action.dto)
|
||||||
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
|
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
|
||||||
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
|
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
|
||||||
|
case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT:
|
||||||
|
return buildStateFromDocumentContentReplacement(state, action.placeholder, action.replacement)
|
||||||
default:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link NoteDetails} redux state with a modified markdown content.
|
||||||
|
*
|
||||||
|
* @param state The previous redux state
|
||||||
|
* @param replaceable The string that should be replaced in the old markdown content
|
||||||
|
* @param replacement The string that should replace the replaceable
|
||||||
|
* @return An updated {@link NoteDetails} redux state
|
||||||
|
*/
|
||||||
|
const buildStateFromDocumentContentReplacement = (
|
||||||
|
state: NoteDetails,
|
||||||
|
replaceable: string,
|
||||||
|
replacement: string
|
||||||
|
): NoteDetails => {
|
||||||
|
return buildStateFromMarkdownContentUpdate(state, state.markdownContent.replaceAll(replaceable, replacement))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||||
* @param dto The first DTO received from the API containing the relevant information about the note.
|
* @param dto The first DTO received from the API containing the relevant information about the note.
|
||||||
|
|
|
@ -11,7 +11,8 @@ export enum NoteDetailsActionType {
|
||||||
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
||||||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||||
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox'
|
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox',
|
||||||
|
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NoteDetailsActions =
|
export type NoteDetailsActions =
|
||||||
|
@ -19,6 +20,7 @@ export type NoteDetailsActions =
|
||||||
| SetNoteDetailsFromServerAction
|
| SetNoteDetailsFromServerAction
|
||||||
| UpdateNoteTitleByFirstHeadingAction
|
| UpdateNoteTitleByFirstHeadingAction
|
||||||
| UpdateTaskListCheckboxAction
|
| UpdateTaskListCheckboxAction
|
||||||
|
| ReplaceInMarkdownContentAction
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action for updating the document content of the currently loaded note.
|
* Action for updating the document content of the currently loaded note.
|
||||||
|
@ -52,3 +54,9 @@ export interface UpdateTaskListCheckboxAction extends Action<NoteDetailsActionTy
|
||||||
changedLine: number
|
changedLine: number
|
||||||
checkboxChecked: boolean
|
checkboxChecked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplaceInMarkdownContentAction extends Action<NoteDetailsActionType> {
|
||||||
|
type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT
|
||||||
|
placeholder: string
|
||||||
|
replacement: string
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue