mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 06:34:39 -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,30 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`HTML to React renders basic html correctly 1`] = `
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This is a test
|
||||||
|
<b>
|
||||||
|
sentence
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`HTML to React will forward the DomPurify settings 1`] = `
|
||||||
|
<div>
|
||||||
|
<test-tag>
|
||||||
|
Test!
|
||||||
|
</test-tag>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`HTML to React will forward the parser options 1`] = `
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Hijacked!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`HTML to React won't render script tags 1`] = `<div />`;
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { HtmlToReact } from './html-to-react'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
|
||||||
|
describe('HTML to React', () => {
|
||||||
|
it('renders basic html correctly', () => {
|
||||||
|
const view = render(<HtmlToReact htmlCode={'<p>This is a test <b>sentence</b></p>'} />)
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("won't render script tags", () => {
|
||||||
|
const view = render(<HtmlToReact htmlCode={'<script type="application/javascript">alert("XSS!")</script>'} />)
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('will forward the DomPurify settings', () => {
|
||||||
|
const view = render(
|
||||||
|
<HtmlToReact domPurifyConfig={{ ADD_TAGS: ['test-tag'] }} htmlCode={'<test-tag>Test!</test-tag>'} />
|
||||||
|
)
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('will forward the parser options', () => {
|
||||||
|
let transformerVisited = false
|
||||||
|
let preprocessNodesVisited = false
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<HtmlToReact
|
||||||
|
htmlCode={'<p>This is a sentence</p>'}
|
||||||
|
parserOptions={{
|
||||||
|
transform: () => {
|
||||||
|
transformerVisited = true
|
||||||
|
return <p>Hijacked!</p>
|
||||||
|
},
|
||||||
|
preprocessNodes: (document) => {
|
||||||
|
preprocessNodesVisited = true
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
expect(preprocessNodesVisited).toBeTruthy()
|
||||||
|
expect(transformerVisited).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { ParserOptions } from '@hedgedoc/html-to-react'
|
||||||
|
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
||||||
|
import type DOMPurify from 'dompurify'
|
||||||
|
import { sanitize } from 'dompurify'
|
||||||
|
import React, { Fragment, useMemo } from 'react'
|
||||||
|
|
||||||
|
export interface HtmlToReactProps {
|
||||||
|
htmlCode: string
|
||||||
|
domPurifyConfig?: DOMPurify.Config
|
||||||
|
parserOptions?: ParserOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders
|
||||||
|
* @param htmlCode
|
||||||
|
* @param domPurifyConfig
|
||||||
|
* @param parserOptions
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const HtmlToReact: React.FC<HtmlToReactProps> = ({ htmlCode, domPurifyConfig, parserOptions }) => {
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const sanitizedHtmlCode = sanitize(htmlCode, { ...domPurifyConfig, RETURN_DOM_FRAGMENT: false, RETURN_DOM: false })
|
||||||
|
return convertHtmlToReact(sanitizedHtmlCode, parserOptions)
|
||||||
|
}, [domPurifyConfig, htmlCode, parserOptions])
|
||||||
|
|
||||||
|
return <Fragment>{elements}</Fragment>
|
||||||
|
}
|
|
@ -7,11 +7,12 @@ import { cypressId } from '../../utils/cypress-attribute'
|
||||||
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
||||||
import { HeadlineAnchorsMarkdownExtension } from './extensions/headline-anchors-markdown-extension'
|
import { HeadlineAnchorsMarkdownExtension } from './extensions/headline-anchors-markdown-extension'
|
||||||
import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdown-it-plugin'
|
import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||||
|
import { LinemarkerMarkdownExtension } from './extensions/linemarker/linemarker-markdown-extension'
|
||||||
import type { LineMarkerPosition } from './extensions/linemarker/types'
|
import type { LineMarkerPosition } from './extensions/linemarker/types'
|
||||||
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
|
import { useCalculateLineMarkerPosition } from './hooks/use-calculate-line-marker-positions'
|
||||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||||
import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions'
|
import { MarkdownToReact } from './markdown-to-react/markdown-to-react'
|
||||||
import React, { useEffect, useMemo, useRef } from 'react'
|
import React, { useEffect, useMemo, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
@ -27,9 +28,7 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro
|
||||||
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
||||||
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
||||||
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
|
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
|
||||||
* @param onTocChange The callback to call if the toc changes.
|
|
||||||
* @param baseUrl The base url of the renderer
|
* @param baseUrl The base url of the renderer
|
||||||
* @param onImageClick The callback to call if a image is clicked
|
|
||||||
* @param outerContainerRef A reference for the outer container
|
* @param outerContainerRef A reference for the outer container
|
||||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||||
*/
|
*/
|
||||||
|
@ -47,19 +46,17 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
|
||||||
|
|
||||||
const extensions = useMarkdownExtensions(
|
const extensions = useMarkdownExtensions(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
currentLineMarkers,
|
useMemo(
|
||||||
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], [])
|
() => [
|
||||||
|
new HeadlineAnchorsMarkdownExtension(),
|
||||||
|
new LinemarkerMarkdownExtension((values) => (currentLineMarkers.current = values))
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
|
|
||||||
|
|
||||||
useTranslation()
|
useTranslation()
|
||||||
useCalculateLineMarkerPosition(
|
useCalculateLineMarkerPosition(markdownBodyRef, currentLineMarkers.current, onLineMarkerPositionChanged)
|
||||||
markdownBodyRef,
|
|
||||||
currentLineMarkers.current,
|
|
||||||
onLineMarkerPositionChanged,
|
|
||||||
markdownBodyRef.current?.offsetTop ?? 0
|
|
||||||
)
|
|
||||||
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
extractFirstHeadline()
|
extractFirstHeadline()
|
||||||
|
@ -72,7 +69,12 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
|
||||||
ref={markdownBodyRef}
|
ref={markdownBodyRef}
|
||||||
data-word-count-target={true}
|
data-word-count-target={true}
|
||||||
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>
|
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>
|
||||||
{markdownReactDom}
|
<MarkdownToReact
|
||||||
|
markdownContentLines={markdownContentLines}
|
||||||
|
markdownRenderExtensions={extensions}
|
||||||
|
newlinesAreBreaks={newlinesAreBreaks}
|
||||||
|
allowHtml={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,10 +15,6 @@ import type MarkdownIt from 'markdown-it/lib'
|
||||||
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
|
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
|
||||||
public static readonly PLACEHOLDER_URL = 'https://'
|
public static readonly PLACEHOLDER_URL = 'https://'
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
configureMarkdownIt(markdownIt: MarkdownIt): void {
|
configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||||
addLineToPlaceholderImageTags(markdownIt)
|
addLineToPlaceholderImageTags(markdownIt)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,9 @@ export interface LineWithId {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the line number of a line marker and its absolute scroll position on the page.
|
||||||
|
*/
|
||||||
export interface LineMarkerPosition {
|
export interface LineMarkerPosition {
|
||||||
line: number
|
line: number
|
||||||
position: number
|
position: number
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
import { NodeProcessor } from '../../node-preprocessors/node-processor'
|
|
||||||
import render from 'dom-serializer'
|
|
||||||
import type { Document } from 'domhandler'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import { parseDocument } from 'htmlparser2'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes the given {@link Document document}.
|
|
||||||
*
|
|
||||||
* @see https://cure53.de/purify
|
|
||||||
*/
|
|
||||||
export class SanitizerNodePreprocessor extends NodeProcessor {
|
|
||||||
constructor(private tagNameWhiteList: string[]) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
process(nodes: Document): Document {
|
|
||||||
const sanitizedHtml = DOMPurify.sanitize(render(nodes), {
|
|
||||||
ADD_TAGS: this.tagNameWhiteList
|
|
||||||
})
|
|
||||||
return parseDocument(sanitizedHtml)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
|
||||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
|
||||||
import { SanitizerNodePreprocessor } from './dom-purifier-node-preprocessor'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds support for html sanitizing using dompurify to the markdown rendering.
|
|
||||||
*/
|
|
||||||
export class SanitizerMarkdownExtension extends MarkdownRendererExtension {
|
|
||||||
constructor(private tagNameWhiteList: string[]) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
public buildNodeProcessors(): NodeProcessor[] {
|
|
||||||
return [new SanitizerNodePreprocessor(this.tagNameWhiteList)]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,17 +18,20 @@ export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension
|
||||||
private lastAst: TocAst | undefined = undefined
|
private lastAst: TocAst | undefined = undefined
|
||||||
|
|
||||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||||
toc(markdownIt, {
|
const eventEmitter = this.eventEmitter
|
||||||
listType: 'ul',
|
if (eventEmitter !== undefined) {
|
||||||
level: [1, 2, 3],
|
toc(markdownIt, {
|
||||||
callback: (ast: TocAst): void => {
|
listType: 'ul',
|
||||||
if (equal(ast, this.lastAst)) {
|
level: [1, 2, 3],
|
||||||
return
|
callback: (ast: TocAst): void => {
|
||||||
}
|
if (equal(ast, this.lastAst)) {
|
||||||
this.lastAst = ast
|
return
|
||||||
this.eventEmitter?.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
|
}
|
||||||
},
|
this.lastAst = ast
|
||||||
slugify: tocSlugify
|
eventEmitter.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
|
||||||
})
|
},
|
||||||
|
slugify: tocSlugify
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,13 +52,11 @@ const calculateLineMarkerPositions = (
|
||||||
* @param documentElement A reference to the rendered document.
|
* @param documentElement A reference to the rendered document.
|
||||||
* @param lineMarkers A list of {@link LineMarkers}
|
* @param lineMarkers A list of {@link LineMarkers}
|
||||||
* @param onLineMarkerPositionChanged The callback to call if the {@link LineMarkerPosition line marker positions} change e.g. by rendering or resizing.
|
* @param onLineMarkerPositionChanged The callback to call if the {@link LineMarkerPosition line marker positions} change e.g. by rendering or resizing.
|
||||||
* @param offset The optional offset
|
|
||||||
*/
|
*/
|
||||||
export const useCalculateLineMarkerPosition = (
|
export const useCalculateLineMarkerPosition = (
|
||||||
documentElement: RefObject<HTMLDivElement>,
|
documentElement: RefObject<HTMLDivElement>,
|
||||||
lineMarkers?: LineMarkers[],
|
lineMarkers: LineMarkers[] | undefined,
|
||||||
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void,
|
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
|
||||||
offset?: number
|
|
||||||
): void => {
|
): void => {
|
||||||
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
|
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
|
||||||
|
|
||||||
|
@ -67,13 +65,17 @@ export const useCalculateLineMarkerPosition = (
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLines = calculateLineMarkerPositions(documentElement.current, lineMarkers, offset)
|
const newLines = calculateLineMarkerPositions(
|
||||||
|
documentElement.current,
|
||||||
|
lineMarkers,
|
||||||
|
documentElement.current.offsetTop ?? 0
|
||||||
|
)
|
||||||
|
|
||||||
if (!equal(newLines, lastLineMarkerPositions)) {
|
if (!equal(newLines, lastLineMarkerPositions)) {
|
||||||
lastLineMarkerPositions.current = newLines
|
lastLineMarkerPositions.current = newLines
|
||||||
onLineMarkerPositionChanged(newLines)
|
onLineMarkerPositionChanged(newLines)
|
||||||
}
|
}
|
||||||
}, [documentElement, lineMarkers, offset, onLineMarkerPositionChanged])
|
}, [documentElement, lineMarkers, onLineMarkerPositionChanged])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
calculateNewLineMarkerPositions()
|
calculateNewLineMarkerPositions()
|
|
@ -1,62 +0,0 @@
|
||||||
/*
|
|
||||||
* 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 { SanitizerMarkdownExtension } from '../extensions/sanitizer/sanitizer-markdown-extension'
|
|
||||||
import type { ValidReactDomElement } from '../replace-components/component-replacer'
|
|
||||||
import { LineIdMapper } from '../utils/line-id-mapper'
|
|
||||||
import { NodeToReactTransformer } from '../utils/node-to-react-transformer'
|
|
||||||
import { useCombinedNodePreprocessor } from './use-combined-node-preprocessor'
|
|
||||||
import { useConfiguredMarkdownIt } from './use-configured-markdown-it'
|
|
||||||
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
|
||||||
import React, { Fragment, useMemo } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders Markdown-Code into react elements.
|
|
||||||
*
|
|
||||||
* @param markdownContentLines The Markdown code lines that should be rendered
|
|
||||||
* @param additionalMarkdownExtensions A list of {@link MarkdownRendererExtension markdown extensions} that should be used
|
|
||||||
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
|
|
||||||
* @param allowHtml Defines if html is allowed in markdown
|
|
||||||
* @return The React DOM that represents the rendered Markdown code
|
|
||||||
*/
|
|
||||||
export const useConvertMarkdownToReactDom = (
|
|
||||||
markdownContentLines: string[],
|
|
||||||
additionalMarkdownExtensions: MarkdownRendererExtension[],
|
|
||||||
newlinesAreBreaks = true,
|
|
||||||
allowHtml = true
|
|
||||||
): ValidReactDomElement => {
|
|
||||||
const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
|
|
||||||
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
|
|
||||||
const markdownExtensions = useMemo(() => {
|
|
||||||
const tagWhiteLists = additionalMarkdownExtensions.flatMap((extension) => extension.buildTagNameAllowList())
|
|
||||||
return [...additionalMarkdownExtensions, new SanitizerMarkdownExtension(tagWhiteLists)]
|
|
||||||
}, [additionalMarkdownExtensions])
|
|
||||||
|
|
||||||
useMemo(() => {
|
|
||||||
htmlToReactTransformer.setReplacers(markdownExtensions.flatMap((extension) => extension.buildReplacers()))
|
|
||||||
}, [htmlToReactTransformer, markdownExtensions])
|
|
||||||
|
|
||||||
useMemo(() => {
|
|
||||||
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
|
|
||||||
}, [htmlToReactTransformer, lineNumberMapper, markdownContentLines])
|
|
||||||
|
|
||||||
const nodePreProcessor = useCombinedNodePreprocessor(markdownExtensions)
|
|
||||||
const markdownIt = useConfiguredMarkdownIt(markdownExtensions, allowHtml, newlinesAreBreaks)
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const html = markdownIt.render(markdownContentLines.join('\n'))
|
|
||||||
htmlToReactTransformer.resetReplacers()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={'root'}>
|
|
||||||
{convertHtmlToReact(html, {
|
|
||||||
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
|
|
||||||
preprocessNodes: (document) => nodePreProcessor(document)
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
}, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
|
|
||||||
}
|
|
|
@ -12,43 +12,30 @@ import { GenericSyntaxMarkdownExtension } from '../extensions/generic-syntax-mar
|
||||||
import { IframeCapsuleMarkdownExtension } from '../extensions/iframe-capsule/iframe-capsule-markdown-extension'
|
import { IframeCapsuleMarkdownExtension } from '../extensions/iframe-capsule/iframe-capsule-markdown-extension'
|
||||||
import { ImagePlaceholderMarkdownExtension } from '../extensions/image-placeholder/image-placeholder-markdown-extension'
|
import { ImagePlaceholderMarkdownExtension } from '../extensions/image-placeholder/image-placeholder-markdown-extension'
|
||||||
import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension'
|
import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension'
|
||||||
import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin'
|
|
||||||
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
|
|
||||||
import { LinkAdjustmentMarkdownExtension } from '../extensions/link-replacer/link-adjustment-markdown-extension'
|
import { LinkAdjustmentMarkdownExtension } from '../extensions/link-replacer/link-adjustment-markdown-extension'
|
||||||
import { LinkifyFixMarkdownExtension } from '../extensions/linkify-fix/linkify-fix-markdown-extension'
|
import { LinkifyFixMarkdownExtension } from '../extensions/linkify-fix/linkify-fix-markdown-extension'
|
||||||
import { TableOfContentsMarkdownExtension } from '../extensions/table-of-contents-markdown-extension'
|
import { TableOfContentsMarkdownExtension } from '../extensions/table-of-contents-markdown-extension'
|
||||||
import { UploadIndicatingImageFrameMarkdownExtension } from '../extensions/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension'
|
import { UploadIndicatingImageFrameMarkdownExtension } from '../extensions/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension'
|
||||||
import { useExtensionEventEmitter } from './use-extension-event-emitter'
|
import { useExtensionEventEmitter } from './use-extension-event-emitter'
|
||||||
import type { MutableRefObject } from 'react'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
const optionalMarkdownRendererExtensions = optionalAppExtensions.flatMap((value) =>
|
|
||||||
value.buildMarkdownRendererExtensions()
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a list of {@link MarkdownRendererExtension markdown extensions} that is a combination of the common extensions and the given additional.
|
* Provides a list of {@link MarkdownRendererExtension markdown extensions} that is a combination of the common extensions and the given additional.
|
||||||
*
|
*
|
||||||
* @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension}
|
* @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension}
|
||||||
* @param currentLineMarkers A {@link MutableRefObject reference} to {@link LineMarkers} for the {@link LinemarkerMarkdownExtension}
|
|
||||||
* @param additionalExtensions The additional extensions that should be included in the list
|
* @param additionalExtensions The additional extensions that should be included in the list
|
||||||
* @return The created list of markdown extensions
|
* @return The created list of markdown extensions
|
||||||
*/
|
*/
|
||||||
export const useMarkdownExtensions = (
|
export const useMarkdownExtensions = (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
|
|
||||||
additionalExtensions: MarkdownRendererExtension[]
|
additionalExtensions: MarkdownRendererExtension[]
|
||||||
): MarkdownRendererExtension[] => {
|
): MarkdownRendererExtension[] => {
|
||||||
const extensionEventEmitter = useExtensionEventEmitter()
|
const extensionEventEmitter = useExtensionEventEmitter()
|
||||||
//replace with global list
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return [
|
return [
|
||||||
...optionalMarkdownRendererExtensions,
|
...optionalAppExtensions.flatMap((extension) => extension.buildMarkdownRendererExtensions(extensionEventEmitter)),
|
||||||
...additionalExtensions,
|
...additionalExtensions,
|
||||||
new TableOfContentsMarkdownExtension(extensionEventEmitter),
|
new TableOfContentsMarkdownExtension(),
|
||||||
new LinemarkerMarkdownExtension(
|
|
||||||
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined
|
|
||||||
),
|
|
||||||
new IframeCapsuleMarkdownExtension(),
|
new IframeCapsuleMarkdownExtension(),
|
||||||
new ImagePlaceholderMarkdownExtension(),
|
new ImagePlaceholderMarkdownExtension(),
|
||||||
new UploadIndicatingImageFrameMarkdownExtension(),
|
new UploadIndicatingImageFrameMarkdownExtension(),
|
||||||
|
@ -60,5 +47,5 @@ export const useMarkdownExtensions = (
|
||||||
new DebuggerMarkdownExtension(),
|
new DebuggerMarkdownExtension(),
|
||||||
new ProxyImageMarkdownExtension()
|
new ProxyImageMarkdownExtension()
|
||||||
]
|
]
|
||||||
}, [additionalExtensions, baseUrl, currentLineMarkers, extensionEventEmitter])
|
}, [additionalExtensions, baseUrl, extensionEventEmitter])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
`;
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension'
|
||||||
import type { Document } from 'domhandler'
|
import type { Document } from 'domhandler'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension'
|
||||||
import MarkdownIt from 'markdown-it/lib'
|
import MarkdownIt from 'markdown-it/lib'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,13 +3,13 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { LineIdMapper } from './line-id-mapper'
|
import { LineContentToLineIdMapper } from './line-content-to-line-id-mapper'
|
||||||
|
|
||||||
describe('line id mapper', () => {
|
describe('line id mapper', () => {
|
||||||
let lineIdMapper: LineIdMapper
|
let lineIdMapper: LineContentToLineIdMapper
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
lineIdMapper = new LineIdMapper()
|
lineIdMapper = new LineContentToLineIdMapper()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be case sensitive', () => {
|
it('should be case sensitive', () => {
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { LineWithId } from '../extensions/linemarker/types'
|
import type { LineWithId } from '../../extensions/linemarker/types'
|
||||||
import type { ArrayChange } from 'diff'
|
import type { ArrayChange } from 'diff'
|
||||||
import { diffArrays } from 'diff'
|
import { diffArrays } from 'diff'
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ type LineChange = ArrayChange<NewLine | LineWithId>
|
||||||
* Calculates ids for every line in a given text and memorized the state of the last given text.
|
* 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.
|
* It also assigns ids for new lines on every update.
|
||||||
*/
|
*/
|
||||||
export class LineIdMapper {
|
export class LineContentToLineIdMapper {
|
||||||
private lastLines: LineWithId[] = []
|
private lastLines: LineWithId[] = []
|
||||||
private lastUsedLineId = 0
|
private lastUsedLineId = 0
|
||||||
|
|
||||||
|
@ -58,7 +58,11 @@ export class LineIdMapper {
|
||||||
*/
|
*/
|
||||||
private convertChangesToLinesWithIds(changes: LineChange[]): LineWithId[] {
|
private convertChangesToLinesWithIds(changes: LineChange[]): LineWithId[] {
|
||||||
return changes
|
return changes
|
||||||
.filter((change) => LineIdMapper.changeIsNotChangingLines(change) || LineIdMapper.changeIsAddingLines(change))
|
.filter(
|
||||||
|
(change) =>
|
||||||
|
LineContentToLineIdMapper.changeIsNotChangingLines(change) ||
|
||||||
|
LineContentToLineIdMapper.changeIsAddingLines(change)
|
||||||
|
)
|
||||||
.reduce(
|
.reduce(
|
||||||
(previousLineKeys, currentChange) => [...previousLineKeys, ...this.convertChangeToLinesWithIds(currentChange)],
|
(previousLineKeys, currentChange) => [...previousLineKeys, ...this.convertChangeToLinesWithIds(currentChange)],
|
||||||
[] as LineWithId[]
|
[] as LineWithId[]
|
||||||
|
@ -94,7 +98,7 @@ export class LineIdMapper {
|
||||||
* @return The created or reused {@link LineWithId lines with ids}
|
* @return The created or reused {@link LineWithId lines with ids}
|
||||||
*/
|
*/
|
||||||
private convertChangeToLinesWithIds(change: LineChange): LineWithId[] {
|
private convertChangeToLinesWithIds(change: LineChange): LineWithId[] {
|
||||||
if (LineIdMapper.changeIsAddingLines(change)) {
|
if (LineContentToLineIdMapper.changeIsAddingLines(change)) {
|
||||||
return change.value.map((line) => {
|
return change.value.map((line) => {
|
||||||
this.lastUsedLineId += 1
|
this.lastUsedLineId += 1
|
||||||
return { line: line, id: this.lastUsedLineId }
|
return { line: line, id: this.lastUsedLineId }
|
|
@ -3,8 +3,8 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { NodeReplacement } from '../replace-components/component-replacer'
|
import type { NodeReplacement } from '../../replace-components/component-replacer'
|
||||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../replace-components/component-replacer'
|
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||||
import { NodeToReactTransformer } from './node-to-react-transformer'
|
import { NodeToReactTransformer } from './node-to-react-transformer'
|
||||||
import { Element } from 'domhandler'
|
import { Element } from 'domhandler'
|
||||||
import type { ReactElement, ReactHTMLElement } from 'react'
|
import type { ReactElement, ReactHTMLElement } from 'react'
|
|
@ -3,10 +3,14 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
|
import { LinemarkerMarkdownExtension } from '../../extensions/linemarker/linemarker-markdown-extension'
|
||||||
import type { LineWithId } from '../extensions/linemarker/types'
|
import type { LineWithId } from '../../extensions/linemarker/types'
|
||||||
import type { ComponentReplacer, NodeReplacement, ValidReactDomElement } from '../replace-components/component-replacer'
|
import type {
|
||||||
import { DO_NOT_REPLACE } from '../replace-components/component-replacer'
|
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 { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
|
||||||
import { Optional } from '@mrdrogdrog/optional'
|
import { Optional } from '@mrdrogdrog/optional'
|
||||||
import type { Element, Node } from 'domhandler'
|
import type { Element, Node } from 'domhandler'
|
|
@ -7,11 +7,11 @@ import type { SlideOptions } from '../../redux/note-details/types/slide-show-opt
|
||||||
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
|
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
|
||||||
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
||||||
import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension'
|
import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension'
|
||||||
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
|
|
||||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||||
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
|
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
|
||||||
import { LoadingSlide } from './loading-slide'
|
import { LoadingSlide } from './loading-slide'
|
||||||
|
import { MarkdownToReact } from './markdown-to-react/markdown-to-react'
|
||||||
import React, { useEffect, useMemo, useRef } from 'react'
|
import React, { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
|
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
|
||||||
|
@ -25,10 +25,7 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr
|
||||||
* @param markdownContentLines The markdown lines
|
* @param markdownContentLines The markdown lines
|
||||||
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
||||||
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
||||||
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
|
|
||||||
* @param onTocChange The callback to call if the toc changes.
|
|
||||||
* @param baseUrl The base url of the renderer
|
* @param baseUrl The base url of the renderer
|
||||||
* @param onImageClick The callback to call if a image is clicked
|
|
||||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||||
* @param slideOptions The {@link SlideOptions} to use
|
* @param slideOptions The {@link SlideOptions} to use
|
||||||
*/
|
*/
|
||||||
|
@ -44,11 +41,9 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
|
||||||
|
|
||||||
const extensions = useMarkdownExtensions(
|
const extensions = useMarkdownExtensions(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
undefined,
|
|
||||||
useMemo(() => [new RevealMarkdownExtension()], [])
|
useMemo(() => [new RevealMarkdownExtension()], [])
|
||||||
)
|
)
|
||||||
|
|
||||||
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
|
|
||||||
const revealStatus = useReveal(markdownContentLines, slideOptions)
|
const revealStatus = useReveal(markdownContentLines, slideOptions)
|
||||||
|
|
||||||
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
||||||
|
@ -59,8 +54,18 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
|
||||||
}, [extractFirstHeadline, markdownContentLines, revealStatus])
|
}, [extractFirstHeadline, markdownContentLines, revealStatus])
|
||||||
|
|
||||||
const slideShowDOM = useMemo(
|
const slideShowDOM = useMemo(
|
||||||
() => (revealStatus === REVEAL_STATUS.INITIALISED ? markdownReactDom : <LoadingSlide />),
|
() =>
|
||||||
[markdownReactDom, revealStatus]
|
revealStatus === REVEAL_STATUS.INITIALISED ? (
|
||||||
|
<MarkdownToReact
|
||||||
|
markdownContentLines={markdownContentLines}
|
||||||
|
markdownRenderExtensions={extensions}
|
||||||
|
allowHtml={true}
|
||||||
|
newlinesAreBreaks={newlinesAreBreaks}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LoadingSlide />
|
||||||
|
),
|
||||||
|
[extensions, markdownContentLines, newlinesAreBreaks, revealStatus]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import { StoreProvider } from '../../../redux/store-provider'
|
import { StoreProvider } from '../../../redux/store-provider'
|
||||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||||
import { useConvertMarkdownToReactDom } from '../hooks/use-convert-markdown-to-react-dom'
|
import { MarkdownToReact } from '../markdown-to-react/markdown-to-react'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
export interface SimpleMarkdownRendererProps {
|
export interface SimpleMarkdownRendererProps {
|
||||||
|
@ -21,7 +21,14 @@ export interface SimpleMarkdownRendererProps {
|
||||||
*/
|
*/
|
||||||
export const TestMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
|
export const TestMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
|
||||||
const lines = useMemo(() => content.split('\n'), [content])
|
const lines = useMemo(() => content.split('\n'), [content])
|
||||||
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
|
return (
|
||||||
|
<StoreProvider>
|
||||||
return <StoreProvider>{dom}</StoreProvider>
|
<MarkdownToReact
|
||||||
|
markdownContentLines={lines}
|
||||||
|
markdownRenderExtensions={extensions}
|
||||||
|
newlinesAreBreaks={true}
|
||||||
|
allowHtml={false}
|
||||||
|
/>
|
||||||
|
</StoreProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ export const useDocumentSyncScrolling = (
|
||||||
): [(lineMarkers: LineMarkerPosition[]) => void, React.UIEventHandler<HTMLElement>] => {
|
): [(lineMarkers: LineMarkerPosition[]) => void, React.UIEventHandler<HTMLElement>] => {
|
||||||
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
|
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
|
||||||
|
|
||||||
const onLineMarkerPositionChanged = useCallback(
|
const recalculateLineMarkerPositions = useCallback(
|
||||||
(linkMarkerPositions: LineMarkerPosition[]) => {
|
(linkMarkerPositions: LineMarkerPosition[]) => {
|
||||||
if (!outerContainerRef.current || !rendererRef.current) {
|
if (!outerContainerRef.current || !rendererRef.current) {
|
||||||
return
|
return
|
||||||
|
@ -51,5 +51,5 @@ export const useDocumentSyncScrolling = (
|
||||||
const onUserScroll = useOnUserScroll(lineMarks, outerContainerRef, onScroll)
|
const onUserScroll = useOnUserScroll(lineMarks, outerContainerRef, onScroll)
|
||||||
useScrollToLineMark(scrollState, lineMarks, numberOfLines, outerContainerRef)
|
useScrollToLineMark(scrollState, lineMarks, numberOfLines, outerContainerRef)
|
||||||
|
|
||||||
return useMemo(() => [onLineMarkerPositionChanged, onUserScroll], [onLineMarkerPositionChanged, onUserScroll])
|
return useMemo(() => [recalculateLineMarkerPositions, onUserScroll], [recalculateLineMarkerPositions, onUserScroll])
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,7 +72,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
|
||||||
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks)
|
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks)
|
||||||
|
|
||||||
const contentLineCount = useMemo(() => markdownContentLines.length, [markdownContentLines])
|
const contentLineCount = useMemo(() => markdownContentLines.length, [markdownContentLines])
|
||||||
const [onLineMarkerPositionChanged, onUserScroll] = useDocumentSyncScrolling(
|
const [recalculateLineMarkers, onUserScroll] = useDocumentSyncScrolling(
|
||||||
internalDocumentRenderPaneRef,
|
internalDocumentRenderPaneRef,
|
||||||
rendererRef,
|
rendererRef,
|
||||||
contentLineCount,
|
contentLineCount,
|
||||||
|
@ -95,7 +95,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
|
||||||
className={`mb-3 ${additionalRendererClasses ?? ''}`}
|
className={`mb-3 ${additionalRendererClasses ?? ''}`}
|
||||||
markdownContentLines={markdownContentLines}
|
markdownContentLines={markdownContentLines}
|
||||||
onFirstHeadingChange={onFirstHeadingChange}
|
onFirstHeadingChange={onFirstHeadingChange}
|
||||||
onLineMarkerPositionChanged={onLineMarkerPositionChanged}
|
onLineMarkerPositionChanged={recalculateLineMarkers}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
newlinesAreBreaks={newlinesAreBreaks}
|
newlinesAreBreaks={newlinesAreBreaks}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -3,11 +3,10 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
import { HtmlToReact } from '../../../../components/common/html-to-react/html-to-react'
|
||||||
import { sanitize } from 'dompurify'
|
|
||||||
import type { HLJSApi } from 'highlight.js'
|
import type { HLJSApi } from 'highlight.js'
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import React, { Fragment, useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlights the given code using highlight.js. If the language wasn't recognized then it won't be highlighted.
|
* Highlights the given code using highlight.js. If the language wasn't recognized then it won't be highlighted.
|
||||||
|
@ -38,7 +37,7 @@ export const useCodeDom = (code: string, hljs: HLJSApi | undefined, language?: s
|
||||||
* @return the code represented as react elements
|
* @return the code represented as react elements
|
||||||
*/
|
*/
|
||||||
const createHtmlLinesToReactDOM = (code: string[]): ReactElement[] => {
|
const createHtmlLinesToReactDOM = (code: string[]): ReactElement[] => {
|
||||||
return code.map((line, lineIndex) => <Fragment key={lineIndex}>{convertHtmlToReact(sanitize(line))}</Fragment>)
|
return code.map((line, lineIndex) => <HtmlToReact htmlCode={line} key={lineIndex} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -20,10 +20,6 @@ import type Token from 'markdown-it/lib/token'
|
||||||
* @see https://plantuml.com
|
* @see https://plantuml.com
|
||||||
*/
|
*/
|
||||||
export class PlantumlMarkdownExtension extends MarkdownRendererExtension {
|
export class PlantumlMarkdownExtension extends MarkdownRendererExtension {
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
private plantumlError(markdownIt: MarkdownIt): void {
|
private plantumlError(markdownIt: MarkdownIt): void {
|
||||||
const defaultRenderer: Renderer.RenderRule = markdownIt.renderer.rules.fence || (() => '')
|
const defaultRenderer: Renderer.RenderRule = markdownIt.renderer.rules.fence || (() => '')
|
||||||
markdownIt.renderer.rules.fence = (tokens: Token[], idx: number, options: Options, env, slf: Renderer) => {
|
markdownIt.renderer.rules.fence = (tokens: Token[], idx: number, options: Options, env, slf: Renderer) => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue