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

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