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:
Tilman Vatteroth 2021-12-11 15:34:33 +01:00 committed by GitHub
parent 58fecc0b3a
commit d4251519e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 908 additions and 72 deletions

View file

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

View file

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

View file

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

View file

@ -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": {

View file

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

View file

@ -18,3 +18,5 @@ export const supportedMimeTypes: string[] = [
'image/tiff',
'image/webp'
]
export const acceptedMimeTypes = supportedMimeTypes.join(', ')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -46,7 +46,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
baseUrl,
currentLineMarkers,
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []),
lineOffset,
lineOffset ?? 0,
onTaskCheckedChange,
onImageClick,
onTocChange

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,7 +45,7 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
baseUrl,
undefined,
useMemo(() => [new RevealMarkdownExtension()], []),
lineOffset,
lineOffset ?? 0,
onTaskCheckedChange,
onImageClick,
onTocChange

View file

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

View file

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

View file

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

View file

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

View file

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