From 9b118ac20348035167e70eba5223dbd844c963da Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Mon, 28 Mar 2022 20:41:18 +0200 Subject: [PATCH] Refactor HighlightedCode Signed-off-by: Tilman Vatteroth --- locales/en.json | 24 ++--- .../common/async-library-loading-boundary.tsx | 45 ++++++++++ .../highlighted-code-replacer.tsx | 4 +- .../highlighted-fence/highlighted-code.tsx | 88 ++++++------------- .../hooks/use-async-highlighted-code-dom.tsx | 87 ++++++++++++++++++ .../hooks/use-attach-line-numbers.tsx | 35 ++++++++ 6 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 src/components/common/async-library-loading-boundary.tsx create mode 100644 src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-async-highlighted-code-dom.tsx create mode 100644 src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-attach-line-numbers.tsx diff --git a/locales/en.json b/locales/en.json index feb9e070e..226094300 100644 --- a/locales/en.json +++ b/locales/en.json @@ -487,18 +487,18 @@ "refresh": "Refresh", "cancel": "Cancel", "dismiss": "Dismiss", - "ok": "OK", - "close": "Close", - "save": "Save", - "delete": "Delete", - "or": "or", - "and": "and", - "avatarOf": "avatar of '{{name}}'", - "why": "Why?", - "loading": "Loading ...", - "errorOccurred": "An error occurred", - "errorWhileLoadingLibrary": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", - "readForMoreInfo": "Read here for more information" + "ok": "OK", + "close": "Close", + "save": "Save", + "delete": "Delete", + "or": "or", + "and": "and", + "avatarOf": "avatar of '{{name}}'", + "why": "Why?", + "loading": "Loading ...", + "errorOccurred": "An error occurred", + "errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", + "readForMoreInfo": "Read here for more information" }, "copyOverlay": { "error": "Error while copying!", diff --git a/src/components/common/async-library-loading-boundary.tsx b/src/components/common/async-library-loading-boundary.tsx new file mode 100644 index 000000000..5a0f97e11 --- /dev/null +++ b/src/components/common/async-library-loading-boundary.tsx @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { WaitSpinner } from './wait-spinner/wait-spinner' +import { Alert } from 'react-bootstrap' + +export interface AsyncLoadingBoundaryProps { + loading: boolean + error?: Error + componentName: string +} + +/** + * Indicates that a component currently loading or an error occurred. + * It's meant to be used in combination with useAsync from react-use. + * + * @param loading Indicates that the component is currently loading. Setting this will show a spinner instead of the children. + * @param error Indicates that an error occurred during the loading process. Setting this to any non-null value will show an error message instead of the children. + * @param libraryName The name of the component that is currently loading. It will be shown in the error message. + * @param children The child {@link ReactElement elements} that are only shown if the component isn't in loading or error state + */ +export const AsyncLibraryLoadingBoundary: React.FC = ({ + loading, + error, + componentName, + children +}) => { + useTranslation() + if (error) { + return ( + + + + ) + } else if (loading) { + return + } else { + return {children} + } +} diff --git a/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code-replacer.tsx b/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code-replacer.tsx index b9923591c..ffe67eab7 100644 --- a/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code-replacer.tsx +++ b/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code-replacer.tsx @@ -15,14 +15,14 @@ import { HighlightedCode } from './highlighted-code' export class HighlightedCodeReplacer extends ComponentReplacer { private lastLineNumber = 0 - private extractCode(codeNode: Element): string | undefined { + private static 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) + const code = HighlightedCodeReplacer.extractCode(codeNode) if (!code) { return } diff --git a/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code.tsx b/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code.tsx index 579ab1504..0c674050a 100644 --- a/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code.tsx +++ b/src/components/markdown-renderer/markdown-extension/highlighted-fence/highlighted-code.tsx @@ -4,15 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ReactElement } from 'react' -import React, { Fragment, useEffect, useState } from 'react' -import convertHtmlToReact from '@hedgedoc/html-to-react' +import React from 'react' import { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button' import styles from './highlighted-code.module.scss' -import { Logger } from '../../../../utils/logger' import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute' - -const log = new Logger('HighlightedCode') +import { AsyncLibraryLoadingBoundary } from '../../../common/async-library-loading-boundary' +import { useAsyncHighlightedCodeDom } from './hooks/use-async-highlighted-code-dom' +import { useAttachLineNumbers } from './hooks/use-attach-line-numbers' export interface HighlightedCodeProps { code: string @@ -21,68 +19,34 @@ export interface HighlightedCodeProps { wrapLines: boolean } -/* - TODO: Test method or rewrite code so this is not necessary anymore +/** + * Shows the given code as highlighted code block. + * + * @param code The code to highlight + * @param language The language that should be used for highlighting + * @param startLineNumber The number of the first line in the block. Will be 1 if omitted. + * @param wrapLines Defines if lines should be wrapped or if the block should show a scroll bar. */ -const escapeHtml = (unsafe: string): string => { - return unsafe - .replaceAll(/&/g, '&') - .replaceAll(//g, '>') - .replaceAll(/"/g, '"') - .replaceAll(/'/g, ''') -} - -const replaceCode = (code: string): (ReactElement | null | string)[][] => { - return code - .split('\n') - .filter((line) => !!line) - .map((line) => convertHtmlToReact(line, {})) -} - export const HighlightedCode: React.FC = ({ code, language, startLineNumber, wrapLines }) => { - const [dom, setDom] = useState() - - useEffect(() => { - import(/* webpackChunkName: "highlight.js" */ '../../../common/hljs/hljs') - .then((hljs) => { - const languageSupported = (lang: string) => hljs.default.listLanguages().includes(lang) - const unreplacedCode = - !!language && languageSupported(language) - ? hljs.default.highlight(code, { language }).value - : escapeHtml(code) - const replacedDom = replaceCode(unreplacedCode).map((line, index) => ( - - - {(startLineNumber || 1) + index} - -
- {line} -
-
- )) - setDom(replacedDom) - }) - .catch((error: Error) => { - log.error('Error while loading highlight.js', error) - }) - }, [code, language, startLineNumber]) - const showGutter = startLineNumber !== undefined + const { loading, error, value: highlightedLines } = useAsyncHighlightedCodeDom(code, language) + const wrappedDomLines = useAttachLineNumbers(highlightedLines, startLineNumber) return ( -
- - {dom} - -
- + +
+ + {wrappedDomLines} + +
+ +
-
+ ) } diff --git a/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-async-highlighted-code-dom.tsx b/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-async-highlighted-code-dom.tsx new file mode 100644 index 000000000..9fa345d52 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-async-highlighted-code-dom.tsx @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ReactElement } from 'react' +import React, { Fragment } from 'react' +import { MarkdownExtensionCollection } from '../../markdown-extension-collection' +import convertHtmlToReact from '@hedgedoc/html-to-react' +import { useAsync } from 'react-use' +import { Logger } from '../../../../../utils/logger' +import type { AsyncState } from 'react-use/lib/useAsyncFn' + +const nodeProcessor = new MarkdownExtensionCollection([]).buildFlatNodeProcessor() + +/** + * Converts the given html code to react elements without any custom transformation but with sanitizing. + * + * @param code The code to convert + * @return the code represented as react elements + */ +const createHtmlLinesToReactDOM = (code: string[]): ReactElement[] => { + return code.map((line, lineIndex) => ( + + {convertHtmlToReact(line, { + preprocessNodes: nodeProcessor + })} + + )) +} + +/** + * Converts the given line based text to plain text react elements but without interpreting them as html first. + * + * @param text The text to convert + * @return the text represented as react elements. + */ +const createPlaintextToReactDOM = (text: string): ReactElement[] => { + return text.split('\n').map((line, lineIndex) => React.createElement('span', { key: lineIndex }, line)) +} + +export interface HighlightedCodeProps { + code: string + language?: string + startLineNumber?: number +} + +const log = new Logger('HighlightedCode') + +/** + * Highlights the given code using highlight.js. If the language wasn't recognized then it won't be highlighted. + * + * @param code The code to highlight + * @param language The language of the code to use for highlighting + * @return {@link AsyncState async state} that contains the converted React elements + */ +export const useAsyncHighlightedCodeDom = (code: string, language?: string): AsyncState => { + return useAsync(async () => { + try { + const hljs = (await import(/* webpackChunkName: "highlight.js" */ '../../../../common/hljs/hljs')).default + if (!!language && hljs.listLanguages().includes(language)) { + const highlightedHtml = hljs.highlight(code, { language }).value + return createHtmlLinesToReactDOM(omitNewLineAtEnd(highlightedHtml).split('\n')) + } else { + return createPlaintextToReactDOM(code) + } + } catch (error) { + log.error('Error while loading highlight.js', error) + throw error + } + }, [code, language]) +} + +/** + * Returns the given code but without the last new line if the string ends with a new line. + * + * @param code The code to inspect + * @return the modified code + */ +const omitNewLineAtEnd = (code: string): string => { + if (code.endsWith('\n')) { + return code.slice(0, -1) + } else { + return code + } +} diff --git a/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-attach-line-numbers.tsx b/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-attach-line-numbers.tsx new file mode 100644 index 000000000..a47b8d6ab --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/highlighted-fence/hooks/use-attach-line-numbers.tsx @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ReactElement } from 'react' +import { Fragment, useMemo } from 'react' +import { cypressId } from '../../../../../utils/cypress-attribute' +import styles from '../highlighted-code.module.scss' + +/** + * Wraps the given {@link ReactElement elements} to attach line numbers to them. + * + * @param lines The elements to wrap + * @param startLineNumber The line number to start with. Will default to 1 if omitted. + */ +export const useAttachLineNumbers = ( + lines: undefined | ReactElement[], + startLineNumber = 1 +): undefined | ReactElement[] => + useMemo( + () => + lines?.map((line, index) => ( + + + {startLineNumber + index} + +
+ {line} +
+
+ )), + [startLineNumber, lines] + )