mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-23 11:37:02 -04:00
Add image placeholder and upload indicating frame (#1666)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de> Co-authored-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
parent
58fecc0b3a
commit
d4251519e2
37 changed files with 908 additions and 72 deletions
|
@ -23,10 +23,11 @@ import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
|
|||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
|
||||
|
||||
const onChange = (editor: Editor) => {
|
||||
const searchTerm = findWordAtCursor(editor)
|
||||
for (const hinter of allHinters) {
|
||||
const searchTerm = findWordAtCursor(editor)
|
||||
if (hinter.wordRegExp.test(searchTerm.text)) {
|
||||
editor.showHint({
|
||||
hint: hinter.hint,
|
||||
|
@ -55,6 +56,8 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
|
||||
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
|
||||
|
||||
useOnImageUploadFromRenderer(editor)
|
||||
|
||||
const onEditorDidMount = useCallback(
|
||||
(mountedEditor: Editor) => {
|
||||
updateStatusBarInfo(mountedEditor)
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { findRegexMatchInText } from './find-regex-match-in-text'
|
||||
|
||||
describe('find regex index in line', function () {
|
||||
it('finds the first occurrence', () => {
|
||||
const result = findRegexMatchInText('aba', /a/g, 0)
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result as RegExpMatchArray).index).toBe(0)
|
||||
})
|
||||
|
||||
it('finds another occurrence', () => {
|
||||
const result = findRegexMatchInText('aba', /a/g, 1)
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result as RegExpMatchArray).index).toBe(2)
|
||||
})
|
||||
|
||||
it('fails to find with a wrong regex', () => {
|
||||
const result = findRegexMatchInText('aba', /c/g, 0)
|
||||
expect(result).not.toBeDefined()
|
||||
})
|
||||
|
||||
it('fails to find with a negative wanted index', () => {
|
||||
const result = findRegexMatchInText('aba', /a/g, -1)
|
||||
expect(result).not.toBeDefined()
|
||||
})
|
||||
|
||||
it('fails to find if the index is to high', () => {
|
||||
const result = findRegexMatchInText('aba', /a/g, 100)
|
||||
expect(result).not.toBeDefined()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches a regex against a given text and returns the n-th match of the regex.
|
||||
*
|
||||
* @param text The text that should be searched through
|
||||
* @param regex The regex that should find matches in the text
|
||||
* @param matchIndex The index of the match to find
|
||||
* @return The regex match of the found occurrence or undefined if no match could be found
|
||||
*/
|
||||
export const findRegexMatchInText = (text: string, regex: RegExp, matchIndex: number): RegExpMatchArray | undefined => {
|
||||
if (matchIndex < 0) {
|
||||
return
|
||||
}
|
||||
let currentIndex = 0
|
||||
for (const match of text.matchAll(regex)) {
|
||||
if (currentIndex === matchIndex) {
|
||||
return match
|
||||
}
|
||||
currentIndex += 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEditorReceiveHandler } from '../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
|
||||
import type { ImageUploadMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import { useCallback } from 'react'
|
||||
import { store } from '../../../../redux'
|
||||
import { handleUpload } from '../upload-handler'
|
||||
import type { Editor, Position } from 'codemirror'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { findRegexMatchInText } from '../find-regex-match-in-text'
|
||||
import Optional from 'optional-js'
|
||||
|
||||
const log = new Logger('useOnImageUpload')
|
||||
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
|
||||
|
||||
/**
|
||||
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
|
||||
*
|
||||
* @param editor The {@link Editor codemirror editor} that should be used to change the markdown code
|
||||
*/
|
||||
export const useOnImageUploadFromRenderer = (editor: Editor | undefined): void => {
|
||||
useEditorReceiveHandler(
|
||||
CommunicationMessageType.IMAGE_UPLOAD,
|
||||
useCallback(
|
||||
(values: ImageUploadMessage) => {
|
||||
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
if (!dataUri.startsWith('data:image/')) {
|
||||
log.error('Received uri is no data uri and image!')
|
||||
return
|
||||
}
|
||||
|
||||
fetch(dataUri)
|
||||
.then((result) => result.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
const { cursorFrom, cursorTo, description, additionalText } = Optional.ofNullable(lineIndex)
|
||||
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
|
||||
.orElseGet(() => calculateInsertAtCurrentCursorPosition(editor))
|
||||
handleUpload(file, editor, cursorFrom, cursorTo, description, additionalText)
|
||||
})
|
||||
.catch((error) => log.error(error))
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
cursorFrom: Position
|
||||
cursorTo: Position
|
||||
description?: string
|
||||
additionalText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the start and end cursor position of the right image placeholder in the current markdown content.
|
||||
*
|
||||
* @param lineIndex The index of the line to change in the current markdown content.
|
||||
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
|
||||
* @return the calculated start and end position or undefined if no position could be determined
|
||||
*/
|
||||
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): ExtractResult | undefined => {
|
||||
const currentMarkdownContentLines = store.getState().noteDetails.markdownContent.split('\n')
|
||||
const lineAtIndex = currentMarkdownContentLines[lineIndex]
|
||||
if (lineAtIndex === undefined) {
|
||||
return
|
||||
}
|
||||
return findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], lineIndex, replacementIndexInLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the right image placeholder in the given line.
|
||||
*
|
||||
* @param line The line that should be inspected
|
||||
* @param lineIndex The index of the line in the document
|
||||
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
|
||||
* @return the calculated start and end position or undefined if no position could be determined
|
||||
*/
|
||||
const findImagePlaceholderInLine = (
|
||||
line: string,
|
||||
lineIndex: number,
|
||||
replacementIndexInLine = 0
|
||||
): ExtractResult | undefined => {
|
||||
const startOfImageTag = findRegexMatchInText(line, imageWithPlaceholderLinkRegex, replacementIndexInLine)
|
||||
if (startOfImageTag === undefined || startOfImageTag.index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
cursorFrom: {
|
||||
ch: startOfImageTag.index,
|
||||
line: lineIndex
|
||||
},
|
||||
cursorTo: {
|
||||
ch: startOfImageTag.index + startOfImageTag[0].length,
|
||||
line: lineIndex
|
||||
},
|
||||
description: startOfImageTag[1],
|
||||
additionalText: startOfImageTag[2]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a fallback position that is the current editor cursor position.
|
||||
* This wouldn't replace anything and only insert.
|
||||
*
|
||||
* @param editor The editor whose cursor should be used
|
||||
*/
|
||||
const calculateInsertAtCurrentCursorPosition = (editor: Editor): ExtractResult => {
|
||||
const editorCursor = editor.getCursor()
|
||||
return { cursorFrom: editorCursor, cursorTo: editorCursor }
|
||||
}
|
|
@ -11,15 +11,13 @@ import { useTranslation } from 'react-i18next'
|
|||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { UploadInput } from '../../sidebar/upload-input'
|
||||
import { handleUpload } from '../upload-handler'
|
||||
import { supportedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
export interface UploadImageButtonProps {
|
||||
editor?: Editor
|
||||
}
|
||||
|
||||
const acceptedMimeTypes = supportedMimeTypes.join(', ')
|
||||
|
||||
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
|
||||
const { t } = useTranslation()
|
||||
const clickRef = useRef<() => void>()
|
||||
|
|
|
@ -4,36 +4,57 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Editor } from 'codemirror'
|
||||
import { t } from 'i18next'
|
||||
import type { Editor, Position } from 'codemirror'
|
||||
import { uploadFile } from '../../../api/media'
|
||||
import { store } from '../../../redux'
|
||||
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { replaceInMarkdownContent } from '../../../redux/note-details/methods'
|
||||
import { t } from 'i18next'
|
||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||
|
||||
const log = new Logger('File Uploader Handler')
|
||||
|
||||
export const handleUpload = (file: File, editor: Editor): void => {
|
||||
/**
|
||||
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param editor The editor that should be used to show the progress
|
||||
* @param cursorFrom The position where the progress message should be placed
|
||||
* @param cursorTo An optional position that should be used to replace content in the editor
|
||||
* @param imageDescription The text that should be used in the description part of the resulting image tag
|
||||
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
|
||||
*/
|
||||
export const handleUpload = (
|
||||
file: File,
|
||||
editor: Editor,
|
||||
cursorFrom?: Position,
|
||||
cursorTo?: Position,
|
||||
imageDescription?: string,
|
||||
additionalUrlText?: string
|
||||
): void => {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (!supportedMimeTypes.includes(file.type)) {
|
||||
// this mimetype is not supported
|
||||
return
|
||||
}
|
||||
const cursor = editor.getCursor()
|
||||
const uploadPlaceholder = `![${t('editor.upload.uploadFile', { fileName: file.name })}]()`
|
||||
const randomId = Math.random().toString(36).slice(7)
|
||||
const uploadFileInfo =
|
||||
imageDescription !== undefined
|
||||
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: imageDescription })
|
||||
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
|
||||
|
||||
const uploadPlaceholder = ``
|
||||
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(``)
|
||||
insertCode(``)
|
||||
})
|
||||
.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]()`)
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue