Refactor replacers and line id mapping

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-10-25 00:13:40 +02:00 committed by GitHub
parent 3591c90f9f
commit ec77e672f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 899 additions and 750 deletions

View file

@ -138,6 +138,7 @@
"markmap-view": "0.2.6",
"@matejmazur/react-katex": "3.1.3",
"mermaid": "8.13.3",
"optional-js": "2.3.0",
"prettier": "2.4.1",
"react": "17.0.2",
"react-bootstrap": "1.6.4",

View file

@ -16,9 +16,9 @@ import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-po
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change'
import { BasicMarkdownItConfigurator } from './markdown-it-configurator/basic-markdown-it-configurator'
import { useTrimmedContent } from './hooks/use-trimmed-content'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { DocumentMarkdownItConfigurator } from './markdown-it-configurator/document-markdown-it-configurator'
export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps {
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
@ -44,15 +44,14 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
const markdownIt = useMemo(
() =>
new BasicMarkdownItConfigurator({
onToc: (toc) => (tocAst.current = toc),
new DocumentMarkdownItConfigurator({
onTocChange: (toc) => (tocAst.current = toc),
onLineMarkers:
onLineMarkerPositionChanged === undefined
? undefined
: (lineMarkers) => (currentLineMarkers.current = lineMarkers),
useAlternativeBreaks,
lineOffset,
headlineAnchors: true
lineOffset
}).buildConfiguredMarkdownIt(),
[onLineMarkerPositionChanged, useAlternativeBreaks, lineOffset]
)

View file

@ -5,28 +5,30 @@
*/
import { useMemo } from 'react'
import { AbcReplacer } from '../replace-components/abc/abc-replacer'
import { AsciinemaReplacer } from '../replace-components/asciinema/asciinema-replacer'
import type { ComponentReplacer } from '../replace-components/ComponentReplacer'
import type { ComponentReplacer } from '../replace-components/component-replacer'
import { CsvReplacer } from '../replace-components/csv/csv-replacer'
import { FlowchartReplacer } from '../replace-components/flow/flowchart-replacer'
import { GistReplacer } from '../replace-components/gist/gist-replacer'
import { GraphvizReplacer } from '../replace-components/graphviz/graphviz-replacer'
import { HighlightedCodeReplacer } from '../replace-components/highlighted-fence/highlighted-fence-replacer'
import type { ImageClickHandler } from '../replace-components/image/image-replacer'
import { ImageReplacer } from '../replace-components/image/image-replacer'
import { KatexReplacer } from '../replace-components/katex/katex-replacer'
import { LinemarkerReplacer } from '../replace-components/linemarker/linemarker-replacer'
import { LinkReplacer } from '../replace-components/link-replacer/link-replacer'
import { MarkmapReplacer } from '../replace-components/markmap/markmap-replacer'
import { MermaidReplacer } from '../replace-components/mermaid/mermaid-replacer'
import { ColoredBlockquoteReplacer } from '../replace-components/colored-blockquote/colored-blockquote-replacer'
import { SequenceDiagramReplacer } from '../replace-components/sequence-diagram/sequence-diagram-replacer'
import type { TaskCheckedChangeHandler } from '../replace-components/task-list/task-list-replacer'
import { TaskListReplacer } from '../replace-components/task-list/task-list-replacer'
import { VegaReplacer } from '../replace-components/vega-lite/vega-replacer'
import { VimeoReplacer } from '../replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from '../replace-components/youtube/youtube-replacer'
import { CodeBlockComponentReplacer } from '../replace-components/code-block-component-replacer'
import { GraphvizFrame } from '../replace-components/graphviz/graphviz-frame'
import { MarkmapFrame } from '../replace-components/markmap/markmap-frame'
import { VegaChart } from '../replace-components/vega-lite/vega-chart'
import { MermaidChart } from '../replace-components/mermaid/mermaid-chart'
import { FlowChart } from '../replace-components/flow/flowchart'
import { SequenceDiagram } from '../replace-components/sequence-diagram/sequence-diagram'
import { AbcFrame } from '../replace-components/abc/abc-frame'
import { CustomTagWithIdComponentReplacer } from '../replace-components/custom-tag-with-id-component-replacer'
import { GistFrame } from '../replace-components/gist/gist-frame'
import { YouTubeFrame } from '../replace-components/youtube/youtube-frame'
import { VimeoFrame } from '../replace-components/vimeo/vimeo-frame'
import { AsciinemaFrame } from '../replace-components/asciinema/asciinema-frame'
/**
* Provides a function that creates a list of {@link ComponentReplacer component replacer} instances.
@ -47,23 +49,23 @@ export const useComponentReplacers = (
useMemo(
() => [
new LinemarkerReplacer(),
new GistReplacer(),
new YoutubeReplacer(),
new VimeoReplacer(),
new AsciinemaReplacer(),
new AbcReplacer(),
new CustomTagWithIdComponentReplacer(GistFrame, 'gist'),
new CustomTagWithIdComponentReplacer(YouTubeFrame, 'youtube'),
new CustomTagWithIdComponentReplacer(VimeoFrame, 'vimeo'),
new CustomTagWithIdComponentReplacer(AsciinemaFrame, 'asciinema'),
new ImageReplacer(onImageClick),
new SequenceDiagramReplacer(),
new CsvReplacer(),
new FlowchartReplacer(),
new MermaidReplacer(),
new GraphvizReplacer(),
new MarkmapReplacer(),
new VegaReplacer(),
new CodeBlockComponentReplacer(AbcFrame, 'abc'),
new CodeBlockComponentReplacer(SequenceDiagram, 'sequence'),
new CodeBlockComponentReplacer(FlowChart, 'flow'),
new CodeBlockComponentReplacer(MermaidChart, 'mermaid'),
new CodeBlockComponentReplacer(GraphvizFrame, 'graphviz'),
new CodeBlockComponentReplacer(MarkmapFrame, 'markmap'),
new CodeBlockComponentReplacer(VegaChart, 'vega-lite'),
new HighlightedCodeReplacer(),
new ColoredBlockquoteReplacer(),
new KatexReplacer(),
new TaskListReplacer(onTaskCheckedChange, frontmatterLinesToSkip),
new TaskListReplacer(frontmatterLinesToSkip, onTaskCheckedChange),
new LinkReplacer(baseUrl)
],
[onImageClick, onTaskCheckedChange, baseUrl, frontmatterLinesToSkip]

View file

@ -5,13 +5,12 @@
*/
import type MarkdownIt from 'markdown-it/lib'
import { useMemo, useRef } from 'react'
import type { ComponentReplacer, ValidReactDomElement } from '../replace-components/ComponentReplacer'
import type { LineKeys } from '../types'
import { buildTransformer } from '../utils/html-react-transformer'
import { calculateNewLineNumberMapping } from '../utils/line-number-mapping'
import { useMemo } from 'react'
import type { ComponentReplacer, ValidReactDomElement } from '../replace-components/component-replacer'
import convertHtmlToReact from '@hedgedoc/html-to-react'
import type { Document } from 'domhandler'
import { NodeToReactTransformer } from '../utils/node-to-react-transformer'
import { LineIdMapper } from '../utils/line-id-mapper'
/**
* Renders markdown code into react elements
@ -28,21 +27,22 @@ export const useConvertMarkdownToReactDom = (
replacers: ComponentReplacer[],
preprocessNodes?: (nodes: Document) => Document
): ValidReactDomElement[] => {
const oldMarkdownLineKeys = useRef<LineKeys[]>()
const lastUsedLineId = useRef<number>(0)
const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
useMemo(() => {
htmlToReactTransformer.setReplacers(replacers)
}, [htmlToReactTransformer, replacers])
useMemo(() => {
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownCode))
}, [htmlToReactTransformer, lineNumberMapper, markdownCode])
return useMemo(() => {
const html = markdownIt.render(markdownCode)
const contentLines = markdownCode.split('\n')
const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping(
contentLines,
oldMarkdownLineKeys.current ?? [],
lastUsedLineId.current
)
oldMarkdownLineKeys.current = newLines
lastUsedLineId.current = newLastUsedLineId
const transformer = replacers.length > 0 ? buildTransformer(newLines, replacers) : undefined
return convertHtmlToReact(html, { transform: transformer, preprocessNodes: preprocessNodes })
}, [markdownIt, markdownCode, replacers, preprocessNodes])
return convertHtmlToReact(html, {
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: preprocessNodes
})
}, [htmlToReactTransformer, markdownCode, markdownIt, preprocessNodes])
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Configuration } from './markdown-it-configurator'
import { MarkdownItConfigurator } from './markdown-it-configurator'
import { headlineAnchors } from '../markdown-it-plugins/headline-anchors'
import type { LineMarkers } from '../replace-components/linemarker/line-number-marker'
import { lineNumberMarker } from '../replace-components/linemarker/line-number-marker'
export interface DocumentConfiguration extends Configuration {
onLineMarkers?: (lineMarkers: LineMarkers[]) => void
}
export class DocumentMarkdownItConfigurator extends MarkdownItConfigurator<DocumentConfiguration> {
protected configure(): void {
super.configure()
this.configurations.push(headlineAnchors)
if (this.options.onLineMarkers) {
this.configurations.push(lineNumberMarker(this.options.onLineMarkers, this.options.lineOffset ?? 0))
}
}
}

View file

@ -20,45 +20,34 @@ import { spoilerContainer } from '../markdown-it-plugins/spoiler-container'
import { tasksLists } from '../markdown-it-plugins/tasks-lists'
import { twitterEmojis } from '../markdown-it-plugins/twitter-emojis'
import type { TocAst } from 'markdown-it-toc-done-right'
import type { LineMarkers } from '../replace-components/linemarker/line-number-marker'
import { lineNumberMarker } from '../replace-components/linemarker/line-number-marker'
import { plantumlWithError } from '../markdown-it-plugins/plantuml'
import { headlineAnchors } from '../markdown-it-plugins/headline-anchors'
import { KatexReplacer } from '../replace-components/katex/katex-replacer'
import { YoutubeReplacer } from '../replace-components/youtube/youtube-replacer'
import { VimeoReplacer } from '../replace-components/vimeo/vimeo-replacer'
import { GistReplacer } from '../replace-components/gist/gist-replacer'
import { legacyPdfShortCode } from '../regex-plugins/replace-legacy-pdf-short-code'
import { legacySlideshareShortCode } from '../regex-plugins/replace-legacy-slideshare-short-code'
import { legacySpeakerdeckShortCode } from '../regex-plugins/replace-legacy-speakerdeck-short-code'
import { AsciinemaReplacer } from '../replace-components/asciinema/asciinema-replacer'
import { highlightedCode } from '../markdown-it-plugins/highlighted-code'
import { quoteExtraColor } from '../markdown-it-plugins/quote-extra-color'
import { quoteExtra } from '../markdown-it-plugins/quote-extra'
import { documentTableOfContents } from '../markdown-it-plugins/document-table-of-contents'
import { addSlideSectionsMarkdownItPlugin } from '../markdown-it-plugins/reveal-sections'
import { youtubeMarkdownItPlugin } from '../replace-components/youtube/youtube-markdown-it-plugin'
import { vimeoMarkdownItPlugin } from '../replace-components/vimeo/vimeo-markdown-it-plugin'
import { gistMarkdownItPlugin } from '../replace-components/gist/gist-markdown-it-plugin'
import { asciinemaMarkdownItPlugin } from '../replace-components/asciinema/replace-asciinema-link'
export interface ConfiguratorDetails {
onToc: (toc: TocAst) => void
onLineMarkers?: (lineMarkers: LineMarkers[]) => void
export interface Configuration {
onTocChange: (toc: TocAst) => void
useAlternativeBreaks?: boolean
lineOffset?: number
headlineAnchors?: boolean
slideSections?: boolean
}
export class BasicMarkdownItConfigurator<T extends ConfiguratorDetails> {
export abstract class MarkdownItConfigurator<T extends Configuration> {
protected readonly options: T
protected configurations: MarkdownIt.PluginSimple[] = []
protected postConfigurations: MarkdownIt.PluginSimple[] = []
constructor(options: T) {
this.options = options
}
public pushConfig(plugin: MarkdownIt.PluginSimple): this {
this.configurations.push(plugin)
return this
this.configure()
}
public buildConfiguredMarkdownIt(): MarkdownIt {
@ -68,28 +57,27 @@ export class BasicMarkdownItConfigurator<T extends ConfiguratorDetails> {
langPrefix: '',
typographer: true
})
this.configure(markdownIt)
this.configurations.forEach((configuration) => markdownIt.use(configuration))
this.postConfigurations.forEach((postConfiguration) => markdownIt.use(postConfiguration))
return markdownIt
}
protected configure(markdownIt: MarkdownIt): void {
protected configure(): void {
this.configurations.push(
plantumlWithError,
KatexReplacer.markdownItPlugin,
YoutubeReplacer.markdownItPlugin,
VimeoReplacer.markdownItPlugin,
GistReplacer.markdownItPlugin,
youtubeMarkdownItPlugin,
vimeoMarkdownItPlugin,
gistMarkdownItPlugin,
asciinemaMarkdownItPlugin,
legacyPdfShortCode,
legacySlideshareShortCode,
legacySpeakerdeckShortCode,
AsciinemaReplacer.markdownItPlugin,
highlightedCode,
quoteExtraColor,
quoteExtra('name', 'user'),
quoteExtra('time', 'clock-o'),
documentTableOfContents(this.options.onToc),
documentTableOfContents(this.options.onTocChange),
twitterEmojis,
abbreviation,
definitionList,
@ -104,18 +92,6 @@ export class BasicMarkdownItConfigurator<T extends ConfiguratorDetails> {
spoilerContainer
)
if (this.options.headlineAnchors) {
this.configurations.push(headlineAnchors)
}
if (this.options.slideSections) {
this.configurations.push(addSlideSectionsMarkdownItPlugin)
}
if (this.options.onLineMarkers) {
this.configurations.push(lineNumberMarker(this.options.onLineMarkers, this.options.lineOffset ?? 0))
}
this.postConfigurations.push(linkifyExtra, MarkdownItParserDebugger)
}
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Configuration } from './markdown-it-configurator'
import { MarkdownItConfigurator } from './markdown-it-configurator'
import { addSlideSectionsMarkdownItPlugin } from '../markdown-it-plugins/reveal-sections'
export class SlideshowMarkdownItConfigurator extends MarkdownItConfigurator<Configuration> {
protected configure(): void {
super.configure()
this.configurations.push(addSlideSectionsMarkdownItPlugin)
}
}

View file

@ -8,7 +8,7 @@ import type MarkdownIt from 'markdown-it'
import markdownItContainer from 'markdown-it-container'
import type Renderer from 'markdown-it/lib/renderer'
import type Token from 'markdown-it/lib/token'
import type { MarkdownItPlugin } from '../replace-components/ComponentReplacer'
import type { MarkdownItPlugin } from '../replace-components/component-replacer'
export type RenderContainerReturn = (
tokens: Token[],

View file

@ -11,7 +11,7 @@ import type { RenderRule } from 'markdown-it/lib/renderer'
import type Renderer from 'markdown-it/lib/renderer'
import type Token from 'markdown-it/lib/token'
import { store } from '../../../redux'
import type { MarkdownItPlugin } from '../replace-components/ComponentReplacer'
import type { MarkdownItPlugin } from '../replace-components/component-replacer'
export const plantumlWithError: MarkdownItPlugin = (markdownIt: MarkdownIt) => {
const plantumlServer = store.getState().config.plantumlServer

View file

@ -8,7 +8,7 @@ import type MarkdownIt from 'markdown-it'
import { escapeHtml } from 'markdown-it/lib/common/utils'
import markdownItContainer from 'markdown-it-container'
import type Token from 'markdown-it/lib/token'
import type { MarkdownItPlugin } from '../replace-components/ComponentReplacer'
import type { MarkdownItPlugin } from '../replace-components/component-replacer'
import type { RenderContainerReturn } from './alert-container'
export const spoilerRegEx = /^spoiler\s+(.*)$/

View file

@ -7,14 +7,11 @@
import React, { useEffect, useRef } from 'react'
import './abc.scss'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('AbcFrame')
export interface AbcFrameProps {
code: string
}
export const AbcFrame: React.FC<AbcFrameProps> = ({ code }) => {
export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -23,8 +20,8 @@ export const AbcFrame: React.FC<AbcFrameProps> = ({ code }) => {
}
const actualContainer = container.current
import(/* webpackChunkName: "abc.js" */ 'abcjs')
.then((imp) => {
imp.renderAbc(actualContainer, code, {})
.then((importedLibrary) => {
importedLibrary.renderAbc(actualContainer, code, {})
})
.catch((error: Error) => {
log.error('Error while loading abcjs', error)

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { AbcFrame } from './abc-frame'
/**
* Detects code blocks with "abc" as language and renders them as ABC.js
*/
export class AbcReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'abc' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <AbcFrame code={code} />
}
}

View file

@ -6,12 +6,9 @@
import React from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
export interface AsciinemaFrameProps {
id: string
}
export const AsciinemaFrame: React.FC<AsciinemaFrameProps> = ({ id }) => {
export const AsciinemaFrame: React.FC<IdProps> = ({ id }) => {
return (
<OneClickEmbedding
containerClassName={'embed-responsive embed-responsive-16by9'}

View file

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { getAttributesFromHedgeDocTag } from '../utils'
import { AsciinemaFrame } from './asciinema-frame'
import { replaceAsciinemaLink } from './replace-asciinema-link'
/**
* Detects code blocks with "asciinema" as language and renders them Asciinema frame
*/
export class AsciinemaReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceAsciinemaLink)
}
public getReplacement(node: Element): React.ReactElement | undefined {
const attributes = getAttributesFromHedgeDocTag(node, 'asciinema')
if (attributes && attributes.id) {
const asciinemaId = attributes.id
return <AsciinemaFrame id={asciinemaId} />
}
}
}

View file

@ -5,6 +5,8 @@
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import type MarkdownIt from 'markdown-it/lib'
import markdownItRegex from 'markdown-it-regex'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:asciinema\.org\/a\/)/
@ -13,6 +15,10 @@ const tailRegex = /(?:[./?#].*)?/
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
export const asciinemaMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
markdownItRegex(markdownIt, replaceAsciinemaLink)
}
export const replaceAsciinemaLink: RegexOptions = {
name: 'asciinema-link',
regex: linkRegex,

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ValidReactDomElement } from './component-replacer'
import { ComponentReplacer } 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): ValidReactDomElement | undefined {
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(node, this.language)
return code ? React.createElement(this.component, { code: code }) : undefined
}
/**
* 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

@ -6,8 +6,8 @@
import type { Element } from 'domhandler'
import { isTag } from 'domhandler'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../ComponentReplacer'
import { ComponentReplacer } from '../ComponentReplacer'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../component-replacer'
import { ComponentReplacer } from '../component-replacer'
/**
* Checks if the given node is a blockquote color definition
@ -42,7 +42,7 @@ const findBlockquoteColorParentElement = (nodes: Element[]): Element | undefined
* If a color tag was found then the color will be applied to the node as border.
*/
export class ColoredBlockquoteReplacer extends ComponentReplacer {
public getReplacement(
public replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element, NodeWithChildren } from 'domhandler'
import type { Element } from 'domhandler'
import { isText } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import type { ReactElement } from 'react'
@ -17,6 +17,10 @@ export type NativeRenderer = () => ValidReactDomElement
export type MarkdownItPlugin = MarkdownIt.PluginSimple | MarkdownIt.PluginWithOptions | MarkdownIt.PluginWithParams
export const REPLACE_WITH_NOTHING = null
export const DO_NOT_REPLACE = undefined
export type NodeReplacement = ValidReactDomElement | typeof REPLACE_WITH_NOTHING | typeof DO_NOT_REPLACE
/**
* Base class for all component replacers.
* Component replacers detect structures in the HTML DOM from markdown it
@ -29,7 +33,7 @@ export abstract class ComponentReplacer {
* @param node the node with the text node child
* @return the string content
*/
protected static extractTextChildContent(node: NodeWithChildren): string {
protected static extractTextChildContent(node: Element): string {
const childrenTextNode = node.children[0]
return isText(childrenTextNode) ? childrenTextNode.data : ''
}
@ -39,12 +43,12 @@ export abstract class ComponentReplacer {
*
* @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 as it is without any replacement.
* @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 getReplacement(
public abstract replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): ValidReactDomElement | undefined
): NodeReplacement
}

View file

@ -6,27 +6,20 @@
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
import { CsvTable } from './csv-table'
import { CodeBlockComponentReplacer } from '../code-block-component-replacer'
/**
* Detects code blocks with "csv" as language and renders them as table.
*/
export class CsvReplacer extends ComponentReplacer {
public getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'csv' ||
!codeNode.children ||
!codeNode.children[0]
) {
public replace(codeNode: Element): React.ReactElement | undefined {
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(codeNode, 'csv')
if (!code) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
const extraData = codeNode.attribs['data-extra']
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
const extraInfos = extraRegex.exec(extraData)

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from './component-replacer'
import { ComponentReplacer } 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 }) : undefined
}
/**
* 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 === `app-${this.tagName}` && element.attribs && element.attribs.id
? element.attribs.id
: undefined
}
}

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { FlowChart } from './flowchart/flowchart'
/**
* Detects code blocks with "flow" as language and renders them as flow chart.
*/
export class FlowchartReplacer extends ComponentReplacer {
public getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'flow' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <FlowChart code={code} />
}
}

View file

@ -7,9 +7,9 @@
import React, { useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useIsDarkModeActivated } from '../../../../../hooks/common/use-is-dark-mode-activated'
import { Logger } from '../../../../../utils/logger'
import { cypressId } from '../../../../../utils/cypress-attribute'
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
const log = new Logger('FlowChart')
@ -30,8 +30,8 @@ export const FlowChart: React.FC<FlowChartProps> = ({ code }) => {
}
const currentDiagramRef = diagramRef.current
import(/* webpackChunkName: "flowchart.js" */ 'flowchart.js')
.then((imp) => {
const parserOutput = imp.parse(code)
.then((importedLibrary) => {
const parserOutput = importedLibrary.parse(code)
try {
parserOutput.drawSVG(currentDiagramRef, {
'line-width': 2,

View file

@ -8,17 +8,16 @@ import React, { useCallback } from 'react'
import { cypressId } from '../../../../utils/cypress-attribute'
import './gist-frame.scss'
import { useResizeGistFrame } from './use-resize-gist-frame'
export interface GistFrameProps {
id: string
}
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import preview from './gist-preview.png'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
/**
* This component renders a GitHub Gist by placing the gist URL in an {@link HTMLIFrameElement iframe}.
*
* @param id The id of the gist
*/
export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
export const GistFrame: React.FC<IdProps> = ({ id }) => {
const [frameHeight, onStartResizing] = useResizeGistFrame(150)
const onStart = useCallback(
@ -29,7 +28,7 @@ export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
)
return (
<span>
<OneClickEmbedding previewContainerClassName={'gist-frame'} loadingImageUrl={preview} hoverIcon={'github'}>
<iframe
sandbox=''
{...cypressId('gh-gist')}
@ -42,6 +41,6 @@ export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
<span className={'gist-resizer-row'}>
<span className={'gist-resizer'} onMouseDown={onStart} onTouchStart={onStart} />
</span>
</span>
</OneClickEmbedding>
)
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import { replaceGistLink } from './replace-gist-link'
import { replaceLegacyGistShortCode } from './replace-legacy-gist-short-code'
export const gistMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceGistLink)
markdownItRegex(markdownIt, replaceLegacyGistShortCode)
}

View file

@ -1,43 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { getAttributesFromHedgeDocTag } from '../utils'
import { GistFrame } from './gist-frame'
import preview from './gist-preview.png'
import { replaceGistLink } from './replace-gist-link'
import { replaceLegacyGistShortCode } from './replace-legacy-gist-short-code'
/**
* Detects "app-gist" tags and renders them as gist frames.
*/
export class GistReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceGistLink)
markdownItRegex(markdownIt, replaceLegacyGistShortCode)
}
public getReplacement(node: Element): React.ReactElement | undefined {
const attributes = getAttributesFromHedgeDocTag(node, 'gist')
if (attributes && attributes.id) {
const gistId = attributes.id
return (
<OneClickEmbedding
previewContainerClassName={'gist-frame'}
loadingImageUrl={preview}
hoverIcon={'github'}
tooltip={'click to load gist'}>
<GistFrame id={gistId} />
</OneClickEmbedding>
)
}
}
}

View file

@ -10,14 +10,11 @@ import { ShowIf } from '../../../common/show-if/show-if'
import { useFrontendBaseUrl } from '../../../../hooks/common/use-frontend-base-url'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('GraphvizFrame')
export interface GraphvizFrameProps {
code: string
}
export const GraphvizFrame: React.FC<GraphvizFrameProps> = ({ code }) => {
export const GraphvizFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { GraphvizFrame } from './graphviz-frame'
/**
* Detects code blocks with "graphviz" as language and renders them as graphviz graph.
*/
export class GraphvizReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'graphviz' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <GraphvizFrame code={code} />
}
}

View file

@ -6,7 +6,7 @@
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
import { HighlightedCode } from './highlighted-code/highlighted-code'
/**
@ -15,14 +15,15 @@ import { HighlightedCode } from './highlighted-code/highlighted-code'
export class HighlightedCodeReplacer extends ComponentReplacer {
private lastLineNumber = 0
public getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
!codeNode.children ||
!codeNode.children[0]
) {
private extractCode(codeNode: Element): string | undefined {
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
? ComponentReplacer.extractTextChildContent(codeNode)
: undefined
}
public replace(codeNode: Element): React.ReactElement | undefined {
const code = this.extractCode(codeNode)
if (!code) {
return
}
@ -42,7 +43,6 @@ export class HighlightedCodeReplacer extends ComponentReplacer {
const startLineNumber =
startLineNumberAttribute === '+' ? this.lastLineNumber : parseInt(startLineNumberAttribute) || 1
const code = ComponentReplacer.extractTextChildContent(codeNode)
if (showLineNumbers) {
this.lastLineNumber = startLineNumber + code.split('\n').filter((line) => !!line).length

View file

@ -6,7 +6,7 @@
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
import { ProxyImageFrame } from './proxy-image-frame'
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void
@ -22,8 +22,8 @@ export class ImageReplacer extends ComponentReplacer {
this.clickHandler = clickHandler
}
public getReplacement(node: Element): React.ReactElement | undefined {
if (node.name === 'img' && node.attribs) {
public replace(node: Element): React.ReactElement | undefined {
if (node.name === 'img') {
return (
<ProxyImageFrame
id={node.attribs.id}

View file

@ -9,7 +9,7 @@ import { isTag } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import mathJax from 'markdown-it-mathjax'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
import './katex.scss'
/**
@ -52,7 +52,7 @@ export class KatexReplacer extends ComponentReplacer {
afterDisplayMath: '</app-katex>'
})
public getReplacement(node: Element): React.ReactElement | undefined {
public replace(node: Element): React.ReactElement | undefined {
const katex = getNodeIfKatexBlock(node) || getNodeIfInlineKatex(node)
if (katex?.children && katex.children[0]) {
const mathJaxContent = ComponentReplacer.extractTextChildContent(katex)

View file

@ -5,13 +5,13 @@
*/
import type { Element } from 'domhandler'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
/**
* Detects line markers and suppresses them in the resulting DOM.
*/
export class LinemarkerReplacer extends ComponentReplacer {
public getReplacement(codeNode: Element): null | undefined {
public replace(codeNode: Element): null | undefined {
return codeNode.name === 'app-linemarker' ? null : undefined
}
}

View file

@ -5,8 +5,8 @@
*/
import type { Element } from 'domhandler'
import React from 'react'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../ComponentReplacer'
import { ComponentReplacer } from '../ComponentReplacer'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../component-replacer'
import { ComponentReplacer } from '../component-replacer'
export const createJumpToMarkClickEventHandler = (id: string) => {
return (event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
@ -25,7 +25,7 @@ export class LinkReplacer extends ComponentReplacer {
super()
}
public getReplacement(
public replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer

View file

@ -10,18 +10,15 @@ import { LockButton } from '../../../common/lock-button/lock-button'
import '../../utils/button-inside.scss'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('MarkmapFrame')
export interface MarkmapFrameProps {
code: string
}
const blockHandler = (event: Event): void => {
event.stopPropagation()
}
export const MarkmapFrame: React.FC<MarkmapFrameProps> = ({ code }) => {
export const MarkmapFrame: React.FC<CodeProps> = ({ code }) => {
const { t } = useTranslation()
const diagramContainer = useRef<HTMLDivElement>(null)
const [disablePanAndZoom, setDisablePanAndZoom] = useState(true)

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { MarkmapFrame } from './markmap-frame'
/**
* Detects code blocks with 'markmap' as language and renders them with Markmap.
*/
export class MarkmapReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'markmap' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <MarkmapFrame code={code} />
}
}

View file

@ -10,11 +10,9 @@ import { useTranslation } from 'react-i18next'
import { ShowIf } from '../../../common/show-if/show-if'
import './mermaid.scss'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('MermaidChart')
export interface MermaidChartProps {
code: string
}
interface MermaidParseError {
str: string
@ -22,7 +20,7 @@ interface MermaidParseError {
let mermaidInitialized = false
export const MermaidChart: React.FC<MermaidChartProps> = ({ code }) => {
export const MermaidChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const { t } = useTranslation()

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { MermaidChart } from './mermaid-chart'
/**
* Detects code blocks with 'mermaid' as language and renders them with mermaid.
*/
export class MermaidReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'mermaid' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <MermaidChart code={code} />
}
}

View file

@ -1,39 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React, { Fragment } from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { MermaidChart } from '../mermaid/mermaid-chart'
import { DeprecationWarning } from './deprecation-warning'
/**
* Detects code blocks with 'sequence' as language and renders them as
* sequence diagram with mermaid.
*/
export class SequenceDiagramReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'sequence' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return (
<Fragment>
<DeprecationWarning />
<MermaidChart code={'sequenceDiagram\n' + code} />
</Fragment>
)
}
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import type { CodeProps } from '../code-block-component-replacer'
import { MermaidChart } from '../mermaid/mermaid-chart'
import { DeprecationWarning } from './deprecation-warning'
/**
* Renders a sequence diagram with a deprecation notice.
*
* @param code the sequence diagram code
*/
export const SequenceDiagram: React.FC<CodeProps> = ({ code }) => {
return (
<Fragment>
<DeprecationWarning />
<MermaidChart code={'sequenceDiagram\n' + code} />
</Fragment>
)
}

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
export interface TaskListProps {
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
checked: boolean
lineInMarkdown?: number
}
/**
* Renders a task list checkbox.
*
* @param onTaskCheckedChange A callback that is executed if the checkbox was clicked. If this prop is omitted then the checkbox will be disabled.
* @param checked Determines if the checkbox should be rendered as checked
* @param lineInMarkdown Defines the line in the markdown code this checkbox is mapped to. The information is send with the onTaskCheckedChange callback.
*/
export const TaskListCheckbox: React.FC<TaskListProps> = ({ onTaskCheckedChange, checked, lineInMarkdown }) => {
const onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
if (onTaskCheckedChange && lineInMarkdown !== undefined) {
onTaskCheckedChange(lineInMarkdown, event.currentTarget.checked)
}
},
[lineInMarkdown, onTaskCheckedChange]
)
return (
<input
disabled={onTaskCheckedChange === undefined}
className='task-list-item-checkbox'
type='checkbox'
checked={checked}
onChange={onChange}
/>
)
}

View file

@ -7,7 +7,8 @@
import type { Element } from 'domhandler'
import type { ReactElement } from 'react'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ComponentReplacer } from '../component-replacer'
import { TaskListCheckbox } from './task-list-checkbox'
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void
@ -16,34 +17,30 @@ export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean
*/
export class TaskListReplacer extends ComponentReplacer {
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
private readonly frontmatterLinesOffset
constructor(onTaskCheckedChange?: TaskCheckedChangeHandler, frontmatterLinesOffset?: number) {
constructor(frontmatterLinesToSkip?: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
super()
this.onTaskCheckedChange = onTaskCheckedChange
this.frontmatterLinesOffset = frontmatterLinesOffset ?? 0
}
handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const lineNum = Number(event.currentTarget.dataset.line)
if (this.onTaskCheckedChange) {
this.onTaskCheckedChange(lineNum + this.frontmatterLinesOffset, event.currentTarget.checked)
this.onTaskCheckedChange = (lineInMarkdown, checked) => {
if (onTaskCheckedChange === undefined || frontmatterLinesToSkip === undefined) {
return
}
onTaskCheckedChange(frontmatterLinesToSkip + lineInMarkdown, checked)
}
}
public getReplacement(node: Element): ReactElement | undefined {
public replace(node: Element): ReactElement | undefined {
if (node.attribs?.class !== 'task-list-item-checkbox') {
return
}
const lineInMarkdown = Number(node.attribs['data-line'])
if (isNaN(lineInMarkdown)) {
return undefined
}
return (
<input
disabled={this.onTaskCheckedChange === undefined}
className='task-list-item-checkbox'
type='checkbox'
<TaskListCheckbox
onTaskCheckedChange={this.onTaskCheckedChange}
checked={node.attribs.checked !== undefined}
onChange={this.handleCheckboxChange}
id={node.attribs.id}
data-line={node.attribs['data-line']}
lineInMarkdown={lineInMarkdown}
/>
)
}

View file

@ -1,14 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
export const getAttributesFromHedgeDocTag = (node: Element, tagName: string): { [s: string]: string } | undefined => {
if (node.name !== `app-${tagName}` || !node.attribs) {
return
}
return node.attribs
}

View file

@ -10,14 +10,11 @@ import { useTranslation } from 'react-i18next'
import type { VisualizationSpec } from 'vega-embed'
import { ShowIf } from '../../../common/show-if/show-if'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('VegaChart')
export interface VegaChartProps {
code: string
}
export const VegaChart: React.FC<VegaChartProps> = ({ code }) => {
export const VegaChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const { t } = useTranslation()

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { VegaChart } from './vega-chart'
/**
* Detects code blocks with 'vega-lite' as language and renders them with Vega.
*/
export class VegaReplacer extends ComponentReplacer {
getReplacement(codeNode: Element): React.ReactElement | undefined {
if (
codeNode.name !== 'code' ||
!codeNode.attribs ||
!codeNode.attribs['data-highlight-language'] ||
codeNode.attribs['data-highlight-language'] !== 'vega-lite' ||
!codeNode.children ||
!codeNode.children[0]
) {
return
}
const code = ComponentReplacer.extractTextChildContent(codeNode)
return <VegaChart code={code} />
}
}

View file

@ -6,6 +6,7 @@
import React, { useCallback } from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
interface VimeoApiResponse {
// Vimeo uses strange names for their fields. ESLint doesn't like that.
@ -13,11 +14,7 @@ interface VimeoApiResponse {
thumbnail_large?: string
}
export interface VimeoFrameProps {
id: string
}
export const VimeoFrame: React.FC<VimeoFrameProps> = ({ id }) => {
export const VimeoFrame: React.FC<IdProps> = ({ id }) => {
const getPreviewImageLink = useCallback(async () => {
const response = await fetch(`https://vimeo.com/api/v2/video/${id}.json`, {
credentials: 'omit',

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import { replaceVimeoLink } from './replace-vimeo-link'
import { replaceLegacyVimeoShortCode } from './replace-legacy-vimeo-short-code'
export const vimeoMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceVimeoLink)
markdownItRegex(markdownIt, replaceLegacyVimeoShortCode)
}

View file

@ -1,33 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { getAttributesFromHedgeDocTag } from '../utils'
import { replaceLegacyVimeoShortCode } from './replace-legacy-vimeo-short-code'
import { replaceVimeoLink } from './replace-vimeo-link'
import { VimeoFrame } from './vimeo-frame'
/**
* Detects 'app-vimeo' tags and renders them as vimeo embedding.
*/
export class VimeoReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceVimeoLink)
markdownItRegex(markdownIt, replaceLegacyVimeoShortCode)
}
public getReplacement(node: Element): React.ReactElement | undefined {
const attributes = getAttributesFromHedgeDocTag(node, 'vimeo')
if (attributes && attributes.id) {
const videoId = attributes.id
return <VimeoFrame id={videoId} />
}
}
}

View file

@ -6,12 +6,9 @@
import React from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
export interface YouTubeFrameProps {
id: string
}
export const YouTubeFrame: React.FC<YouTubeFrameProps> = ({ id }) => {
export const YouTubeFrame: React.FC<IdProps> = ({ id }) => {
return (
<OneClickEmbedding
containerClassName={'embed-responsive embed-responsive-16by9'}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import { replaceYouTubeLink } from './replace-youtube-link'
import { replaceLegacyYoutubeShortCode } from './replace-legacy-youtube-short-code'
export const youtubeMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceYouTubeLink)
markdownItRegex(markdownIt, replaceLegacyYoutubeShortCode)
}

View file

@ -1,33 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { getAttributesFromHedgeDocTag } from '../utils'
import { replaceLegacyYoutubeShortCode } from './replace-legacy-youtube-short-code'
import { replaceYouTubeLink } from './replace-youtube-link'
import { YouTubeFrame } from './youtube-frame'
/**
* Detects 'app-youtube' tags and renders them as youtube embedding.
*/
export class YoutubeReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, replaceYouTubeLink)
markdownItRegex(markdownIt, replaceLegacyYoutubeShortCode)
}
public getReplacement(node: Element): React.ReactElement | undefined {
const attributes = getAttributesFromHedgeDocTag(node, 'youtube')
if (attributes && attributes.id) {
const videoId = attributes.id
return <YouTubeFrame id={videoId} />
}
}
}

View file

@ -16,11 +16,11 @@ import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
import './slideshow.scss'
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert'
import { BasicMarkdownItConfigurator } from './markdown-it-configurator/basic-markdown-it-configurator'
import type { SlideOptions } from '../common/note-frontmatter/types'
import { processRevealCommentNodes } from './process-reveal-comment-nodes'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { LoadingSlide } from './loading-slide'
import { SlideshowMarkdownItConfigurator } from './markdown-it-configurator/slideshow-markdown-it-configurator'
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
slideOptions: SlideOptions
@ -44,12 +44,10 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
const markdownIt = useMemo(
() =>
new BasicMarkdownItConfigurator({
onToc: (toc) => (tocAst.current = toc),
new SlideshowMarkdownItConfigurator({
onTocChange: (toc) => (tocAst.current = toc),
useAlternativeBreaks,
lineOffset,
headlineAnchors: false,
slideSections: true
lineOffset
}).buildConfiguredMarkdownIt(),
[lineOffset, useAlternativeBreaks]
)

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface LineKeys {
export interface LineWithId {
line: string
id: number
}

View file

@ -1,117 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import { isTag } from 'domhandler'
import React, { Suspense } from 'react'
import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
import type {
ComponentReplacer,
NativeRenderer,
SubNodeTransform,
ValidReactDomElement
} from '../replace-components/ComponentReplacer'
import type { LineKeys } from '../types'
import type { NodeToReactElementTransformer } from '@hedgedoc/html-to-react/dist/NodeToReactElementTransformer'
export interface TextDifferenceResult {
lines: LineKeys[]
lastUsedLineId: number
}
export const calculateKeyFromLineMarker = (node: Element, lineKeys?: LineKeys[]): string | undefined => {
if (!node.attribs || lineKeys === undefined) {
return
}
const key = node.attribs['data-key']
if (key) {
return key
}
const lineMarker = node.prev
if (!lineMarker || !isTag(lineMarker) || !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)
const startLine = lineKeys[startLineIndex - 1]
const endLine = lineKeys[endLineIndex - 2]
if (startLine === undefined || endLine === undefined) {
return
}
return `${startLine.id}_${endLine.id}`
}
export const findNodeReplacement = (
node: Element,
allReplacers: ComponentReplacer[],
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): ValidReactDomElement | undefined => {
for (const componentReplacer of allReplacers) {
const replacement = componentReplacer.getReplacement(node, subNodeTransform, nativeRenderer)
if (replacement !== undefined) {
return replacement
}
}
}
/**
* Renders the given node without any replacement
*
* @param node The node to render
* @param key The unique key for the node
* @param transform The transform function that should be applied to the child nodes
*/
export const renderNativeNode = (
node: Element,
key: string,
transform: NodeToReactElementTransformer
): ValidReactDomElement => {
if (node.attribs === undefined) {
node.attribs = {}
}
delete node.attribs['data-key']
return convertNodeToReactElement(node, key, transform)
}
export const buildTransformer = (
lineKeys: LineKeys[] | undefined,
allReplacers: ComponentReplacer[]
): NodeToReactElementTransformer => {
const transform: NodeToReactElementTransformer = (node, index) => {
if (!isTag(node)) {
return convertNodeToReactElement(node, index)
}
const nativeRenderer: NativeRenderer = () => renderNativeNode(node, key, transform)
const subNodeTransform: SubNodeTransform = (subNode, subKey) => transform(subNode, subKey)
const key = calculateKeyFromLineMarker(node, lineKeys) ?? (-index).toString()
const tryReplacement = findNodeReplacement(node, allReplacers, subNodeTransform, nativeRenderer)
if (tryReplacement === null) {
return null
} else if (tryReplacement === undefined) {
return nativeRenderer()
} else {
return (
<Suspense key={key} fallback={<span>Loading...</span>}>
{tryReplacement}
</Suspense>
)
}
}
return transform
}

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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nText')).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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nmore\ntext')).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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\ntext')).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\nis\nold')
lineIdMapper.updateLineMapping('this\nis')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
it('should update line ids for changed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
})

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LineWithId } from '../types'
import type { ArrayChange } from 'diff'
import { diffArrays } from 'diff'
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 newText The new text for which the line ids should be calculated
* @return the calculated {@link LineWithId lines with unique ids}
*/
public updateLineMapping(newText: string): LineWithId[] {
const lines = newText.split('\n')
const lineDifferences = this.diffNewLinesWithLastLineKeys(lines)
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 {@code true} if the given change is neither adding or 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 {@code 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

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { diffArrays } from 'diff'
import type { TextDifferenceResult } from './html-react-transformer'
import type { LineKeys } from '../types'
export const calculateNewLineNumberMapping = (
newMarkdownLines: string[],
oldLineKeys: LineKeys[],
lastUsedLineId: number
): TextDifferenceResult => {
const lineDifferences = diffArrays<string, LineKeys>(newMarkdownLines, oldLineKeys, {
comparator: (left: string | LineKeys, right: string | LineKeys) => {
const leftLine = (left as LineKeys).line ?? (left as string)
const rightLine = (right as LineKeys).line ?? (right as string)
return leftLine === rightLine
}
})
const newLines: LineKeys[] = []
lineDifferences
.filter((change) => change.added === undefined || !change.added)
.forEach((value) => {
if (value.removed) {
;(value.value as string[]).forEach((line) => {
lastUsedLineId += 1
newLines.push({ line: line, id: lastUsedLineId })
})
} else {
;(value.value as LineKeys[]).forEach((line) => newLines.push(line))
}
})
return { lines: newLines, lastUsedLineId: lastUsedLineId }
}

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2021 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 { DO_NOT_REPLACE, REPLACE_WITH_NOTHING } 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([
{
replace(): NodeReplacement {
return REPLACE_WITH_NOTHING
}
}
])
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation).toEqual(null)
})
it('can translate an element with no matching replacer', () => {
nodeToReactTransformer.setReplacers([
{
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([
{
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,174 @@
/*
* SPDX-FileCopyrightText: 2021 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, REPLACE_WITH_NOTHING } from '../replace-components/component-replacer'
import React from 'react'
import type { LineWithId } from '../types'
import Optional from 'optional-js'
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
}
/**
* 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 | undefined {
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 | undefined {
const elementKey = this.calculateUniqueKey(element).orElseGet(() => (-index).toString())
const replacement = this.findElementReplacement(element, elementKey)
if (replacement === REPLACE_WITH_NOTHING) {
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 or {@link REPLACE_WITH_NOTHING} if the node shouldn't be rendered at all.
*/
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 !== 'app-linemarker' || !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
*/
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])
}
}

View file

@ -10444,6 +10444,11 @@ optimize-css-assets-webpack-plugin@5.0.4:
cssnano "^4.1.10"
last-call-webpack-plugin "^3.0.0"
optional-js@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/optional-js/-/optional-js-2.3.0.tgz#81d54c4719afa8845b988143643a5148f9d89490"
integrity sha512-B0LLi+Vg+eko++0z/b8zIv57kp7HKEzaPJo7LowJXMUKYdf+3XJGu/cw03h/JhIOsLnP+cG5QnTHAuicjA5fMw==
optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"