mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-21 18:55:19 -04:00
refactor(renderer): convert html/markdown-to-react converters from hooks to components
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
0457a633cc
commit
958b23e25a
30 changed files with 523 additions and 203 deletions
|
@ -0,0 +1,92 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`markdown to react can render html if allowed 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<span>
|
||||
test
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`markdown to react can render markdown with newlines as line breaks 1`] = `
|
||||
<div>
|
||||
<h1>
|
||||
This is a headline
|
||||
</h1>
|
||||
|
||||
|
||||
<p>
|
||||
This is content
|
||||
<br />
|
||||
|
||||
This Too
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`markdown to react will use markdown render extensions 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<span>test</span>
|
||||
</p>
|
||||
|
||||
|
||||
<span>
|
||||
configure
|
||||
</span>
|
||||
<span>
|
||||
post
|
||||
</span>
|
||||
<span>
|
||||
NodeProcessor!
|
||||
</span>
|
||||
<span
|
||||
data-children="true"
|
||||
>
|
||||
|
||||
node processor children
|
||||
|
||||
</span>
|
||||
<span
|
||||
data-native="true"
|
||||
>
|
||||
|
||||
<nodeprocessor>
|
||||
node processor children
|
||||
</nodeprocessor>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`markdown to react won't render html if forbidden 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<span>test</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`markdown to react won't render markdown with newlines as line breaks if forbidden 1`] = `
|
||||
<div>
|
||||
<h1>
|
||||
This is a headline
|
||||
</h1>
|
||||
|
||||
|
||||
<p>
|
||||
This is content
|
||||
This Too
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension'
|
||||
import type { Document } from 'domhandler'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* 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])
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension'
|
||||
import MarkdownIt from 'markdown-it/lib'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* 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])
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownToReact } from './markdown-to-react'
|
||||
import { TestMarkdownRendererExtension } from './test-utils/test-markdown-renderer-extension'
|
||||
import { render } from '@testing-library/react'
|
||||
import type { EventMap } from 'eventemitter2'
|
||||
import { EventEmitter2 } from 'eventemitter2'
|
||||
|
||||
describe('markdown to react', () => {
|
||||
it('can render markdown with newlines as line breaks', () => {
|
||||
const view = render(
|
||||
<MarkdownToReact
|
||||
markdownContentLines={['# This is a headline', 'This is content', 'This Too']}
|
||||
allowHtml={false}
|
||||
newlinesAreBreaks={true}
|
||||
markdownRenderExtensions={[]}></MarkdownToReact>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("won't render markdown with newlines as line breaks if forbidden", () => {
|
||||
const view = render(
|
||||
<MarkdownToReact
|
||||
markdownContentLines={['# This is a headline', 'This is content', 'This Too']}
|
||||
allowHtml={false}
|
||||
newlinesAreBreaks={false}
|
||||
markdownRenderExtensions={[]}></MarkdownToReact>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can render html if allowed', () => {
|
||||
const view = render(
|
||||
<MarkdownToReact
|
||||
markdownContentLines={['<span>test</span>']}
|
||||
markdownRenderExtensions={[]}
|
||||
newlinesAreBreaks={true}
|
||||
allowHtml={true}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("won't render html if forbidden", () => {
|
||||
const view = render(
|
||||
<MarkdownToReact
|
||||
markdownContentLines={['<span>test</span>']}
|
||||
markdownRenderExtensions={[]}
|
||||
newlinesAreBreaks={true}
|
||||
allowHtml={false}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('will use markdown render extensions', () => {
|
||||
const view = render(
|
||||
<MarkdownToReact
|
||||
markdownContentLines={['<span>test</span>']}
|
||||
markdownRenderExtensions={[new TestMarkdownRendererExtension(new EventEmitter2<EventMap>())]}
|
||||
newlinesAreBreaks={true}
|
||||
allowHtml={false}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HtmlToReact } from '../../common/html-to-react/html-to-react'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
import { useCombinedNodePreprocessor } from './hooks/use-combined-node-preprocessor'
|
||||
import { useConfiguredMarkdownIt } from './hooks/use-configured-markdown-it'
|
||||
import { LineContentToLineIdMapper } from './utils/line-content-to-line-id-mapper'
|
||||
import { NodeToReactTransformer } from './utils/node-to-react-transformer'
|
||||
import type { ParserOptions } from '@hedgedoc/html-to-react'
|
||||
import type DOMPurify from 'dompurify'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
export interface MarkdownToReactProps {
|
||||
markdownContentLines: string[]
|
||||
markdownRenderExtensions: MarkdownRendererExtension[]
|
||||
newlinesAreBreaks?: boolean
|
||||
allowHtml: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Markdown code.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export const MarkdownToReact: React.FC<MarkdownToReactProps> = ({
|
||||
markdownContentLines,
|
||||
markdownRenderExtensions,
|
||||
newlinesAreBreaks,
|
||||
allowHtml
|
||||
}) => {
|
||||
const lineNumberMapper = useMemo(() => new LineContentToLineIdMapper(), [])
|
||||
const nodeToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
|
||||
|
||||
useMemo(() => {
|
||||
nodeToReactTransformer.setReplacers(markdownRenderExtensions.flatMap((extension) => extension.buildReplacers()))
|
||||
}, [nodeToReactTransformer, markdownRenderExtensions])
|
||||
|
||||
useMemo(() => {
|
||||
nodeToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
|
||||
}, [nodeToReactTransformer, lineNumberMapper, markdownContentLines])
|
||||
|
||||
const nodePreProcessor = useCombinedNodePreprocessor(markdownRenderExtensions)
|
||||
const markdownIt = useConfiguredMarkdownIt(markdownRenderExtensions, allowHtml, newlinesAreBreaks ?? true)
|
||||
|
||||
const parserOptions: ParserOptions = useMemo(
|
||||
() => ({
|
||||
transform: (node, index) => nodeToReactTransformer.translateNodeToReactElement(node, index),
|
||||
preprocessNodes: (document) => {
|
||||
nodeToReactTransformer.resetReplacers()
|
||||
return nodePreProcessor(document)
|
||||
}
|
||||
}),
|
||||
[nodeToReactTransformer, nodePreProcessor]
|
||||
)
|
||||
|
||||
const html = useMemo(() => markdownIt.render(markdownContentLines.join('\n')), [markdownContentLines, markdownIt])
|
||||
const domPurifyConfig: DOMPurify.Config = useMemo(
|
||||
() => ({
|
||||
ADD_TAGS: markdownRenderExtensions.flatMap((extension) => extension.buildTagNameAllowList())
|
||||
}),
|
||||
[markdownRenderExtensions]
|
||||
)
|
||||
|
||||
return <HtmlToReact htmlCode={html} parserOptions={parserOptions} domPurifyConfig={domPurifyConfig} />
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension'
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import { TestNodeProcessor } from './test-node-processor'
|
||||
import { TestReplacer } from './test-replacer'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import Token from 'markdown-it/lib/token'
|
||||
|
||||
export class TestMarkdownRendererExtension extends MarkdownRendererExtension {
|
||||
buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new TestNodeProcessor()]
|
||||
}
|
||||
|
||||
configureMarkdownIt(markdownIt: MarkdownIt) {
|
||||
markdownIt.use(() => {
|
||||
markdownIt.core.ruler.push('configure', (core) => core.tokens.push(new Token('configure', 'configure', 0)))
|
||||
markdownIt.renderer.rules.configure = () => '<span>configure</span>'
|
||||
})
|
||||
}
|
||||
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new TestReplacer()]
|
||||
}
|
||||
|
||||
buildTagNameAllowList(): string[] {
|
||||
return ['nodeProcessor']
|
||||
}
|
||||
|
||||
configureMarkdownItPost(markdownIt: MarkdownIt) {
|
||||
markdownIt.use(() => {
|
||||
markdownIt.core.ruler.push('post', (core) => core.tokens.push(new Token('post', 'post', 0)))
|
||||
markdownIt.renderer.rules.post = () => '<span>post</span>'
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import type { Document } from 'domhandler'
|
||||
import { Element, Text } from 'domhandler'
|
||||
|
||||
export class TestNodeProcessor extends NodeProcessor {
|
||||
process(document: Document): Document {
|
||||
document.childNodes.push(new Element('nodeProcessor', {}, [new Text('node processor children')]))
|
||||
return document
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 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 React, { Fragment } from 'react'
|
||||
|
||||
export class TestReplacer extends ComponentReplacer {
|
||||
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||
return node.tagName === 'nodeProcessor' ? (
|
||||
<Fragment>
|
||||
<span>NodeProcessor! </span>
|
||||
<span data-children={true}> {node.childNodes.map(subNodeTransform)} </span>
|
||||
<span data-native={true}> {nativeRenderer()} </span>
|
||||
</Fragment>
|
||||
) : (
|
||||
DO_NOT_REPLACE
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { LineContentToLineIdMapper } from './line-content-to-line-id-mapper'
|
||||
|
||||
describe('line id mapper', () => {
|
||||
let lineIdMapper: LineContentToLineIdMapper
|
||||
|
||||
beforeEach(() => {
|
||||
lineIdMapper = new LineContentToLineIdMapper()
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { LineWithId } from '../../extensions/linemarker/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 LineContentToLineIdMapper {
|
||||
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) =>
|
||||
LineContentToLineIdMapper.changeIsNotChangingLines(change) ||
|
||||
LineContentToLineIdMapper.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 (LineContentToLineIdMapper.changeIsAddingLines(change)) {
|
||||
return change.value.map((line) => {
|
||||
this.lastUsedLineId += 1
|
||||
return { line: line, id: this.lastUsedLineId }
|
||||
})
|
||||
} else {
|
||||
return change.value as LineWithId[]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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 { NodeToReactTransformer } from './node-to-react-transformer'
|
||||
import { Element } from 'domhandler'
|
||||
import type { ReactElement, ReactHTMLElement } from 'react'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { LinemarkerMarkdownExtension } from '../../extensions/linemarker/linemarker-markdown-extension'
|
||||
import type { LineWithId } from '../../extensions/linemarker/types'
|
||||
import type {
|
||||
ComponentReplacer,
|
||||
NodeReplacement,
|
||||
ValidReactDomElement
|
||||
} from '../../replace-components/component-replacer'
|
||||
import { DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Element, Node } from 'domhandler'
|
||||
import { isTag } from 'domhandler'
|
||||
import React from 'react'
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue