From d4251519e2feda9608c57bffc7ab95ad6bb7a332 Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Sat, 11 Dec 2021 15:34:33 +0100 Subject: [PATCH] Add image placeholder and upload indicating frame (#1666) Signed-off-by: Tilman Vatteroth Co-authored-by: Philip Molares --- CHANGELOG.md | 2 + cypress/integration/autocompletion.spec.ts | 60 ++++----- cypress/integration/fileUpload.spec.ts | 10 +- locales/en.json | 17 ++- src/api/media/index.ts | 9 +- .../common/upload-image-mimetypes.ts | 2 + .../editor-page/editor-pane/editor-pane.tsx | 5 +- .../find-regex-match-in-text.test.ts | 38 ++++++ .../editor-pane/find-regex-match-in-text.ts | 26 ++++ .../use-on-image-upload-from-renderer.ts | 120 ++++++++++++++++++ .../tool-bar/upload-image-button.tsx | 4 +- .../editor-page/editor-pane/upload-handler.ts | 49 +++++-- .../document-markdown-renderer.tsx | 2 +- .../use-convert-markdown-to-react-dom.ts | 2 + .../hooks/use-markdown-extensions.ts | 10 +- .../add-line-to-placeholder-image-tags.ts | 35 +++++ .../hooks/use-on-image-upload.ts | 57 +++++++++ .../hooks/use-placeholder-size-style.ts | 27 ++++ .../image-placeholder-markdown-extension.ts | 30 +++++ .../image-placeholder-replacer.tsx | 44 +++++++ .../image-placeholder/image-placeholder.scss | 27 ++++ .../image-placeholder/image-placeholder.tsx | 112 ++++++++++++++++ .../utils/build-placeholder-size-css.test.ts | 67 ++++++++++ .../utils/build-placeholder-size-css.ts | 66 ++++++++++ .../linemarker-markdown-extension.ts | 2 +- .../task-list/task-list-markdown-extension.ts | 2 +- .../task-list/task-list-replacer.tsx | 4 +- .../upload-indicating-frame.tsx | 37 ++++++ ...dicating-image-frame-markdown-extension.ts | 18 +++ ...upload-indicating-image-frame-replacer.tsx | 23 ++++ .../replace-components/component-replacer.ts | 7 + .../slideshow-markdown-renderer.tsx | 2 +- .../utils/node-to-react-transformer.tsx | 7 + .../rendering-message.ts | 13 +- src/redux/note-details/methods.ts | 16 +++ src/redux/note-details/reducer.ts | 18 +++ src/redux/note-details/types.ts | 10 +- 37 files changed, 908 insertions(+), 72 deletions(-) create mode 100644 src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts create mode 100644 src/components/editor-page/editor-pane/find-regex-match-in-text.ts create mode 100644 src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/add-line-to-placeholder-image-tags.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-on-image-upload.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-placeholder-size-style.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-markdown-extension.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-replacer.tsx create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.scss create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.tsx create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.test.ts create mode 100644 src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.ts create mode 100644 src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-frame.tsx create mode 100644 src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension.ts create mode 100644 src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-replacer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c472aec97..86625d22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cypress/integration/autocompletion.spec.ts b/cypress/integration/autocompletion.spec.ts index f7ecf4fdc..e4462608e 100644 --- a/cypress/integration/autocompletion.spec.ts +++ b/cypress/integration/autocompletion.spec.ts @@ -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', '') // after selecting the hint, the last line of the inserted suggestion is active + cy.get('.CodeMirror-activeline').contains('') // after selecting the hint, the last line of the inserted suggestion is active cy.getMarkdownBody().find('details').should('exist') }) it('via doubleclick', () => { cy.setCodemirrorContent(' 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('details').should('exist') }) }) diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index 3737864c1..57498437d 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -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}`) }) }) diff --git a/locales/en.json b/locales/en.json index 7ec833274..dd0bbc97c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": { diff --git a/src/api/media/index.ts b/src/api/media/index.ts index 0d73c43c0..624c20272 100644 --- a/src/api/media/index.ts +++ b/src/api/media/index.ts @@ -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 { + setTimeout(resolve, 3000) + }) + } + expectResponseCode(response, isMockMode() ? 200 : 201) return (await response.json()) as Promise } diff --git a/src/components/common/upload-image-mimetypes.ts b/src/components/common/upload-image-mimetypes.ts index 7a837ff3d..fb9c193dd 100644 --- a/src/components/common/upload-image-mimetypes.ts +++ b/src/components/common/upload-image-mimetypes.ts @@ -18,3 +18,5 @@ export const supportedMimeTypes: string[] = [ 'image/tiff', 'image/webp' ] + +export const acceptedMimeTypes = supportedMimeTypes.join(', ') diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index f61cf20f1..3135cf726 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -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 = ({ scrollState, onScroll, onMak const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo() + useOnImageUploadFromRenderer(editor) + const onEditorDidMount = useCallback( (mountedEditor: Editor) => { updateStatusBarInfo(mountedEditor) diff --git a/src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts b/src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts new file mode 100644 index 000000000..862c6b054 --- /dev/null +++ b/src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts @@ -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() + }) +}) diff --git a/src/components/editor-page/editor-pane/find-regex-match-in-text.ts b/src/components/editor-page/editor-pane/find-regex-match-in-text.ts new file mode 100644 index 000000000..c1d82a625 --- /dev/null +++ b/src/components/editor-page/editor-pane/find-regex-match-in-text.ts @@ -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 + } +} diff --git a/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts b/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts new file mode 100644 index 000000000..92290fc48 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts @@ -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 } +} diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx b/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx index 81c3369ca..514a424ac 100644 --- a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx @@ -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 = ({ editor }) => { const { t } = useTranslation() const clickRef = useRef<() => void>() diff --git a/src/components/editor-page/editor-pane/upload-handler.ts b/src/components/editor-page/editor-pane/upload-handler.ts index 7a63eaa0e..dfd05f0da 100644 --- a/src/components/editor-page/editor-pane/upload-handler.ts +++ b/src/components/editor-page/editor-pane/upload-handler.ts @@ -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]()`) }) } diff --git a/src/components/markdown-renderer/document-markdown-renderer.tsx b/src/components/markdown-renderer/document-markdown-renderer.tsx index 3eafbe0a3..7eb40cd88 100644 --- a/src/components/markdown-renderer/document-markdown-renderer.tsx +++ b/src/components/markdown-renderer/document-markdown-renderer.tsx @@ -46,7 +46,7 @@ export const DocumentMarkdownRenderer: React.FC = baseUrl, currentLineMarkers, useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []), - lineOffset, + lineOffset ?? 0, onTaskCheckedChange, onImageClick, onTocChange diff --git a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts index 439cc5439..2b89e50dc 100644 --- a/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts +++ b/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.ts @@ -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) diff --git a/src/components/markdown-renderer/hooks/use-markdown-extensions.ts b/src/components/markdown-renderer/hooks/use-markdown-extensions.ts index 9b84d1a7b..8d0bb95ba 100644 --- a/src/components/markdown-renderer/hooks/use-markdown-extensions.ts +++ b/src/components/markdown-renderer/hooks/use-markdown-extensions.ts @@ -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 | 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(), diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/add-line-to-placeholder-image-tags.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/add-line-to-placeholder-image-tags.ts new file mode 100644 index 000000000..4cc801294 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/add-line-to-placeholder-image-tags.ts @@ -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 + }) +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-on-image-upload.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-on-image-upload.ts new file mode 100644 index 000000000..9f14be0b4 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-on-image-upload.ts @@ -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 => { + 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] + ) +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-placeholder-size-style.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-placeholder-size-style.ts new file mode 100644 index 000000000..3ca8c26c7 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/hooks/use-placeholder-size-style.ts @@ -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]) +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-markdown-extension.ts new file mode 100644 index 000000000..b7fa8c8c8 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-markdown-extension.ts @@ -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)] + } +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-replacer.tsx b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-replacer.tsx new file mode 100644 index 000000000..d04772ec2 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder-replacer.tsx @@ -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() + + constructor(private lineOffset: number) { + super() + } + + reset(): void { + this.countPerSourceLine = new Map() + } + + 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 ( + + ) + } + } +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.scss b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.scss new file mode 100644 index 000000000..9dbed0530 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.scss @@ -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; + } +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.tsx b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.tsx new file mode 100644 index 000000000..117781176 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/image-placeholder.tsx @@ -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 = ({ + alt, + title, + width, + height, + lineIndex, + placeholderIndexInLine +}) => { + useTranslation() + const fileInputReference = useRef(null) + const onImageUpload = useOnImageUpload(lineIndex, placeholderIndexInLine) + + const [showDragStatus, setShowDragStatus] = useState(false) + + const onDropHandler = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + if (event?.dataTransfer?.files?.length > 0) { + onImageUpload(event.dataTransfer.files[0]) + } + }, + [onImageUpload] + ) + + const onDragOverHandler = useCallback((event: React.DragEvent) => { + event.preventDefault() + setShowDragStatus(true) + }, []) + + const onDragLeave = useCallback(() => { + setShowDragStatus(false) + }, []) + + const onChangeHandler = useCallback( + (event: React.ChangeEvent) => { + 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 ( + + +
+
+ + + + {alt ?? title ?? ''} +
+
+ +
+ ) +} diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.test.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.test.ts new file mode 100644 index 000000000..cb467ccb7 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.test.ts @@ -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]) + }) +}) diff --git a/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.ts b/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.ts new file mode 100644 index 000000000..2621e6c12 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/image-placeholder/utils/build-placeholder-size-css.ts @@ -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] + } +} diff --git a/src/components/markdown-renderer/markdown-extension/linemarker/linemarker-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/linemarker/linemarker-markdown-extension.ts index d03b97b3b..309c591a9 100644 --- a/src/components/markdown-renderer/markdown-extension/linemarker/linemarker-markdown-extension.ts +++ b/src/components/markdown-renderer/markdown-extension/linemarker/linemarker-markdown-extension.ts @@ -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() } diff --git a/src/components/markdown-renderer/markdown-extension/task-list/task-list-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/task-list/task-list-markdown-extension.ts index 3d2d1fc41..9d65681f5 100644 --- a/src/components/markdown-renderer/markdown-extension/task-list/task-list-markdown-extension.ts +++ b/src/components/markdown-renderer/markdown-extension/task-list/task-list-markdown-extension.ts @@ -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() } diff --git a/src/components/markdown-renderer/markdown-extension/task-list/task-list-replacer.tsx b/src/components/markdown-renderer/markdown-extension/task-list/task-list-replacer.tsx index 4f00f4070..b6e3093b7 100644 --- a/src/components/markdown-renderer/markdown-extension/task-list/task-list-replacer.tsx +++ b/src/components/markdown-renderer/markdown-extension/task-list/task-list-replacer.tsx @@ -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) diff --git a/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-frame.tsx b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-frame.tsx new file mode 100644 index 000000000..72a99de57 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-frame.tsx @@ -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 = ({ width, height }) => { + const containerStyle = usePlaceholderSizeStyle(width, height) + useTranslation() + + return ( + + + + + + + ) +} diff --git a/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension.ts b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension.ts new file mode 100644 index 000000000..c24c4bcc7 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension.ts @@ -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()] + } +} diff --git a/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-replacer.tsx b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-replacer.tsx new file mode 100644 index 000000000..6abba4222 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/upload-indicating-image-frame/upload-indicating-image-frame-replacer.tsx @@ -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 + } + } +} diff --git a/src/components/markdown-renderer/replace-components/component-replacer.ts b/src/components/markdown-renderer/replace-components/component-replacer.ts index 631bd7181..cc4247cbe 100644 --- a/src/components/markdown-renderer/replace-components/component-replacer.ts +++ b/src/components/markdown-renderer/replace-components/component-replacer.ts @@ -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. * diff --git a/src/components/markdown-renderer/slideshow-markdown-renderer.tsx b/src/components/markdown-renderer/slideshow-markdown-renderer.tsx index 304f795eb..ad1537e03 100644 --- a/src/components/markdown-renderer/slideshow-markdown-renderer.tsx +++ b/src/components/markdown-renderer/slideshow-markdown-renderer.tsx @@ -45,7 +45,7 @@ export const SlideshowMarkdownRenderer: React.FC [new RevealMarkdownExtension()], []), - lineOffset, + lineOffset ?? 0, onTaskCheckedChange, onImageClick, onTocChange diff --git a/src/components/markdown-renderer/utils/node-to-react-transformer.tsx b/src/components/markdown-renderer/utils/node-to-react-transformer.tsx index 86031bdba..b93684be8 100644 --- a/src/components/markdown-renderer/utils/node-to-react-transformer.tsx +++ b/src/components/markdown-renderer/utils/node-to-react-transformer.tsx @@ -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. * diff --git a/src/components/render-page/window-post-message-communicator/rendering-message.ts b/src/components/render-page/window-post-message-communicator/rendering-message.ts index e6f8db37d..646c437e7 100644 --- a/src/components/render-page/window-post-message-communicator/rendering-message.ts +++ b/src/components/render-page/window-post-message-communicator/rendering-message.ts @@ -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', diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index fed5eef1b..22de95492 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -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) +} diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index 327f72ea5..c779b1cd6 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -28,11 +28,29 @@ export const NoteDetailsReducer: Reducer = ( 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. diff --git a/src/redux/note-details/types.ts b/src/redux/note-details/types.ts index f8f4a1fdd..4174c9d07 100644 --- a/src/redux/note-details/types.ts +++ b/src/redux/note-details/types.ts @@ -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 { + type: NoteDetailsActionType.REPLACE_IN_MARKDOWN_CONTENT + placeholder: string + replacement: string +}