fix: Move content into to frontend directory

Doing this BEFORE the merge prevents a lot of merge conflicts.

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-11-11 11:16:18 +01:00
parent 4e18ce38f3
commit 762a0a850e
No known key found for this signature in database
GPG key ID: B97799103358209B
1051 changed files with 0 additions and 35 deletions

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Ref } from 'react'
export interface CommonMarkdownRendererProps {
onFirstHeadingChange?: (firstHeading: string | undefined) => void
baseUrl: string
outerContainerRef?: Ref<HTMLDivElement>
newlinesAreBreaks?: boolean
lineOffset?: number
className?: string
markdownContentLines: string[]
}

View file

@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useMemo, useRef } from 'react'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import { useTranslation } from 'react-i18next'
import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions'
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
import { cypressId } from '../../utils/cypress-attribute'
import { HeadlineAnchorsMarkdownExtension } from './extensions/headline-anchors-markdown-extension'
import type { LineMarkerPosition } from './extensions/linemarker/types'
import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdown-it-plugin'
export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps {
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
}
/**
* Renders the note as normal document.
*
* @param className Additional class names directly given to the div
* @param markdownContentLines The markdown lines
* @param onFirstHeadingChange The callback to call if the first heading changes.
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
* @param onTocChange The callback to call if the toc changes.
* @param baseUrl The base url of the renderer
* @param onImageClick The callback to call if a image is clicked
* @param outerContainerRef A reference for the outer container
* @param newlinesAreBreaks If newlines are rendered as breaks or not
*/
export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({
className,
markdownContentLines,
onFirstHeadingChange,
onLineMarkerPositionChanged,
baseUrl,
outerContainerRef,
newlinesAreBreaks
}) => {
const markdownBodyRef = useRef<HTMLDivElement>(null)
const currentLineMarkers = useRef<LineMarkers[]>()
const extensions = useMarkdownExtensions(
baseUrl,
currentLineMarkers,
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], [])
)
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
useTranslation()
useCalculateLineMarkerPosition(
markdownBodyRef,
currentLineMarkers.current,
onLineMarkerPositionChanged,
markdownBodyRef.current?.offsetTop ?? 0
)
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
useEffect(() => {
extractFirstHeadline()
}, [extractFirstHeadline, markdownContentLines])
return (
<div ref={outerContainerRef} className={`position-relative`}>
<div
{...cypressId('markdown-body')}
ref={markdownBodyRef}
data-word-count-target={true}
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>
{markdownReactDom}
</div>
</div>
)
}
export default DocumentMarkdownRenderer

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import type { RuleCore } from 'markdown-it/lib/parser_core'
import { Optional } from '@mrdrogdrog/optional'
import { parseCodeBlockParameters } from './code-block-parameters'
const ruleName = 'code-highlighter'
/**
* Extracts the language name and additional flags from the code fence parameter and sets them as attributes in the token.
*
* @param state The current state of the processing {@link MarkdownIt} instance.
* @see MarkdownIt.RuleCore
*/
const rule: RuleCore = (state): void => {
state.tokens.forEach((token) => {
if (token.type === 'fence') {
const highlightInfos = parseCodeBlockParameters(token.info)
Optional.ofNullable(highlightInfos.language).ifPresent((language) =>
token.attrJoin('data-highlight-language', language)
)
Optional.ofNullable(highlightInfos.codeFenceParameters).ifPresent((language) =>
token.attrJoin('data-extra', language)
)
}
})
}
/**
* Adds the rule to the given {@link MarkdownIt markdown-it instance} if it hasn't been added yet.
*
* @param markdownIt The {@link MarkdownIt markdown-it instance} to which the rule should be added
*/
export const codeBlockMarkdownPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
if (markdownIt.core.ruler.getRules(ruleName).length === 0) {
markdownIt.core.ruler.push(ruleName, rule, { alt: [ruleName] })
}
}

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import { codeBlockMarkdownPlugin } from './code-block-markdown-plugin'
import type { ComponentReplacer } from '../../../replace-components/component-replacer'
import { MarkdownRendererExtension } from '../markdown-renderer-extension'
/**
* A {@link MarkdownRendererExtension markdown extension} that is used for code fence replacements.
*/
export abstract class CodeBlockMarkdownRendererExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
codeBlockMarkdownPlugin(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {
return []
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parseCodeBlockParameters } from './code-block-parameters'
describe('Code block parameter parsing', () => {
it('should detect just the language', () => {
const result = parseCodeBlockParameters('esperanto')
expect(result.language).toBe('esperanto')
expect(result.codeFenceParameters).toBe('')
})
it('should detect an empty string', () => {
const result = parseCodeBlockParameters('')
expect(result.language).toBe('')
expect(result.codeFenceParameters).toBe('')
})
it('should detect additional information after the language', () => {
const result = parseCodeBlockParameters('esperanto!!!!!')
expect(result.language).toBe('esperanto')
expect(result.codeFenceParameters).toBe('!!!!!')
})
it('should detect just the additional information if no language is given', () => {
const result = parseCodeBlockParameters('!!!!!esperanto')
expect(result.language).toBe('')
expect(result.codeFenceParameters).toBe('!!!!!esperanto')
})
it('should detect additional information if separated from the language with a space', () => {
const result = parseCodeBlockParameters('esperanto sed multe')
expect(result.language).toBe('esperanto')
expect(result.codeFenceParameters).toBe('sed multe')
})
it('should ignore spaces at the beginning and the end', () => {
const result = parseCodeBlockParameters(' esperanto sed multe ')
expect(result.language).toBe('esperanto')
expect(result.codeFenceParameters).toBe('sed multe')
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const codeFenceArguments = /^ *([\w-]*)(.*)$/
interface CodeBlockParameters {
language: string
codeFenceParameters: string
}
/**
* Parses the language name and additional parameters from a code block name input.
*
* @param text The text to parse
* @return The parsed parameters
*/
export const parseCodeBlockParameters = (text: string): CodeBlockParameters => {
const parsedText = codeFenceArguments.exec(text)
return {
language: parsedText?.[1].trim() ?? '',
codeFenceParameters: parsedText?.[2].trim() ?? ''
}
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { findLanguageByCodeBlockName } from './find-language-by-code-block-name'
import { Mock } from 'ts-mockery'
import type { LanguageDescription } from '@codemirror/language'
describe('filter language name', () => {
const mockedLanguage1 = Mock.of<LanguageDescription>({ name: 'Mocky', alias: ['mocky'] })
const mockedLanguage2 = Mock.of<LanguageDescription>({ name: 'Blocky', alias: ['blocky'] })
const mockedLanguage3 = Mock.of<LanguageDescription>({ name: 'Rocky', alias: ['rocky'] })
const mockedLanguage4 = Mock.of<LanguageDescription>({ name: 'Zocky', alias: ['zocky'] })
const mockedLanguages = [mockedLanguage1, mockedLanguage2, mockedLanguage3, mockedLanguage4]
it('should detect just the name of a language', () => {
expect(findLanguageByCodeBlockName(mockedLanguages, 'Mocky')).toBe(mockedLanguage1)
})
it('should detect the name of a language with parameters', () => {
expect(findLanguageByCodeBlockName(mockedLanguages, 'Blocky!!!')).toBe(mockedLanguage2)
})
it('should detect just the alias of a language', () => {
expect(findLanguageByCodeBlockName(mockedLanguages, 'rocky')).toBe(mockedLanguage3)
})
it('should detect the alias of a language with parameters', () => {
expect(findLanguageByCodeBlockName(mockedLanguages, 'zocky!!!')).toBe(mockedLanguage4)
})
it("shouldn't return a language if no match", () => {
expect(findLanguageByCodeBlockName(mockedLanguages, 'Docky')).toBe(null)
})
})

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Optional } from '@mrdrogdrog/optional'
import type { LanguageDescription } from '@codemirror/language'
import { parseCodeBlockParameters } from './code-block-parameters'
/**
* Finds the {@link LanguageDescription code mirror language descriptions} that matches the given language name or any alias.
* It ignores additional code block name parameters.
*
* @param languages The languages in which the description should be found
* @param inputLanguageName The input from the code block
* @return The found language description or null if no language could be found by name or alias
*/
export const findLanguageByCodeBlockName = (
languages: LanguageDescription[],
inputLanguageName: string
): LanguageDescription | null => {
return Optional.ofNullable(parseCodeBlockParameters(inputLanguageName).language)
.map((filteredLanguage) =>
languages.find((language) => language.name === filteredLanguage || language.alias.includes(filteredLanguage))
)
.orElse(null)
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type EventEmitter2 from 'eventemitter2'
import type MarkdownIt from 'markdown-it'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
/**
* Base class for Markdown extensions.
*/
export abstract class MarkdownRendererExtension {
constructor(protected readonly eventEmitter?: EventEmitter2) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public configureMarkdownIt(markdownIt: MarkdownIt): void {
return
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
return
}
public buildNodeProcessors(): NodeProcessor[] {
return []
}
public buildReplacers(): ComponentReplacer[] {
return []
}
public buildTagNameAllowList(): string[] {
return []
}
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import { Logger } from '../../../utils/logger'
import { isDevMode } from '../../../utils/test-modes'
const log = new Logger('DebuggerMarkdownExtension')
/**
* Adds console debug logging to the markdown rendering.
*/
export class DebuggerMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
if (isDevMode) {
markdownIt.core.ruler.push('printStateToConsole', (state) => {
log.debug('Current state', state)
return false
})
}
}
}

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Emoji Markdown Extension renders a fork awesome code 1`] = `
<div>
<p>
<i
class="fa fa-circle-thin"
/>
</p>
</div>
`;
exports[`Emoji Markdown Extension renders a skin tone code 1`] = `
<div>
<p>
🏽
</p>
</div>
`;
exports[`Emoji Markdown Extension renders an emoji code 1`] = `
<div>
<p>
😄
</p>
</div>
`;

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { mockI18n } from '../../test-utils/mock-i18n'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import { EmojiMarkdownExtension } from './emoji-markdown-extension'
describe('Emoji Markdown Extension', () => {
beforeAll(async () => {
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders an emoji code', () => {
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':smile:'} />)
expect(view.container).toMatchSnapshot()
})
it('renders a fork awesome code', () => {
const view = render(
<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':fa-circle-thin:'} />
)
expect(view.container).toMatchSnapshot()
})
it('renders a skin tone code', () => {
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':skin-tone-3:'} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji/bare'
import { combinedEmojiData } from './mapping'
/**
* Adds support for utf-8 emojis.
*/
export class EmojiMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownIt.use(emoji, {
defs: combinedEmojiData
})
}
}

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
import { ForkAwesomeIcons } from '../../../common/fork-awesome/fork-awesome-icons'
interface EmojiEntry {
shortcodes: string[]
emoji: string
}
type ShortCodeMap = { [key: string]: string }
const shortCodeMap = (emojiData as unknown as EmojiEntry[]).reduce((reduceObject, emoji) => {
emoji.shortcodes.forEach((shortcode) => {
reduceObject[shortcode] = emoji.emoji
})
return reduceObject
}, {} as ShortCodeMap)
const emojiSkinToneModifierMap = [1, 2, 3, 4, 5].reduce((reduceObject, modifierValue) => {
const lightSkinCode = 127995
const codepoint = lightSkinCode + (modifierValue - 1)
const shortcode = `skin-tone-${modifierValue}`
reduceObject[shortcode] = `&#${codepoint};`
return reduceObject
}, {} as ShortCodeMap)
const forkAwesomeIconMap = ForkAwesomeIcons.reduce((reduceObject, icon) => {
const shortcode = `fa-${icon}`
// noinspection CheckTagEmptyBody
reduceObject[shortcode] = `<i class='fa fa-${icon}'></i>`
return reduceObject
}, {} as ShortCodeMap)
export const combinedEmojiData = {
...shortCodeMap,
...emojiSkinToneModifierMap,
...forkAwesomeIconMap
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
import definitionList from 'markdown-it-deflist'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import inserted from 'markdown-it-ins'
import marked from 'markdown-it-mark'
import footnote from 'markdown-it-footnote'
import { imageSize } from '@hedgedoc/markdown-it-plugins'
/**
* Adds some common markdown syntaxes to the markdown rendering.
*/
export class GenericSyntaxMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
abbreviation(markdownIt)
definitionList(markdownIt)
subscript(markdownIt)
superscript(markdownIt)
inserted(markdownIt)
marked(markdownIt)
footnote(markdownIt)
imageSize(markdownIt)
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import anchor from 'markdown-it-anchor'
/**
* Adds headline anchors to the markdown rendering.
*/
export class HeadlineAnchorsMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
anchor(markdownIt, {
permalink: anchor.permalink.ariaHidden({
symbol: '<i class="fa fa-link"></i>',
class: 'heading-anchor text-dark',
renderHref: (slug: string): string => `#${slug}`,
placement: 'before'
})
})
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { IframeCapsuleReplacer } from './iframe-capsule-replacer'
/**
* Adds a replacer that capsules iframes in a click shield.
*/
export class IframeCapsuleMarkdownExtension extends MarkdownRendererExtension {
public buildReplacers(): ComponentReplacer[] {
return [new IframeCapsuleReplacer()]
}
public buildTagNameAllowList(): string[] {
return ['iframe']
}
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 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, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import type { Element } from 'domhandler'
import { ClickShield } from '../../replace-components/click-shield/click-shield'
/**
* Capsules <iframe> elements with a click shield.
*
* @see ClickShield
*/
export class IframeCapsuleReplacer extends ComponentReplacer {
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
return node.name !== 'iframe' ? (
DO_NOT_REPLACE
) : (
<ClickShield
hoverIcon={'globe'}
targetDescription={node.attribs.src}
data-cypress-id={'iframe-capsule-click-shield'}>
{nativeRenderer()}
</ClickShield>
)
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 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,43 @@
/*
* SPDX-FileCopyrightText: 2022 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'
import { FileContentFormat, readFile } from '../../../../../utils/read-file'
const log = new Logger('useOnImageUpload')
/**
* 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) => {
readFile(file, FileContentFormat.DATA_URL)
.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: 2022 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: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-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'
/**
* Adds support for {@link ImagePlaceholder}.
*/
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
public static readonly PLACEHOLDER_URL = 'https://'
constructor() {
super()
}
configureMarkdownIt(markdownIt: MarkdownIt): void {
addLineToPlaceholderImageTags(markdownIt)
}
buildReplacers(): ComponentReplacer[] {
return [new ImagePlaceholderReplacer()]
}
}

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } 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() {
super()
}
reset(): void {
this.countPerSourceLine = new Map<number, number>()
}
replace(node: Element): NodeReplacement {
if (node.name !== 'img' || node.attribs?.src !== ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL) {
return DO_NOT_REPLACE
}
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}
placeholderIndexInLine={indexInLine}
/>
)
}
}

View file

@ -0,0 +1,21 @@
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.image-drop {
border: 3px dashed var(--bs-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,114 @@
/*
* SPDX-FileCopyrightText: 2022 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 styles from './image-placeholder.module.scss'
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
import { useOnImageUpload } from './hooks/use-on-image-upload'
import { usePlaceholderSizeStyle } from './hooks/use-placeholder-size-style'
import { cypressId } from '../../../../utils/cypress-attribute'
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
{...cypressId('image-placeholder-image-drop')}
className={`${styles['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={styles['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: 2022 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: 2022 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

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageDetails } from '../../../render-page/window-post-message-communicator/rendering-message'
import React, { useCallback, useState } from 'react'
import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { ImageLightboxModal } from './image-lightbox-modal'
import { useExtensionEventEmitterHandler } from '../../hooks/use-extension-event-emitter'
import { SHOW_IMAGE_LIGHTBOX_EVENT_NAME } from './event-emitting-proxy-image-frame'
/**
* Handles messages from the render in the iframe to open a {@link ImageLightboxModal}.
*/
export const CommunicatorImageLightbox: React.FC = () => {
const [lightboxDetails, setLightboxDetails] = useState<ImageDetails | undefined>(undefined)
const [modalVisibility, showModal, closeModal] = useBooleanState()
const handler = useCallback(
(values: ImageDetails) => {
setLightboxDetails?.(values)
showModal()
},
[showModal]
)
useExtensionEventEmitterHandler(SHOW_IMAGE_LIGHTBOX_EVENT_NAME, handler)
return (
<ImageLightboxModal
show={modalVisibility}
onHide={closeModal}
src={lightboxDetails?.src}
alt={lightboxDetails?.alt}
title={lightboxDetails?.title}
/>
)
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { useExtensionEventEmitter } from '../../hooks/use-extension-event-emitter'
import { ProxyImageFrame } from './proxy-image-frame'
import type { ImageDetails } from '../../../render-page/window-post-message-communicator/rendering-message'
type EventEmittingProxyImageFrameProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onClick'>
export const SHOW_IMAGE_LIGHTBOX_EVENT_NAME = 'ImageClick'
/**
* Renders a {@link ProxyImageFrame} but claims the `onClick` event to send image information to the current event emitter.
*
* @param props props that will be forwarded to the inner image frame
*/
export const EventEmittingProxyImageFrame: React.FC<EventEmittingProxyImageFrameProps> = (props) => {
const eventEmitter = useExtensionEventEmitter()
const onClick = useCallback(
(event: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
const image = event.target as HTMLImageElement
if (image.src === '') {
return
}
eventEmitter?.emit(SHOW_IMAGE_LIGHTBOX_EVENT_NAME, {
src: image.src,
alt: image.alt,
title: image.title
} as ImageDetails)
},
[eventEmitter]
)
return <ProxyImageFrame {...props} onClick={onClick} />
}

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import styles from './lightbox.module.scss'
import { ProxyImageFrame } from './proxy-image-frame'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
export interface ImageLightboxModalProps extends ModalVisibilityProps {
alt?: string
src?: string
title?: string
}
/**
* Renders a lightbox modal for images.
*
* @param show If the modal should be shown
* @param onHide The callback to hide the modal
* @param src The image source
* @param alt The alt text of the image
* @param title The title of the image
*/
export const ImageLightboxModal: React.FC<ImageLightboxModalProps> = ({ show, onHide, src, alt, title }) => {
return (
<CommonModal
modalSize={'xl'}
show={show && !!src}
onHide={onHide}
showCloseButton={true}
additionalClasses={styles.lightbox}
title={alt ?? title ?? ''}
titleIsI18nKey={false}>
<ProxyImageFrame alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide} />
</CommonModal>
)
}

View file

@ -0,0 +1,11 @@
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.lightbox img {
max-width: calc(100vw - 3.5rem);
max-height: calc(100vh - 3.5rem - 75px);
object-fit: contain;
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useState } from 'react'
import { getProxiedUrl } from '../../../../api/media'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { Logger } from '../../../../utils/logger'
const log = new Logger('ProxyImageFrame')
/**
* Renders an image using the image proxy.
*
* @param src The image source
* @param title The title of the image
* @param alt The alt text of the image
* @param props Additional props directly given to the image
*/
export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = ({ src, title, alt, ...props }) => {
const [imageUrl, setImageUrl] = useState('')
const imageProxyEnabled = useApplicationState((state) => state.config.useImageProxy)
useEffect(() => {
if (!imageProxyEnabled || !src) {
return
}
getProxiedUrl(src)
.then((proxyResponse) => setImageUrl(proxyResponse.url))
.catch((err) => log.error(err))
}, [imageProxyEnabled, src])
// The next image processor works with a whitelist of origins. Therefore, we can't use it for general images.
// eslint-disable-next-line @next/next/no-img-element
return <img src={imageProxyEnabled ? imageUrl : src ?? ''} title={title ?? alt ?? ''} alt={alt} {...props} />
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { ProxyImageReplacer } from './proxy-image-replacer'
/**
* Adds support for image lightbox and image proxy redirection.
*/
export class ProxyImageMarkdownExtension extends MarkdownRendererExtension {
buildReplacers(): ComponentReplacer[] {
return [new ProxyImageReplacer()]
}
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import type { NodeReplacement } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import { EventEmittingProxyImageFrame } from './event-emitting-proxy-image-frame'
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void
/**
* Detects image tags and loads them via image proxy if configured.
*/
export class ProxyImageReplacer extends ComponentReplacer {
public replace(node: Element): NodeReplacement {
return node.name !== 'img' ? (
DO_NOT_REPLACE
) : (
<EventEmittingProxyImageFrame
id={node.attribs.id}
className={`${node.attribs.class} cursor-zoom-in`}
src={node.attribs.src}
alt={node.attribs.alt}
title={node.attribs.title}
width={node.attribs.width}
height={node.attribs.height}
/>
)
}
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
export interface LineMarkers {
startLine: number
endLine: number
}
const insertNewLineMarker = (
startLineNumber: number,
endLineNumber: number,
tokenPosition: number,
level: number,
tokens: Token[]
) => {
const startToken = new Token('app_linemarker', LinemarkerMarkdownExtension.tagName, 0)
startToken.level = level
startToken.attrPush(['data-start-line', `${startLineNumber}`])
startToken.attrPush(['data-end-line', `${endLineNumber}`])
tokens.splice(tokenPosition, 0, startToken)
}
const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[]) => {
for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) {
const token = tokens[tokenPosition]
if (token.hidden || !token.map) {
continue
}
const startLineNumber = token.map[0] + 1
const endLineNumber = token.map[1] + 1
if (token.level === 0) {
lineMarkers.push({ startLine: startLineNumber, endLine: endLineNumber })
insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens)
tokenPosition += 1
}
if (token.children) {
tagTokens(token.children, lineMarkers)
}
}
}
/**
* This plugin adds markers to the dom, that are used to map line numbers to dom elements.
* It also provides a list of line numbers for the top level dom elements.
*/
export const addLineMarkerMarkdownItPlugin: (
markdownIt: MarkdownIt,
onLineMarkerChange?: (lineMarkers: LineMarkers[]) => void
) => void = (md, onLineMarkerChange) => {
md.core.ruler.push('line_number_marker', (state) => {
const lineMarkers: LineMarkers[] = []
tagTokens(state.tokens, lineMarkers)
if (onLineMarkerChange) {
onLineMarkerChange(lineMarkers)
}
return true
})
md.renderer.rules.app_linemarker = (tokens: Token[], index: number): string => {
const startLineNumber = tokens[index].attrGet('data-start-line')
const endLineNumber = tokens[index].attrGet('data-end-line')
return startLineNumber && endLineNumber
? `<${LinemarkerMarkdownExtension.tagName} data-start-line='${startLineNumber}' data-end-line='${endLineNumber}'></${LinemarkerMarkdownExtension.tagName}>`
: ''
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import { LinemarkerReplacer } from './linemarker-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { LineMarkers } from './add-line-marker-markdown-it-plugin'
import { addLineMarkerMarkdownItPlugin } from './add-line-marker-markdown-it-plugin'
import type MarkdownIt from 'markdown-it'
/**
* Adds support for the generation of line marker elements which are needed for synced scrolling.
*/
export class LinemarkerMarkdownExtension extends MarkdownRendererExtension {
public static readonly tagName = 'app-linemarker'
constructor(private onLineMarkers?: (lineMarkers: LineMarkers[]) => void) {
super()
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
addLineMarkerMarkdownItPlugin(markdownIt, this.onLineMarkers)
}
public buildReplacers(): ComponentReplacer[] {
return [new LinemarkerReplacer()]
}
public buildTagNameAllowList(): string[] {
return [LinemarkerMarkdownExtension.tagName]
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type { NodeReplacement } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
/**
* Detects line markers and suppresses them in the resulting DOM.
*/
export class LinemarkerReplacer extends ComponentReplacer {
public replace(codeNode: Element): NodeReplacement {
return codeNode.name === LinemarkerMarkdownExtension.tagName ? null : DO_NOT_REPLACE
}
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface LineWithId {
line: string
id: number
}
export interface LineMarkerPosition {
line: number
position: number
}

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
import type { Node } from 'domhandler'
import { isTag } from 'domhandler'
/**
* A preprocessor for links. It filters script and data links, converts relative URLs into absolute URLs and fixes jump marks.
*/
export class AnchorNodePreprocessor extends TravelerNodeProcessor {
constructor(private baseUrl: string) {
super()
}
protected processNode(node: Node): void {
if (!isTag(node) || node.name !== 'a' || !node.attribs || !node.attribs.href) {
return
}
const url = node.attribs.href.trim()
// eslint-disable-next-line no-script-url
if (url.startsWith('data:') || url.startsWith('javascript:') || url.startsWith('vbscript:')) {
delete node.attribs.href
return
}
const isJumpMark = url.slice(0, 1) === '#'
if (isJumpMark) {
node.attribs['data-jump-target-id'] = url.slice(1)
} else {
node.attribs.rel = 'noreferer noopener'
node.attribs.target = '_blank'
}
try {
node.attribs.href = new URL(url, this.baseUrl).toString()
} catch (e) {
node.attribs.href = url
}
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type { AllHTMLAttributes } from 'react'
import React from 'react'
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import { JumpAnchor } from './jump-anchor'
/**
* Detects anchors that should jump to scroll to another element.
*/
export class JumpAnchorReplacer extends ComponentReplacer {
public replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
if (node.name !== 'a' || !node.attribs || !node.attribs['data-jump-target-id']) {
return DO_NOT_REPLACE
}
const jumpId = node.attribs['data-jump-target-id']
delete node.attribs['data-jump-target-id']
const replacement = nativeRenderer()
return replacement === null || typeof replacement === 'string' ? (
replacement
) : (
<JumpAnchor {...(replacement.props as AllHTMLAttributes<HTMLAnchorElement>)} jumpTargetId={jumpId} />
)
}
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AllHTMLAttributes } from 'react'
import React, { useCallback } from 'react'
export interface JumpAnchorProps extends AllHTMLAttributes<HTMLAnchorElement> {
jumpTargetId: string
}
/**
* Renders jump anchors.
*
* @param jumpTargetId The target id
* @param children Children rendered into the link.
* @param props Additional props directly given to the link
*/
export const JumpAnchor: React.FC<JumpAnchorProps> = ({ jumpTargetId, children, ...props }) => {
const jumpToTargetId = useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
const intoViewElement = document.getElementById(jumpTargetId)
const scrollElement = document.querySelector('[data-scroll-element]')
if (!intoViewElement || !scrollElement) {
return
}
//It would be much easier to use scrollIntoView here but since the code mirror also uses smooth scroll and bugs like
// https://stackoverflow.com/a/63563437/13103995 exist, we must use scrollTo.
scrollElement.scrollTo({ behavior: 'smooth', top: intoViewElement.offsetTop })
event.preventDefault()
},
[jumpTargetId]
)
return (
<a {...props} onClick={jumpToTargetId}>
{children}
</a>
)
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import { JumpAnchorReplacer } from './jump-anchor-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import { AnchorNodePreprocessor } from './anchor-node-preprocessor'
/**
* Adds tweaks for anchor tags which are needed for the use in the secured iframe.
*/
export class LinkAdjustmentMarkdownExtension extends MarkdownRendererExtension {
constructor(private baseUrl: string) {
super()
}
public buildNodeProcessors(): NodeProcessor[] {
return [new AnchorNodePreprocessor(this.baseUrl)]
}
public buildReplacers(): ComponentReplacer[] {
return [new JumpAnchorReplacer()]
}
}

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Linkify markdown extensions renders a .rocks link correctly 1`] = `
<div>
<p>
<a
href="http://example.rocks"
>
example.rocks
</a>
</p>
<p>
<a
href="http://example.com"
>
example.com
</a>
</p>
<p>
<a
href="http://example.de"
>
example.de
</a>
</p>
</div>
`;

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mockI18n } from '../../test-utils/mock-i18n'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import { LinkifyFixMarkdownExtension } from './linkify-fix-markdown-extension'
describe('Linkify markdown extensions', () => {
beforeAll(async () => {
await mockI18n()
})
it('renders a .rocks link correctly', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new LinkifyFixMarkdownExtension()]}
content={'example.rocks\n\nexample.com\n\nexample.de'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import linkify from 'markdown-it/lib/rules_core/linkify'
import type MarkdownIt from 'markdown-it'
import tlds from 'tlds'
/**
* A markdown extension that detects plain text URLs and converts them into links.
*/
export class LinkifyFixMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
markdownIt.linkify.tlds(tlds)
markdownIt.core.ruler.push('linkify', (state) => {
try {
state.md.options.linkify = true
return linkify(state)
} finally {
state.md.options.linkify = false
}
})
}
}

View file

@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DataNode, Element, Node } from 'domhandler'
import { isComment, isTag } from 'domhandler'
import { Logger } from '../../../../utils/logger'
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
const log = new Logger('reveal.js > Comment Node Preprocessor')
const revealCommandSyntax = /^\s*\.(\w*):(.*)$/g
const dataAttributesSyntax = /\s*(data-[\w-]*|class)=(?:"((?:[^"\\]|\\"|\\)*)"|'([^']*)')/g
/**
* Travels through the given {@link Document}, searches for reveal command comments and applies them.
*
* @param doc The document that should be changed
* @return The edited document
*/
export class RevealCommentCommandNodePreprocessor extends TravelerNodeProcessor {
protected processNode(node: Node): void {
if (isComment(node)) {
processCommentNode(node)
}
}
}
/**
* Processes the given {@link DataNode html comment} by parsing it, finding the element that should be changed and applies the contained changes.
*
* @param node The node that contains the reveal command.
*/
const processCommentNode = (node: DataNode): void => {
const regexResult = node.data.split(revealCommandSyntax)
if (regexResult.length === 1) {
return
}
const parentNode: Element | null = findTargetElement(node, regexResult[1])
if (!parentNode) {
return
}
for (const dataAttribute of [...regexResult[2].matchAll(dataAttributesSyntax)]) {
const attributeName = dataAttribute[1]
const attributeValue = dataAttribute[2] ?? dataAttribute[3]
if (attributeValue) {
log.debug(
`Add attribute "${attributeName}"=>"${attributeValue}" to node`,
parentNode,
'because of',
regexResult[1],
'selector'
)
parentNode.attribs[attributeName] = attributeValue
}
}
}
/**
* Finds the ancestor element that should be changed based on the given selector.
*
* @param node The node whose ancestor should be found.
* @param selector The found ancestor node or null if no node could be found.
* @return The ancestor element, if it exists. {@link undefined} otherwise.
*/
const findTargetElement = (node: Node, selector: string): Element | null => {
if (selector === 'slide') {
return findNearestAncestorSection(node)
} else if (selector === 'element') {
return findParentElement(node)
} else {
return null
}
}
/**
* Returns the parent node if it is an {@link Element}.
*
* @param node the found node or null if no parent node exists or if the parent node isn't an {@link Element}.
* @return The parent node, if it exists. {@link undefined} otherwise.
*/
const findParentElement = (node: Node): Element | null => {
return node.parentNode !== null && isTag(node.parentNode) ? node.parentNode : null
}
/**
* Looks for the nearest ancestor of the node that is a section element.
*
* @param node the found section node or null if no section ancestor could be found.
* @return The nearest ancestor element, if it exists. {@link undefined} otherwise.
*/
const findNearestAncestorSection = (node: Node): Element | null => {
let currentNode = node.parentNode
while (currentNode != null) {
if (isTag(currentNode) && currentNode.tagName === 'section') {
break
}
currentNode = node.parentNode
}
return currentNode
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import { addSlideSectionsMarkdownItPlugin } from './reveal-sections'
import { RevealCommentCommandNodePreprocessor } from './process-reveal-comment-nodes'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
/**
* Adds support for reveal.js to the markdown rendering.
* This includes the generation of sections and the manipulation of elements using reveal comments.
*/
export class RevealMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
addSlideSectionsMarkdownItPlugin(markdownIt)
}
public buildNodeProcessors(): NodeProcessor[] {
return [new RevealCommentCommandNodePreprocessor()]
}
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
import type StateCore from 'markdown-it/lib/rules_core/state_core'
/**
* This functions adds a 'section close' token at currentTokenIndex in the state's token array,
* replacing the current token, if replaceCurrentToken is true.
* It also returns the currentTokenIndex, that will be increased only if the previous token was not replaced.
*
* @param {number} currentTokenIndex - the current position in the tokens array
* @param {StateCore} state - the state core
* @param {boolean} replaceCurrentToken - if the currentToken should be replaced
*/
const addSectionClose = (currentTokenIndex: number, state: StateCore, replaceCurrentToken: boolean): void => {
const sectionCloseToken = new Token('section', 'section', -1)
state.tokens.splice(currentTokenIndex, replaceCurrentToken ? 1 : 0, sectionCloseToken)
}
/**
* This functions adds a 'section open' token at insertIndex in the state's token array.
*
* @param {number} insertIndex - the index at which the token should be added
* @param {StateCore} state - the state core
*/
const addSectionOpen = (insertIndex: number, state: StateCore): void => {
const sectionOpenToken = new Token('section', 'section', 1)
state.tokens.splice(insertIndex, 0, sectionOpenToken)
}
/**
* Adds a plugin to the given {@link MarkdownIt markdown it instance} that
* replaces splits the content by horizontal lines and groups these blocks into
* html section tags.
*
* @param markdownIt The {@link MarkdownIt markdown it instance} to which the plugin should be added
*/
export const addSlideSectionsMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt): void => {
markdownIt.core.ruler.push('reveal.sections', (state) => {
let sectionBeginIndex = 0
let lastSectionWasBranch = false
for (let currentTokenIndex = 0; currentTokenIndex < state.tokens.length; currentTokenIndex++) {
const currentToken = state.tokens[currentTokenIndex]
if (currentToken.type !== 'hr') {
continue
}
addSectionOpen(sectionBeginIndex, state)
currentTokenIndex += 1
if (currentToken.markup === '---' && lastSectionWasBranch) {
lastSectionWasBranch = false
addSectionClose(currentTokenIndex, state, false)
currentTokenIndex += 1
} else if (currentToken.markup === '----' && !lastSectionWasBranch) {
lastSectionWasBranch = true
addSectionOpen(sectionBeginIndex, state)
currentTokenIndex += 1
}
addSectionClose(currentTokenIndex, state, true)
sectionBeginIndex = currentTokenIndex + 1
}
addSectionOpen(sectionBeginIndex, state)
addSectionClose(state.tokens.length, state, false)
if (lastSectionWasBranch) {
addSectionClose(state.tokens.length, state, false)
}
return true
})
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Document } from 'domhandler'
import render from 'dom-serializer'
import DOMPurify from 'dompurify'
import { parseDocument } from 'htmlparser2'
import { NodeProcessor } from '../../node-preprocessors/node-processor'
/**
* Sanitizes the given {@link Document document}.
*
* @see https://cure53.de/purify
*/
export class SanitizerNodePreprocessor extends NodeProcessor {
constructor(private tagNameWhiteList: string[]) {
super()
}
process(nodes: Document): Document {
const sanitizedHtml = DOMPurify.sanitize(render(nodes), {
ADD_TAGS: this.tagNameWhiteList
})
return parseDocument(sanitizedHtml)
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { SanitizerNodePreprocessor } from './dom-purifier-node-preprocessor'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
/**
* Adds support for html sanitizing using dompurify to the markdown rendering.
*/
export class SanitizerMarkdownExtension extends MarkdownRendererExtension {
constructor(private tagNameWhiteList: string[]) {
super()
}
public buildNodeProcessors(): NodeProcessor[] {
return [new SanitizerNodePreprocessor(this.tagNameWhiteList)]
}
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import type { TocAst } from 'markdown-it-toc-done-right'
import toc from 'markdown-it-toc-done-right'
import { tocSlugify } from '../../editor-page/table-of-contents/toc-slugify'
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
import equal from 'fast-deep-equal'
/**
* Adds table of content to the markdown rendering.
*/
export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension {
public static readonly EVENT_NAME = 'TocChange'
private lastAst: TocAst | undefined = undefined
public configureMarkdownIt(markdownIt: MarkdownIt): void {
toc(markdownIt, {
placeholder: '(\\[TOC\\]|\\[toc\\])',
listType: 'ul',
level: [1, 2, 3],
callback: (code: string, ast: TocAst): void => {
if (equal(ast, this.lastAst)) {
return
}
this.lastAst = ast
this.eventEmitter?.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
},
slugify: tocSlugify
})
}
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2022 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: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../base/markdown-renderer-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 MarkdownRendererExtension {
buildReplacers(): ComponentReplacer[] {
return [new UploadIndicatingImageFrameReplacer()]
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } 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): NodeReplacement {
return node.name !== 'img' || !uploadIdRegex.test(node.attribs.src) ? (
DO_NOT_REPLACE
) : (
<UploadIndicatingFrame width={node.attribs.width} height={node.attribs.height} />
)
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import type { Document } from 'domhandler'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
/**
* Creates a function that applies the node preprocessors of every given {@link MarkdownRendererExtension} to a {@link Document}.
*
* @param extensions The extensions who provide node processors
* @return The created apply function
*/
export const useCombinedNodePreprocessor = (extensions: MarkdownRendererExtension[]): ((nodes: Document) => Document) =>
useMemo(() => {
return extensions
.flatMap((extension) => extension.buildNodeProcessors())
.reduce(
(state, processor) => (document: Document) => state(processor.process(document)),
(document: Document) => document
)
}, [extensions])

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import MarkdownIt from 'markdown-it/lib'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
/**
* Creates a new {@link MarkdownIt markdown-it instance} and configures it using the given {@link MarkdownRendererExtension markdown renderer extensions}.
*
* @param extensions The extensions that configure the new markdown-it instance
* @param allowHtml Defines if html in markdown is allowed
* @param newlinesAreBreaks Defines if new lines should be treated as line breaks or paragraphs
* @return the created markdown-it instance
*/
export const useConfiguredMarkdownIt = (
extensions: MarkdownRendererExtension[],
allowHtml: boolean,
newlinesAreBreaks: boolean
): MarkdownIt => {
return useMemo(() => {
const newMarkdownIt = new MarkdownIt('default', {
html: allowHtml,
breaks: newlinesAreBreaks,
langPrefix: '',
typographer: true
})
extensions.forEach((extension) => newMarkdownIt.use((markdownIt) => extension.configureMarkdownIt(markdownIt)))
extensions.forEach((extension) => newMarkdownIt.use((markdownIt) => extension.configureMarkdownItPost(markdownIt)))
return newMarkdownIt
}, [allowHtml, extensions, newlinesAreBreaks])
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo } from 'react'
import type { ValidReactDomElement } from '../replace-components/component-replacer'
import convertHtmlToReact from '@hedgedoc/html-to-react'
import { NodeToReactTransformer } from '../utils/node-to-react-transformer'
import { LineIdMapper } from '../utils/line-id-mapper'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
import { SanitizerMarkdownExtension } from '../extensions/sanitizer/sanitizer-markdown-extension'
import { useCombinedNodePreprocessor } from './use-combined-node-preprocessor'
import { useConfiguredMarkdownIt } from './use-configured-markdown-it'
/**
* Renders Markdown-Code into react elements.
*
* @param markdownContentLines The Markdown code lines that should be rendered
* @param additionalMarkdownExtensions A list of {@link MarkdownRendererExtension markdown extensions} that should be used
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
* @param allowHtml Defines if html is allowed in markdown
* @return The React DOM that represents the rendered Markdown code
*/
export const useConvertMarkdownToReactDom = (
markdownContentLines: string[],
additionalMarkdownExtensions: MarkdownRendererExtension[],
newlinesAreBreaks = true,
allowHtml = true
): ValidReactDomElement => {
const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
const markdownExtensions = useMemo(() => {
const tagWhiteLists = additionalMarkdownExtensions.flatMap((extension) => extension.buildTagNameAllowList())
return [...additionalMarkdownExtensions, new SanitizerMarkdownExtension(tagWhiteLists)]
}, [additionalMarkdownExtensions])
useMemo(() => {
htmlToReactTransformer.setReplacers(markdownExtensions.flatMap((extension) => extension.buildReplacers()))
}, [htmlToReactTransformer, markdownExtensions])
useMemo(() => {
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
}, [htmlToReactTransformer, lineNumberMapper, markdownContentLines])
const nodePreProcessor = useCombinedNodePreprocessor(markdownExtensions)
const markdownIt = useConfiguredMarkdownIt(markdownExtensions, allowHtml, newlinesAreBreaks)
return useMemo(() => {
const html = markdownIt.render(markdownContentLines.join('\n'))
htmlToReactTransformer.resetReplacers()
return (
<Fragment key={'root'}>
{convertHtmlToReact(html, {
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: (document) => nodePreProcessor(document)
})}
</Fragment>
)
}, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
}

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React, { createContext, useContext, useEffect, useMemo } from 'react'
import EventEmitter2 from 'eventemitter2'
export const eventEmitterContext = createContext<EventEmitter2 | undefined>(undefined)
/**
* Provides the {@link EventEmitter2 event emitter} from the current {@link eventEmitterContext context}.
*/
export const useExtensionEventEmitter = () => {
return useContext(eventEmitterContext)
}
/**
* Creates a new {@link EventEmitter2 event emitter} and provides it as {@link eventEmitterContext context}.
*
* @param children The elements that should receive the context value
*/
export const ExtensionEventEmitterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const eventEmitter = useMemo(() => new EventEmitter2(), [])
return <eventEmitterContext.Provider value={eventEmitter}>{children}</eventEmitterContext.Provider>
}
/**
* Registers a handler callback on the current {@link EventEmitter2 event emitter} that is provided in the {@link eventEmitterContext context}.
*
* @param eventName The name of the event which should be subscribed
* @param handler The callback that should be executed. If undefined the event will be unsubscribed.
*/
export const useExtensionEventEmitterHandler = <T,>(
eventName: string,
handler: ((values: T) => void) | undefined
): void => {
const eventEmitter = useExtensionEventEmitter()
useEffect(() => {
if (!eventEmitter || !handler) {
return
}
eventEmitter.on(eventName, handler)
return () => {
eventEmitter.off(eventName, handler)
}
}, [eventEmitter, eventName, handler])
}

View file

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Optional } from '@mrdrogdrog/optional'
/**
* Extracts the plain text content of a {@link ChildNode node}.
*
* @param node The node whose text content should be extracted.
* @return the plain text content
*/
const extractInnerText = (node: ChildNode | null): string => {
if (!node) {
return ''
} else if (isKatexMathMlElement(node)) {
return ''
} else if (node.childNodes && node.childNodes.length > 0) {
return extractInnerTextFromChildren(node)
} else if (node.nodeName.toLowerCase() === 'img') {
return (node as HTMLImageElement).getAttribute('alt') ?? ''
} else {
return node.textContent ?? ''
}
}
/**
* Determines if the given {@link ChildNode node} is the mathml part of a KaTeX rendering.
* @param node The node that might be a katex mathml element
*/
const isKatexMathMlElement = (node: ChildNode): boolean => (node as HTMLElement).classList?.contains('katex-mathml')
/**
* Extracts the text content of the children of the given {@link ChildNode node}.
* @param node The node whose children should be processed. The content of the node itself won't be included.
* @return the concatenated text content of the child nodes
*/
const extractInnerTextFromChildren = (node: ChildNode): string =>
Array.from(node.childNodes).reduce((state, child) => {
return state + extractInnerText(child)
}, '')
/**
* Extracts the plain text content of the first level 1 heading in the document.
*
* @param documentElement The root element of (sub)dom that should be inspected
* @param onFirstHeadingChange A callback that will be executed with the new level 1 heading
*/
export const useExtractFirstHeadline = (
documentElement: React.RefObject<HTMLDivElement>,
onFirstHeadingChange?: (firstHeading: string | undefined) => void
): (() => void) => {
const lastFirstHeadingContent = useRef<string | undefined>()
const currentFirstHeadingElement = useRef<HTMLHeadingElement | null>(null)
const extractHeaderText = useCallback(() => {
if (!onFirstHeadingChange) {
return
}
const headingText = extractInnerText(currentFirstHeadingElement.current).trim()
if (headingText !== lastFirstHeadingContent.current) {
lastFirstHeadingContent.current = headingText
onFirstHeadingChange(headingText)
}
}, [onFirstHeadingChange])
const mutationObserver = useMemo(() => new MutationObserver(() => extractHeaderText()), [extractHeaderText])
useEffect(() => () => mutationObserver.disconnect(), [mutationObserver])
return useCallback(() => {
const foundFirstHeading = Optional.ofNullable(documentElement.current)
.map((currentDocumentElement) => currentDocumentElement.getElementsByTagName('h1').item(0))
.orElse(null)
if (foundFirstHeading === currentFirstHeadingElement.current) {
return
}
mutationObserver.disconnect()
currentFirstHeadingElement.current = foundFirstHeading
if (foundFirstHeading !== null) {
mutationObserver.observe(foundFirstHeading, { subtree: true, childList: true })
}
extractHeaderText()
}, [documentElement, extractHeaderText, mutationObserver])
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MutableRefObject } from 'react'
import { useMemo } from 'react'
import { GenericSyntaxMarkdownExtension } from '../extensions/generic-syntax-markdown-extension'
import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin'
import { DebuggerMarkdownExtension } from '../extensions/debugger-markdown-extension'
import { UploadIndicatingImageFrameMarkdownExtension } from '../extensions/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension'
import { IframeCapsuleMarkdownExtension } from '../extensions/iframe-capsule/iframe-capsule-markdown-extension'
import { LinkifyFixMarkdownExtension } from '../extensions/linkify-fix/linkify-fix-markdown-extension'
import { LinkAdjustmentMarkdownExtension } from '../extensions/link-replacer/link-adjustment-markdown-extension'
import { EmojiMarkdownExtension } from '../extensions/emoji/emoji-markdown-extension'
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
import { TableOfContentsMarkdownExtension } from '../extensions/table-of-contents-markdown-extension'
import { ImagePlaceholderMarkdownExtension } from '../extensions/image-placeholder/image-placeholder-markdown-extension'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
import { useExtensionEventEmitter } from './use-extension-event-emitter'
import { optionalAppExtensions } from '../../../extensions/extra-integrations/optional-app-extensions'
import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension'
const optionalMarkdownRendererExtensions = optionalAppExtensions.flatMap((value) =>
value.buildMarkdownRendererExtensions()
)
/**
* Provides a list of {@link MarkdownRendererExtension markdown extensions} that is a combination of the common extensions and the given additional.
*
* @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension}
* @param currentLineMarkers A {@link MutableRefObject reference} to {@link LineMarkers} for the {@link LinemarkerMarkdownExtension}
* @param additionalExtensions The additional extensions that should be included in the list
* @return The created list of markdown extensions
*/
export const useMarkdownExtensions = (
baseUrl: string,
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
additionalExtensions: MarkdownRendererExtension[]
): MarkdownRendererExtension[] => {
const extensionEventEmitter = useExtensionEventEmitter()
//replace with global list
return useMemo(() => {
return [
...optionalMarkdownRendererExtensions,
...additionalExtensions,
new TableOfContentsMarkdownExtension(extensionEventEmitter),
new LinemarkerMarkdownExtension(
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined
),
new IframeCapsuleMarkdownExtension(),
new ImagePlaceholderMarkdownExtension(),
new UploadIndicatingImageFrameMarkdownExtension(),
new LinkAdjustmentMarkdownExtension(baseUrl),
new EmojiMarkdownExtension(),
new GenericSyntaxMarkdownExtension(),
new LinkifyFixMarkdownExtension(),
new DebuggerMarkdownExtension(),
new ProxyImageMarkdownExtension()
]
}, [additionalExtensions, baseUrl, currentLineMarkers, extensionEventEmitter])
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import equal from 'fast-deep-equal'
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
/**
* Check if the given reference changes and then calls the callback onChange.
*
* @param reference The reference to observe
* @param onChange The callback to call if something changes
*/
export const useOnRefChange = <T>(reference: MutableRefObject<T>, onChange?: (newValue: T) => void): void => {
const lastValue = useRef<T>()
useEffect(() => {
if (onChange && !equal(reference.current, lastValue.current)) {
lastValue.current = reference.current
onChange(reference.current)
}
})
}

View file

@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useRef, useState } from 'react'
import type Reveal from 'reveal.js'
import { Logger } from '../../../utils/logger'
import type { SlideOptions } from '../../../redux/note-details/types/slide-show-options'
const log = new Logger('reveal.js')
export enum REVEAL_STATUS {
NOT_INITIALISED,
INITIALISING,
INITIALISED
}
export interface SlideState {
indexHorizontal: number
indexVertical: number
}
const initialSlideState: SlideState = {
indexHorizontal: 0,
indexVertical: 0
}
/**
* Initialize reveal.js and renders the document as a reveal.js presentation.
*
* @param markdownContentLines An array of markdown lines.
* @param slideOptions The slide options.
* @return The current state of reveal.js
* @see https://revealjs.com/
*/
export const useReveal = (markdownContentLines: string[], slideOptions?: SlideOptions): REVEAL_STATUS => {
const [deck, setDeck] = useState<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState)
useEffect(() => {
if (revealStatus !== REVEAL_STATUS.NOT_INITIALISED) {
return
}
setRevealStatus(REVEAL_STATUS.INITIALISING)
log.debug('Initialize with slide options', slideOptions)
import(/* webpackChunkName: "reveal" */ 'reveal.js')
.then((revealImport) => {
const reveal = new revealImport.default({})
reveal
.initialize()
.then(() => {
reveal.layout()
reveal.slide(0, 0, 0)
reveal.addEventListener('slidechanged', (event) => {
currentSlideState.current = {
indexHorizontal: event.indexh,
indexVertical: event.indexv ?? 0
} as SlideState
})
setDeck(reveal)
setRevealStatus(REVEAL_STATUS.INITIALISED)
log.debug('Initialisation finished')
})
.catch((error: Error) => {
log.error('Error while initializing reveal.js', error)
})
})
.catch((error: Error) => {
log.error('Error while loading reveal.js', error)
})
}, [revealStatus, slideOptions])
useEffect(() => {
if (!deck || revealStatus !== REVEAL_STATUS.INITIALISED) {
return
}
log.debug('Sync deck')
deck.sync()
deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical)
}, [markdownContentLines, deck, revealStatus])
useEffect(() => {
if (
!deck ||
slideOptions === undefined ||
Object.keys(slideOptions).length === 0 ||
revealStatus !== REVEAL_STATUS.INITIALISED
) {
return
}
log.debug('Apply config', slideOptions)
deck.configure(slideOptions)
}, [deck, revealStatus, slideOptions])
return revealStatus
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
/**
* Shows a static text placeholder while reveal.js is loading.
*/
export const LoadingSlide: React.FC = () => {
useTranslation()
return (
<section>
<h1>
<Trans i18nKey={'common.loading'} />
</h1>
</section>
)
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Document } from 'domhandler'
/**
* Base class for node processors.
*/
export abstract class NodeProcessor {
public abstract process(document: Document): Document
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NodeProcessor } from './node-processor'
import type { Document, Node } from 'domhandler'
import { hasChildren } from 'domhandler'
/**
* Base class for traveler node processors whose processing is executed on the given node and every child.
*/
export abstract class TravelerNodeProcessor extends NodeProcessor {
process(nodes: Document): Document {
this.processNodes(nodes.children)
return nodes
}
private processNodes(nodes: Node[]): void {
nodes.forEach((node) => {
this.processNode(node)
if (hasChildren(node)) {
this.processNodes(node.children)
}
})
}
protected abstract processNode(node: Node): void
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.click-shield {
position: relative;
cursor: pointer;
width: 100%;
overflow: hidden;
.preview-hover {
color: white;
opacity: 0.5;
transition: opacity 0.2s;
text-shadow: #000000 0 0 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.preview-hover-text {
opacity: 0;
}
&:hover {
.preview-hover-text {
opacity: 1;
}
.preview-hover {
opacity: 1;
text-shadow: #000000 0 0 5px, #000000 0 0 10px;
}
}
.preview-background {
background: none;
height: 100%;
object-fit: cover;
min-height: 300px;
width: 100%;
box-shadow: inset rgba(var(--bs-dark), 0.5) 0 0 20px;
}
}

View file

@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import type { IconName } from '../../../common/fork-awesome/types'
import { ShowIf } from '../../../common/show-if/show-if'
import styles from './click-shield.module.scss'
import { Logger } from '../../../../utils/logger'
import type { Property } from 'csstype'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { ProxyImageFrame } from '../../extensions/image/proxy-image-frame'
const log = new Logger('OneClickEmbedding')
export interface ClickShieldProps extends PropsWithChildren<PropsWithDataCypressId> {
onImageFetch?: () => Promise<string>
fallbackPreviewImageUrl?: string
hoverIcon: IconName
targetDescription: string
containerClassName?: string
fallbackBackgroundColor?: Property.BackgroundColor
}
/**
* Prevents loading of the children elements until the user unlocks the content by e.g. clicking.
*
* @param containerClassName Additional CSS classes for the complete component
* @param onImageFetch A callback that is used to get an URL for the preview image
* @param fallbackPreviewImageUrl The URL for an image that should be shown. If onImageFetch is defined then this image will be shown until onImageFetch provides another URL.
* @param targetDescription The name of the target service
* @param hoverIcon The name of an icon that should be shown in the preview
* @param fallbackBackgroundColor A color that should be used if no background image was provided or could be loaded.
* @param children The children element that should be shielded.
*/
export const ClickShield: React.FC<ClickShieldProps> = ({
containerClassName,
onImageFetch,
fallbackPreviewImageUrl,
children,
targetDescription,
hoverIcon,
fallbackBackgroundColor,
...props
}) => {
const [showChildren, setShowChildren] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState(fallbackPreviewImageUrl)
const { t } = useTranslation()
const doShowChildren = useCallback(() => {
setShowChildren(true)
}, [])
useEffect(() => {
if (!onImageFetch) {
return
}
onImageFetch()
.then((imageLink) => {
setPreviewImageUrl(imageLink)
})
.catch((message) => {
log.error(message)
})
}, [onImageFetch])
const fallbackBackgroundStyle = useMemo<React.CSSProperties>(
() =>
!fallbackBackgroundColor
? {}
: {
backgroundColor: fallbackBackgroundColor
},
[fallbackBackgroundColor]
)
const previewHoverText = useMemo(() => {
return targetDescription ? t('renderer.clickShield.previewHoverText', { target: targetDescription }) : ''
}, [t, targetDescription])
const previewBackground = useMemo(() => {
return previewImageUrl === undefined ? (
<span
className={`${styles['preview-background']} embed-responsive-item`}
{...cypressId('preview-background')}
style={fallbackBackgroundStyle}
/>
) : (
<ProxyImageFrame
{...cypressId('preview-background')}
className={`${styles['preview-background']} embed-responsive-item`}
style={fallbackBackgroundStyle}
src={previewImageUrl}
alt={previewHoverText}
title={previewHoverText}
/>
)
}, [fallbackBackgroundStyle, previewHoverText, previewImageUrl])
const hoverTextTranslationValues = useMemo(() => ({ target: targetDescription }), [targetDescription])
return (
<span className={containerClassName} {...cypressId(props['data-cypress-id'])}>
<ShowIf condition={showChildren}>{children}</ShowIf>
<ShowIf condition={!showChildren}>
<span className={`${styles['click-shield']} d-inline-block ratio ratio-16x9`} onClick={doShowChildren}>
{previewBackground}
<span className={`${styles['preview-hover']}`}>
<span className={`${styles['preview-hover-text']}`}>
<Trans i18nKey={'renderer.clickShield.previewHoverText'} values={hoverTextTranslationValues} />
</span>
<ForkAwesomeIcon icon={hoverIcon} size={'5x'} className={'mb-2'} />
</span>
</span>
</ShowIf>
</span>
)
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from './component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from './component-replacer'
import type { FunctionComponent } from 'react'
import React from 'react'
import type { Element } from 'domhandler'
export interface CodeProps {
code: string
}
/**
* Checks if the given checked node is a code block with a specific language attribute and creates an react-element that receives the code.
*/
export class CodeBlockComponentReplacer extends ComponentReplacer {
constructor(private component: FunctionComponent<CodeProps>, private language: string) {
super()
}
replace(node: Element): NodeReplacement {
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(node, this.language)
return code ? React.createElement(this.component, { code: code }) : DO_NOT_REPLACE
}
/**
* Extracts the text content if the given {@link Element} is a code block with a specific language.
*
* @param element The {@link Element} to check.
* @param language The language that code block should be assigned to.
* @return The text content or undefined if the element isn't a code block or has the wrong language attribute.
*/
public static extractTextFromCodeNode(element: Element, language: string): string | undefined {
return element.name === 'code' && element.attribs['data-highlight-language'] === language && element.children[0]
? ComponentReplacer.extractTextChildContent(element)
: undefined
}
}

View file

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element, Node } from 'domhandler'
import { isText } from 'domhandler'
import type { ReactElement } from 'react'
export type ValidReactDomElement = ReactElement | string | null
export type SubNodeTransform = (node: Node, subKey: number | string) => ValidReactDomElement
export type NativeRenderer = () => ValidReactDomElement
export const DO_NOT_REPLACE = Symbol()
export type NodeReplacement = ValidReactDomElement | typeof DO_NOT_REPLACE
/**
* Base class for all component replacers.
* Component replacers detect structures in the HTML DOM from markdown it
* and replace them with some special react components.
*/
export abstract class ComponentReplacer {
/**
* Extracts the content of the first text child node.
*
* @param node the node with the text node child
* @return the string content
*/
protected static extractTextChildContent(node: Element): string {
const childrenTextNode = node.children[0]
return isText(childrenTextNode) ? childrenTextNode.data : ''
}
/**
* Applies the given {@link SubNodeTransform sub node transformer} to every children of the given {@link Node}.
*
* @param node The node whose children should be transformed
* @param subNodeTransform The transformer that should be used.
* @return The children as react elements.
*/
protected static transformChildren(node: Element, subNodeTransform: SubNodeTransform): ValidReactDomElement[] {
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.
*
* @param node The current html dom node
* @param subNodeTransform should be used to convert child elements of the current node
* @param nativeRenderer renders the current node without any replacement
* @return the replacement for the current node or undefined if the current replacer replacer hasn't done anything.
*/
public abstract replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): NodeReplacement
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from './component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from './component-replacer'
import type { FunctionComponent } from 'react'
import React from 'react'
import type { Element } from 'domhandler'
export interface IdProps {
id: string
}
/**
* Replaces custom tags that have just an id (<app-something id="something"/>) with react elements.
*/
export class CustomTagWithIdComponentReplacer extends ComponentReplacer {
constructor(private component: FunctionComponent<IdProps>, private tagName: string) {
super()
}
public replace(node: Element): NodeReplacement {
const id = this.extractId(node)
return id ? React.createElement(this.component, { id: id }) : DO_NOT_REPLACE
}
/**
* Checks if the given {@link Element} is a custom tag and extracts its `id` attribute.
*
* @param element The element to check.
* @return the extracted id or undefined if the element isn't a custom tag or has no id attribute.
*/
private extractId(element: Element): string | undefined {
return element.name === this.tagName && element.attribs && element.attribs.id ? element.attribs.id : undefined
}
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useMemo, useRef } from 'react'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { LoadingSlide } from './loading-slide'
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
import type { SlideOptions } from '../../redux/note-details/types/slide-show-options'
import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension'
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
slideOptions?: SlideOptions
}
/**
* Renders the note as a reveal.js presentation.
*
* @param className Additional class names directly given to the div
* @param markdownContentLines The markdown lines
* @param onFirstHeadingChange The callback to call if the first heading changes.
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
* @param onTocChange The callback to call if the toc changes.
* @param baseUrl The base url of the renderer
* @param onImageClick The callback to call if a image is clicked
* @param newlinesAreBreaks If newlines are rendered as breaks or not
* @param slideOptions The {@link SlideOptions} to use
*/
export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps & ScrollProps> = ({
className,
markdownContentLines,
onFirstHeadingChange,
baseUrl,
newlinesAreBreaks,
slideOptions
}) => {
const markdownBodyRef = useRef<HTMLDivElement>(null)
const extensions = useMarkdownExtensions(
baseUrl,
undefined,
useMemo(() => [new RevealMarkdownExtension()], [])
)
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
const revealStatus = useReveal(markdownContentLines, slideOptions)
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
useEffect(() => {
if (revealStatus === REVEAL_STATUS.INITIALISED) {
extractFirstHeadline()
}
}, [extractFirstHeadline, markdownContentLines, revealStatus])
const slideShowDOM = useMemo(
() => (revealStatus === REVEAL_STATUS.INITIALISED ? markdownReactDom : <LoadingSlide />),
[markdownReactDom, revealStatus]
)
return (
<div className={'reveal'}>
<div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
{slideShowDOM}
</div>
</div>
)
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { TFunction } from 'i18next'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
/**
* Initializes i18n with minimal settings and without any data, so it just returns the used key as translation.
*
* @return A promise that resolves if i18n has been initialized
*/
export const mockI18n = (): Promise<TFunction> => {
return i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
ns: ['translationsNS'],
defaultNS: 'translationsNS',
interpolation: {
escapeValue: false
},
resources: { en: { translationsNS: {} } }
})
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { useConvertMarkdownToReactDom } from '../hooks/use-convert-markdown-to-react-dom'
import { StoreProvider } from '../../../redux/store-provider'
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
export interface SimpleMarkdownRendererProps {
content: string
extensions: MarkdownRendererExtension[]
}
/**
* A markdown renderer for tests.
*
* @param content The content to be rendered.
* @param extensions The {@link MarkdownRendererExtension MarkdownExtensions} to use for rendering.
*/
export const TestMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
const lines = useMemo(() => content.split('\n'), [content])
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
return <StoreProvider>{dom}</StoreProvider>
}

View file

@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import equal from 'fast-deep-equal'
import type { RefObject } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import useResizeObserver from '@react-hook/resize-observer'
import type { LineMarkerPosition } from '../extensions/linemarker/types'
import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin'
const calculateLineMarkerPositions = (
documentElement: HTMLDivElement,
currentLineMarkers: LineMarkers[],
offset?: number
): LineMarkerPosition[] => {
const lineMarkers = currentLineMarkers
const children: HTMLCollection = documentElement.children
const lineMarkerPositions: LineMarkerPosition[] = []
Array.from(children).forEach((child, childIndex) => {
const htmlChild = child as HTMLElement
if (htmlChild.offsetTop === undefined) {
return
}
const currentLineMarker = lineMarkers[childIndex]
if (currentLineMarker === undefined) {
return
}
const lastPosition = lineMarkerPositions[lineMarkerPositions.length - 1]
if (!lastPosition || lastPosition.line !== currentLineMarker.startLine) {
lineMarkerPositions.push({
line: currentLineMarker.startLine,
position: htmlChild.offsetTop + (offset ?? 0)
})
}
lineMarkerPositions.push({
line: currentLineMarker.endLine,
position: htmlChild.offsetTop + htmlChild.offsetHeight + (offset ?? 0)
})
})
return lineMarkerPositions
}
/**
* Calculates the positions of the given {@link LineMarkers} in the given {@link Document}.
*
* @param documentElement A reference to the rendered document.
* @param lineMarkers A list of {@link LineMarkers}
* @param onLineMarkerPositionChanged The callback to call if the {@link LineMarkerPosition line marker positions} change e.g. by rendering or resizing.
* @param offset The optional offset
*/
export const useCalculateLineMarkerPosition = (
documentElement: RefObject<HTMLDivElement>,
lineMarkers?: LineMarkers[],
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void,
offset?: number
): void => {
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
const calculateNewLineMarkerPositions = useCallback(() => {
if (!documentElement.current || !onLineMarkerPositionChanged || !lineMarkers) {
return
}
const newLines = calculateLineMarkerPositions(documentElement.current, lineMarkers, offset)
if (!equal(newLines, lastLineMarkerPositions)) {
lastLineMarkerPositions.current = newLines
onLineMarkerPositionChanged(newLines)
}
}, [documentElement, lineMarkers, offset, onLineMarkerPositionChanged])
useEffect(() => {
calculateNewLineMarkerPositions()
}, [calculateNewLineMarkerPositions])
useResizeObserver(documentElement, calculateNewLineMarkerPositions)
}

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { LineIdMapper } from './line-id-mapper'
describe('line id mapper', () => {
let lineIdMapper: LineIdMapper
beforeEach(() => {
lineIdMapper = new LineIdMapper()
})
it('should be case sensitive', () => {
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'Text'])).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'Text',
id: 4
}
])
})
it('should not update line ids of shifted lines', () => {
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'more', 'text'])).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'more',
id: 4
},
{
line: 'text',
id: 3
}
])
})
it('should not update line ids if nothing changes', () => {
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'text'])).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'text',
id: 3
}
])
})
it('should not reuse line ids of removed lines', () => {
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
lineIdMapper.updateLineMapping(['this', 'is'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
it('should update line ids for changed lines', () => {
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
})

View file

@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ArrayChange } from 'diff'
import { diffArrays } from 'diff'
import type { LineWithId } from '../extensions/linemarker/types'
type NewLine = string
type LineChange = ArrayChange<NewLine | LineWithId>
/**
* Calculates ids for every line in a given text and memorized the state of the last given text.
* It also assigns ids for new lines on every update.
*/
export class LineIdMapper {
private lastLines: LineWithId[] = []
private lastUsedLineId = 0
/**
* Calculates a line id mapping for the given line based text by creating a diff
* with the last lines code.
*
* @param newMarkdownContentLines The markdown content for which the line ids should be calculated
* @return the calculated {@link LineWithId lines with unique ids}
*/
public updateLineMapping(newMarkdownContentLines: string[]): LineWithId[] {
const lineDifferences = this.diffNewLinesWithLastLineKeys(newMarkdownContentLines)
const newLineKeys = this.convertChangesToLinesWithIds(lineDifferences)
this.lastLines = newLineKeys
return newLineKeys
}
/**
* Creates a diff between the given {@link string lines} and the existing {@link LineWithId lines with unique ids}.
* The diff is based on the line content.
*
* @param lines The plain lines that describe the new state.
* @return {@link LineChange line changes} that describe the difference between the given and the old lines. Because of the way the used diff-lib works, the ADDED lines will be tagged as "removed", because if two lines are the same the lib takes the line from the NEW lines, which results in a loss of the unique id.
*/
private diffNewLinesWithLastLineKeys(lines: string[]): LineChange[] {
return diffArrays<NewLine, LineWithId>(lines, this.lastLines, {
comparator: (left: NewLine | LineWithId, right: NewLine | LineWithId) => {
const leftLine = (left as LineWithId).line ?? (left as NewLine)
const rightLine = (right as LineWithId).line ?? (right as NewLine)
return leftLine === rightLine
}
})
}
/**
* Converts the given {@link LineChange line changes} to {@link lines with unique ids}.
* Only not changed or added lines will be processed.
*
* @param changes The {@link LineChange changes} whose lines should be converted.
* @return The created or reused {@link LineWithId lines with ids}
*/
private convertChangesToLinesWithIds(changes: LineChange[]): LineWithId[] {
return changes
.filter((change) => LineIdMapper.changeIsNotChangingLines(change) || LineIdMapper.changeIsAddingLines(change))
.reduce(
(previousLineKeys, currentChange) => [...previousLineKeys, ...this.convertChangeToLinesWithIds(currentChange)],
[] as LineWithId[]
)
}
/**
* Defines if the given {@link LineChange change} is neither adding or removing lines.
*
* @param change The {@link LineChange change} to check.
* @return {@link true} if the given change is neither adding nor removing lines.
*/
private static changeIsNotChangingLines(change: LineChange): boolean {
return change.added === undefined && change.removed === undefined
}
/**
* Defines if the given {@link LineChange change} contains new, not existing lines.
*
* @param change The {@link LineChange change} to check.
* @return {@link true} if the given change contains {@link NewLine new lines}
*/
private static changeIsAddingLines(change: LineChange): change is ArrayChange<NewLine> {
return change.removed === true
}
/**
* Converts the given {@link LineChange change} into {@link LineWithId lines with unique ids} by inspecting the contained lines.
* This is done by either reusing the existing ids (if the line wasn't added),
* or by assigning new, unused line ids.
*
* @param change The {@link LineChange change} whose lines should be converted.
* @return The created or reused {@link LineWithId lines with ids}
*/
private convertChangeToLinesWithIds(change: LineChange): LineWithId[] {
if (LineIdMapper.changeIsAddingLines(change)) {
return change.value.map((line) => {
this.lastUsedLineId += 1
return { line: line, id: this.lastUsedLineId }
})
} else {
return change.value as LineWithId[]
}
}
}

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NodeToReactTransformer } from './node-to-react-transformer'
import { Element } from 'domhandler'
import type { ReactElement, ReactHTMLElement } from 'react'
import type { NodeReplacement } from '../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../replace-components/component-replacer'
describe('node to react transformer', () => {
let nodeToReactTransformer: NodeToReactTransformer
let defaultTestSpanElement: Element
beforeEach(() => {
defaultTestSpanElement = new Element('span', { 'data-test': 'test' })
nodeToReactTransformer = new NodeToReactTransformer()
})
describe('replacement', () => {
it('can translate an element without any replacer', () => {
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation.type).toEqual('span')
expect(translation.props).toEqual({ children: [], 'data-test': 'test' })
})
it('can replace an element nothing', () => {
nodeToReactTransformer.setReplacers([
new (class extends ComponentReplacer {
replace(): NodeReplacement {
return null
}
})()
])
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation).toEqual(null)
})
it('can translate an element with no matching replacer', () => {
nodeToReactTransformer.setReplacers([
new (class extends ComponentReplacer {
replace(): NodeReplacement {
return DO_NOT_REPLACE
}
})()
])
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation.type).toEqual('span')
expect(translation.props).toEqual({ children: [], 'data-test': 'test' })
})
it('can replace an element', () => {
nodeToReactTransformer.setReplacers([
new (class extends ComponentReplacer {
replace(): NodeReplacement {
return <div data-test2={'test2'} />
}
})()
])
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation.type).toEqual('div')
expect(translation.props).toEqual({ 'data-test2': 'test2' })
})
})
describe('key calculation', () => {
beforeEach(() => {
nodeToReactTransformer.setLineIds([
{
id: 1,
line: 'test'
}
])
})
it('can calculate a fallback key', () => {
const result = nodeToReactTransformer.translateNodeToReactElement(
defaultTestSpanElement,
1
) as ReactHTMLElement<HTMLDivElement>
expect(result.type).toEqual('span')
expect(result.key).toEqual('-1')
})
it('can calculate a key based on line markers and line keys', () => {
const lineMarker = new Element('app-linemarker', { 'data-start-line': '1', 'data-end-line': '2' })
defaultTestSpanElement.prev = lineMarker
const rootElement: Element = new Element('div', {}, [lineMarker, defaultTestSpanElement])
const result = nodeToReactTransformer.translateNodeToReactElement(
rootElement,
1
) as ReactHTMLElement<HTMLDivElement>
const resultSpanTag = (result.props.children as ReactElement[])[1]
expect(result.type).toEqual('div')
expect(resultSpanTag.type).toEqual('span')
expect(resultSpanTag.key).toEqual('1_1')
})
})
})

View file

@ -0,0 +1,183 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element, Node } from 'domhandler'
import { isTag } from 'domhandler'
import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
import type { ComponentReplacer, NodeReplacement, ValidReactDomElement } from '../replace-components/component-replacer'
import { DO_NOT_REPLACE } from '../replace-components/component-replacer'
import React from 'react'
import { Optional } from '@mrdrogdrog/optional'
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
import type { LineWithId } from '../extensions/linemarker/types'
type LineIndexPair = [startLineIndex: number, endLineIndex: number]
/**
* Converts {@link Node domhandler nodes} to react elements by using direct translation or {@link ComponentReplacer replacers}.
*/
export class NodeToReactTransformer {
private lineIds: LineWithId[] = []
private replacers: ComponentReplacer[] = []
public setLineIds(lineIds: LineWithId[]): void {
this.lineIds = lineIds
}
public setReplacers(replacers: ComponentReplacer[]): void {
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.
*
* @param node The {@link Node DOM node} that should be translated.
* @param index The index of the node within its parents child list.
* @return the created react element
*/
public translateNodeToReactElement(node: Node, index: number | string): ValidReactDomElement {
return isTag(node)
? this.translateElementToReactElement(node, index)
: convertNodeToReactElement(node, index, this.translateNodeToReactElement.bind(this))
}
/**
* Translates the given {@link Element} to a react element.
*
* @param element The {@link Element DOM element} that should be translated.
* @param index The index of the element within its parents child list.
* @return the created react element
*/
private translateElementToReactElement(element: Element, index: number | string): ValidReactDomElement {
const elementKey = this.calculateUniqueKey(element).orElseGet(() => (-index).toString())
const replacement = this.findElementReplacement(element, elementKey)
if (replacement === null) {
return null
} else if (replacement === DO_NOT_REPLACE) {
return this.renderNativeNode(element, elementKey)
} else if (typeof replacement === 'string') {
return replacement
} else {
return React.cloneElement(replacement, {
...(replacement.props as Record<string, unknown>),
key: elementKey
})
}
}
/**
* Calculates the unique key for the given {@link Element}.
*
* @param element The element for which the unique key should be calculated.
* @return An {@link Optional} that contains the unique key or is empty if no key could be found.
*/
private calculateUniqueKey(element: Element): Optional<string> {
if (!element.attribs) {
return Optional.empty()
}
return Optional.ofNullable(element.prev)
.map((lineMarker) => NodeToReactTransformer.extractLineIndexFromLineMarker(lineMarker))
.map(([startLineIndex, endLineIndex]) =>
NodeToReactTransformer.convertMarkdownItLineIndexesToInternalLineIndexes(startLineIndex, endLineIndex)
)
.flatMap((adjustedLineIndexes) => this.findLineIdsByIndex(adjustedLineIndexes))
.map(([startLine, endLine]) => `${startLine.id}_${endLine.id}`)
}
/**
* Asks every saved replacer if the given {@link Element element} should be
* replaced with another react element or not.
*
* @param element The {@link Element} that should be checked.
* @param elementKey The unique key for the element
* @return The replacement or {@link DO_NOT_REPLACE} if the element shouldn't be replaced with a custom component.
*/
private findElementReplacement(element: Element, elementKey: string): NodeReplacement {
const transformer = this.translateNodeToReactElement.bind(this)
const nativeRenderer = () => this.renderNativeNode(element, elementKey)
for (const componentReplacer of this.replacers) {
const replacement = componentReplacer.replace(element, transformer, nativeRenderer)
if (replacement !== DO_NOT_REPLACE) {
return replacement
}
}
return DO_NOT_REPLACE
}
/**
* Extracts the start and end line indexes that are saved in a line marker element
* and describe in which line, in the markdown code, the node before the marker ends
* and which the node after the marker starts.
*
* @param lineMarker The line marker that saves a start and end line index.
* @return the extracted line indexes
*/
private static extractLineIndexFromLineMarker(lineMarker: Node): LineIndexPair | undefined {
if (!isTag(lineMarker) || lineMarker.tagName !== LinemarkerMarkdownExtension.tagName || !lineMarker.attribs) {
return
}
const startLineInMarkdown = lineMarker.attribs['data-start-line']
const endLineInMarkdown = lineMarker.attribs['data-end-line']
if (startLineInMarkdown === undefined || endLineInMarkdown === undefined) {
return
}
const startLineIndex = Number(startLineInMarkdown)
const endLineIndex = Number(endLineInMarkdown)
if (isNaN(startLineIndex) || isNaN(endLineIndex)) {
return
}
return [startLineIndex, endLineIndex]
}
/**
* Converts markdown it line indexes to internal line indexes.
* The differences are:
* - Markdown it starts to count at 1, but we start at 0
* - Line indexes in markdown it are start(inclusive) to end(exclusive). But we need start(inclusive) to end(inclusive).
*
* @param startLineIndex The start line index from markdown it
* @param endLineIndex The end line index from markdown it
* @return The adjusted start and end line index
*/
private static convertMarkdownItLineIndexesToInternalLineIndexes(
startLineIndex: number,
endLineIndex: number
): LineIndexPair {
return [startLineIndex - 1, endLineIndex - 2]
}
/**
* Renders the given node without any replacement
*
* @param node The node to render
* @param key The unique key for the node
* @return The rendered {@link ValidReactDomElement}
*/
private renderNativeNode = (node: Element, key: string): ValidReactDomElement => {
if (node.attribs === undefined) {
node.attribs = {}
}
return convertNodeToReactElement(node, key, this.translateNodeToReactElement.bind(this))
}
private findLineIdsByIndex([startLineIndex, endLineIndex]: LineIndexPair): Optional<[LineWithId, LineWithId]> {
const startLine = this.lineIds[startLineIndex]
const endLine = this.lineIds[endLineIndex]
return startLine === undefined || endLine === undefined ? Optional.empty() : Optional.of([startLine, endLine])
}
}