mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05: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.
|
||||
- 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.
|
||||
- Image tags with placeholder urls (`https://`) will be replaced with a placeholder frame.
|
||||
- Images that are currently uploading will be rendered as "uploading".
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
@ -16,16 +16,16 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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(3) > .CodeMirror-line > span > span').should('have.text', '```')
|
||||
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().findById('highlighted-code-block').should('exist')
|
||||
})
|
||||
it('via doubleclick', () => {
|
||||
cy.setCodemirrorContent('```')
|
||||
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 > span > span').should('have.text', '```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(1) > .CodeMirror-line').contains('```abnf')
|
||||
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line').contains('```')
|
||||
cy.getMarkdownBody().findById('highlighted-code-block').should('exist')
|
||||
})
|
||||
})
|
||||
|
@ -36,17 +36,17 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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(3) > .CodeMirror-line > span > span').should('have.text', '::: ')
|
||||
cy.getMarkdownBody().find('div.alert').should('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 > li').first().dblclick()
|
||||
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(3) > .CodeMirror-line > span > span').should('have.text', '::: ')
|
||||
cy.getMarkdownBody().find('div.alert').should('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')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -57,13 +57,13 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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', () => {
|
||||
cy.setCodemirrorContent(':hedg')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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('@codeinput').type('{enter}')
|
||||
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', () => {
|
||||
cy.setCodemirrorContent(':fa-face')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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('@codeinput').type('{enter}')
|
||||
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 ')
|
||||
})
|
||||
it('via doubleclick', () => {
|
||||
cy.setCodemirrorContent('#')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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 ')
|
||||
})
|
||||
})
|
||||
|
@ -108,23 +108,15 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
cy.get('.CodeMirror-hints').should('not.exist')
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '![image alt](https:// "title")')
|
||||
cy.getMarkdownBody()
|
||||
.find('p > img')
|
||||
.should('have.attr', 'alt', 'image alt')
|
||||
.should('have.attr', 'src', 'https://')
|
||||
.should('have.attr', 'title', 'title')
|
||||
cy.get('.CodeMirror-activeline').contains('![image alt](https:// "title")')
|
||||
cy.getMarkdownBody().find('.image-drop').should('exist')
|
||||
})
|
||||
it('via doubleclick', () => {
|
||||
cy.setCodemirrorContent('!')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
cy.get('.CodeMirror-hints').should('not.exist')
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', '![image alt](https:// "title")')
|
||||
cy.getMarkdownBody()
|
||||
.find('p > img')
|
||||
.should('have.attr', 'alt', 'image alt')
|
||||
.should('have.attr', 'src', 'https://')
|
||||
.should('have.attr', 'title', 'title')
|
||||
cy.get('.CodeMirror-activeline').contains('![image alt](https:// "title")')
|
||||
cy.getMarkdownBody().find('.image-drop').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -134,7 +126,7 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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()
|
||||
.find('p > a')
|
||||
.should('have.text', 'link text')
|
||||
|
@ -145,7 +137,7 @@ describe('Autocompletion works for', () => {
|
|||
cy.setCodemirrorContent('[')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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()
|
||||
.find('p > a')
|
||||
.should('have.text', 'link text')
|
||||
|
@ -160,14 +152,14 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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')
|
||||
})
|
||||
it('via doubleclick', () => {
|
||||
cy.setCodemirrorContent('{')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
@ -178,14 +170,14 @@ describe('Autocompletion works for', () => {
|
|||
cy.get('.CodeMirror-hints').should('exist')
|
||||
cy.get('@codeinput').type('{enter}')
|
||||
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')
|
||||
})
|
||||
it('via doubleclick', () => {
|
||||
cy.setCodemirrorContent('<d')
|
||||
cy.get('.CodeMirror-hints > li').first().dblclick()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('File upload', () => {
|
|||
it('via button', () => {
|
||||
cy.getById('editor-toolbar-upload-image-button').click()
|
||||
cy.getById('editor-toolbar-upload-image-input').attachFile({ filePath: 'demo.png', mimeType: 'image/png' })
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `![](${imageUrl})`)
|
||||
cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`)
|
||||
})
|
||||
|
||||
it('via paste', () => {
|
||||
|
@ -54,7 +54,7 @@ describe('File upload', () => {
|
|||
}
|
||||
}
|
||||
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `![](${imageUrl})`)
|
||||
cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -68,7 +68,7 @@ describe('File upload', () => {
|
|||
}
|
||||
cy.get('.CodeMirror-scroll').trigger('dragenter', dropEvent)
|
||||
cy.get('.CodeMirror-scroll').trigger('drop', dropEvent)
|
||||
cy.get('.CodeMirror-activeline > .CodeMirror-line > span').should('have.text', `![](${imageUrl})`)
|
||||
cy.get('.CodeMirror-activeline').contains(`![](${imageUrl})`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -87,7 +87,7 @@ describe('File upload', () => {
|
|||
cy.fixture('demo.png').then(() => {
|
||||
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', () => {
|
||||
|
@ -98,6 +98,6 @@ describe('File upload', () => {
|
|||
}
|
||||
}
|
||||
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": {
|
||||
"previewHoverText": "Click to load content from {{target}}"
|
||||
},
|
||||
"uploadIndicator": {
|
||||
"uploadMessage": "Uploading file"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
|
@ -202,8 +205,12 @@
|
|||
},
|
||||
"editor": {
|
||||
"upload": {
|
||||
"uploadFile": "Uploading file...{{fileName}}",
|
||||
"dropImage": "Drop Image to insert"
|
||||
"uploadFile": {
|
||||
"withoutDescription": "Uploading file {{fileName}}",
|
||||
"withDescription": "Uploading file {{fileName}} - {{description}}"
|
||||
},
|
||||
"dropImage": "Drop Image to insert",
|
||||
"failed": "Error while uploading {{fileName}}"
|
||||
},
|
||||
"untitledNote": "Untitled",
|
||||
"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": {
|
||||
"clickToLoad": "Click to load"
|
||||
"clickToLoad": "Click to load",
|
||||
"placeholderImage": {
|
||||
"placeholderText": "Placeholder",
|
||||
"upload": "Upload image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"views": {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 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'
|
||||
|
||||
export interface ImageProxyResponse {
|
||||
|
@ -37,6 +37,13 @@ export const uploadFile = async (noteId: string, media: Blob): Promise<UploadedM
|
|||
method: isMockMode() ? 'GET' : 'POST',
|
||||
body: isMockMode() ? undefined : media
|
||||
})
|
||||
|
||||
if (isMockMode() && !isTestMode()) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
expectResponseCode(response, isMockMode() ? 200 : 201)
|
||||
return (await response.json()) as Promise<UploadedMedia>
|
||||
}
|
||||
|
|
|
@ -18,3 +18,5 @@ export const supportedMimeTypes: string[] = [
|
|||
'image/tiff',
|
||||
'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 { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
|
||||
|
||||
const onChange = (editor: Editor) => {
|
||||
const searchTerm = findWordAtCursor(editor)
|
||||
for (const hinter of allHinters) {
|
||||
const searchTerm = findWordAtCursor(editor)
|
||||
if (hinter.wordRegExp.test(searchTerm.text)) {
|
||||
editor.showHint({
|
||||
hint: hinter.hint,
|
||||
|
@ -55,6 +56,8 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
|
||||
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
|
||||
|
||||
useOnImageUploadFromRenderer(editor)
|
||||
|
||||
const onEditorDidMount = useCallback(
|
||||
(mountedEditor: Editor) => {
|
||||
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 { UploadInput } from '../../sidebar/upload-input'
|
||||
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'
|
||||
|
||||
export interface UploadImageButtonProps {
|
||||
editor?: Editor
|
||||
}
|
||||
|
||||
const acceptedMimeTypes = supportedMimeTypes.join(', ')
|
||||
|
||||
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
|
||||
const { t } = useTranslation()
|
||||
const clickRef = useRef<() => void>()
|
||||
|
|
|
@ -4,36 +4,57 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import { t } from 'i18next'
|
||||
import type { Editor, Position } from 'codemirror'
|
||||
import { uploadFile } from '../../../api/media'
|
||||
import { store } from '../../../redux'
|
||||
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')
|
||||
|
||||
export const handleUpload = (file: File, editor: Editor): void => {
|
||||
/**
|
||||
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param editor The editor that should be used to show the progress
|
||||
* @param cursorFrom The position where the progress message should be placed
|
||||
* @param cursorTo An optional position that should be used to replace content in the editor
|
||||
* @param imageDescription The text that should be used in the description part of the resulting image tag
|
||||
* @param 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) {
|
||||
return
|
||||
}
|
||||
if (!supportedMimeTypes.includes(file.type)) {
|
||||
// this mimetype is not supported
|
||||
return
|
||||
}
|
||||
const cursor = editor.getCursor()
|
||||
const uploadPlaceholder = `![${t('editor.upload.uploadFile', { fileName: file.name })}]()`
|
||||
const randomId = Math.random().toString(36).slice(7)
|
||||
const uploadFileInfo =
|
||||
imageDescription !== undefined
|
||||
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: imageDescription })
|
||||
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
||||
|
||||
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
|
||||
const noteId = store.getState().noteDetails.id
|
||||
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)
|
||||
.then(({ link }) => {
|
||||
insertCode(`![](${link})`)
|
||||
insertCode(`![${imageDescription ?? ''}](${link}${additionalUrlText ?? ''})`)
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
log.error('error while uploading file', error)
|
||||
insertCode('')
|
||||
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
|
||||
insertCode(`![upload of ${file.name} failed]()`)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
|
|||
baseUrl,
|
||||
currentLineMarkers,
|
||||
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []),
|
||||
lineOffset,
|
||||
lineOffset ?? 0,
|
||||
onTaskCheckedChange,
|
||||
onImageClick,
|
||||
onTocChange
|
||||
|
|
|
@ -78,6 +78,8 @@ export const useConvertMarkdownToReactDom = (
|
|||
return useMemo(() => {
|
||||
const html = markdownIt.render(markdownCode)
|
||||
|
||||
htmlToReactTransformer.resetReplacers()
|
||||
|
||||
return convertHtmlToReact(html, {
|
||||
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
|
||||
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 { MarkdownExtension } from '../markdown-extension/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.
|
||||
|
@ -57,7 +59,7 @@ export const useMarkdownExtensions = (
|
|||
baseUrl: string,
|
||||
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
|
||||
additionalExtensions: MarkdownExtension[],
|
||||
lineOffset?: number,
|
||||
lineOffset: number,
|
||||
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void,
|
||||
onImageClick?: ImageClickHandler,
|
||||
onTocChange?: (ast?: TocAst) => void
|
||||
|
@ -71,10 +73,12 @@ export const useMarkdownExtensions = (
|
|||
new VegaLiteMarkdownExtension(),
|
||||
new MarkmapMarkdownExtension(),
|
||||
new LinemarkerMarkdownExtension(
|
||||
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined,
|
||||
lineOffset
|
||||
lineOffset,
|
||||
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined
|
||||
),
|
||||
new IframeCapsuleMarkdownExtension(),
|
||||
new ImagePlaceholderMarkdownExtension(lineOffset),
|
||||
new UploadIndicatingImageFrameMarkdownExtension(),
|
||||
new GistMarkdownExtension(),
|
||||
new YoutubeMarkdownExtension(),
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
export class TaskListMarkdownExtension extends MarkdownExtension {
|
||||
constructor(private frontmatterLinesToSkip?: number, private onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||
constructor(private frontmatterLinesToSkip: number, private onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||
super()
|
||||
}
|
||||
|
||||
|
|
|
@ -18,10 +18,10 @@ export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean
|
|||
export class TaskListReplacer extends ComponentReplacer {
|
||||
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
|
||||
|
||||
constructor(frontmatterLinesToSkip?: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||
constructor(frontmatterLinesToSkip: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
|
||||
super()
|
||||
this.onTaskCheckedChange = (lineInMarkdown, checked) => {
|
||||
if (onTaskCheckedChange === undefined || frontmatterLinesToSkip === undefined) {
|
||||
if (onTaskCheckedChange === undefined) {
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
|
|
@ -45,7 +45,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
|
|||
baseUrl,
|
||||
undefined,
|
||||
useMemo(() => [new RevealMarkdownExtension()], []),
|
||||
lineOffset,
|
||||
lineOffset ?? 0,
|
||||
onTaskCheckedChange,
|
||||
onImageClick,
|
||||
onTocChange
|
||||
|
|
|
@ -31,6 +31,13 @@ export class NodeToReactTransformer {
|
|||
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.
|
||||
*
|
||||
|
|
|
@ -19,7 +19,8 @@ export enum CommunicationMessageType {
|
|||
SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION',
|
||||
GET_WORD_COUNT = 'GET_WORD_COUNT',
|
||||
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 {
|
||||
|
@ -37,6 +38,14 @@ export interface ImageDetails {
|
|||
title?: string
|
||||
}
|
||||
|
||||
export interface ImageUploadMessage {
|
||||
type: CommunicationMessageType.IMAGE_UPLOAD
|
||||
dataUri: string
|
||||
fileName: string
|
||||
lineIndex?: number
|
||||
placeholderIndexInLine?: number
|
||||
}
|
||||
|
||||
export interface SetBaseUrlMessage {
|
||||
type: CommunicationMessageType.SET_BASE_CONFIGURATION
|
||||
baseConfiguration: BaseConfiguration
|
||||
|
@ -100,6 +109,7 @@ export type CommunicationMessages =
|
|||
| SetFrontmatterInfoMessage
|
||||
| OnHeightChangeMessage
|
||||
| OnWordCountCalculatedMessage
|
||||
| ImageUploadMessage
|
||||
|
||||
export type EditorToRendererMessageType =
|
||||
| CommunicationMessageType.SET_MARKDOWN_CONTENT
|
||||
|
@ -118,6 +128,7 @@ export type RendererToEditorMessageType =
|
|||
| CommunicationMessageType.IMAGE_CLICKED
|
||||
| CommunicationMessageType.ON_HEIGHT_CHANGE
|
||||
| CommunicationMessageType.ON_WORD_COUNT_CALCULATED
|
||||
| CommunicationMessageType.IMAGE_UPLOAD
|
||||
|
||||
export enum RendererType {
|
||||
DOCUMENT = 'document',
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { store } from '..'
|
||||
import type { NoteDto } from '../../api/notes/types'
|
||||
import type {
|
||||
ReplaceInMarkdownContentAction,
|
||||
SetNoteDetailsFromServerAction,
|
||||
SetNoteDocumentContentAction,
|
||||
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.
|
||||
*
|
||||
* @param lineInDocumentContent The line in the document content to change.
|
||||
* @param checked true if the checkbox is checked, false otherwise.
|
||||
*/
|
||||
|
@ -59,3 +61,17 @@ export const setCheckboxInMarkdownContent = (lineInDocumentContent: number, chec
|
|||
changedLine: lineInDocumentContent
|
||||
} 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)
|
||||
case NoteDetailsActionType.UPDATE_TASK_LIST_CHECKBOX:
|
||||
return buildStateFromTaskListUpdate(state, action.changedLine, action.checkboxChecked)
|
||||
case NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT:
|
||||
return buildStateFromDocumentContentReplacement(state, action.placeholder, action.replacement)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state with a modified markdown content.
|
||||
*
|
||||
* @param state The previous redux state
|
||||
* @param replaceable The string that should be replaced in the old markdown content
|
||||
* @param replacement The string that should replace the replaceable
|
||||
* @return An updated {@link NoteDetails} redux state
|
||||
*/
|
||||
const buildStateFromDocumentContentReplacement = (
|
||||
state: NoteDetails,
|
||||
replaceable: string,
|
||||
replacement: string
|
||||
): NoteDetails => {
|
||||
return buildStateFromMarkdownContentUpdate(state, state.markdownContent.replaceAll(replaceable, replacement))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||
* @param dto The first DTO received from the API containing the relevant information about the note.
|
||||
|
|
|
@ -11,7 +11,8 @@ export enum NoteDetailsActionType {
|
|||
SET_DOCUMENT_CONTENT = 'note-details/content/set',
|
||||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox'
|
||||
UPDATE_TASK_LIST_CHECKBOX = 'note-details/update-task-list-checkbox',
|
||||
REPLACE_IN_MARKDOWN_CONTENT = 'note-details/replace-in-markdown-content'
|
||||
}
|
||||
|
||||
export type NoteDetailsActions =
|
||||
|
@ -19,6 +20,7 @@ export type NoteDetailsActions =
|
|||
| SetNoteDetailsFromServerAction
|
||||
| UpdateNoteTitleByFirstHeadingAction
|
||||
| UpdateTaskListCheckboxAction
|
||||
| ReplaceInMarkdownContentAction
|
||||
|
||||
/**
|
||||
* Action for updating the document content of the currently loaded note.
|
||||
|
@ -52,3 +54,9 @@ export interface UpdateTaskListCheckboxAction extends Action<NoteDetailsActionTy
|
|||
changedLine: number
|
||||
checkboxChecked: boolean
|
||||
}
|
||||
|
||||
export interface ReplaceInMarkdownContentAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT
|
||||
placeholder: string
|
||||
replacement: string
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue