mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 07:04:45 -04:00
Refactor HighlightedCode
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
e565a548ee
commit
9b118ac203
6 changed files with 207 additions and 76 deletions
|
@ -487,18 +487,18 @@
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
"avatarOf": "avatar of '{{name}}'",
|
"avatarOf": "avatar of '{{name}}'",
|
||||||
"why": "Why?",
|
"why": "Why?",
|
||||||
"loading": "Loading ...",
|
"loading": "Loading ...",
|
||||||
"errorOccurred": "An error occurred",
|
"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.",
|
"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"
|
"readForMoreInfo": "Read here for more information"
|
||||||
},
|
},
|
||||||
"copyOverlay": {
|
"copyOverlay": {
|
||||||
"error": "Error while copying!",
|
"error": "Error while copying!",
|
||||||
|
|
45
src/components/common/async-library-loading-boundary.tsx
Normal file
45
src/components/common/async-library-loading-boundary.tsx
Normal file
|
@ -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<AsyncLoadingBoundaryProps> = ({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
componentName,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
useTranslation()
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant={'danger'}>
|
||||||
|
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} />
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
} else if (loading) {
|
||||||
|
return <WaitSpinner />
|
||||||
|
} else {
|
||||||
|
return <Fragment>{children}</Fragment>
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,14 +15,14 @@ import { HighlightedCode } from './highlighted-code'
|
||||||
export class HighlightedCodeReplacer extends ComponentReplacer {
|
export class HighlightedCodeReplacer extends ComponentReplacer {
|
||||||
private lastLineNumber = 0
|
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]
|
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
|
||||||
? ComponentReplacer.extractTextChildContent(codeNode)
|
? ComponentReplacer.extractTextChildContent(codeNode)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
public replace(codeNode: Element): React.ReactElement | undefined {
|
public replace(codeNode: Element): React.ReactElement | undefined {
|
||||||
const code = this.extractCode(codeNode)
|
const code = HighlightedCodeReplacer.extractCode(codeNode)
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,15 +4,13 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactElement } from 'react'
|
import React from 'react'
|
||||||
import React, { Fragment, useEffect, useState } from 'react'
|
|
||||||
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
|
||||||
import { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
|
import { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
|
||||||
import styles from './highlighted-code.module.scss'
|
import styles from './highlighted-code.module.scss'
|
||||||
import { Logger } from '../../../../utils/logger'
|
|
||||||
import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute'
|
import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute'
|
||||||
|
import { AsyncLibraryLoadingBoundary } from '../../../common/async-library-loading-boundary'
|
||||||
const log = new Logger('HighlightedCode')
|
import { useAsyncHighlightedCodeDom } from './hooks/use-async-highlighted-code-dom'
|
||||||
|
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers'
|
||||||
|
|
||||||
export interface HighlightedCodeProps {
|
export interface HighlightedCodeProps {
|
||||||
code: string
|
code: string
|
||||||
|
@ -21,68 +19,34 @@ export interface HighlightedCodeProps {
|
||||||
wrapLines: boolean
|
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, '"')
|
|
||||||
.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<HighlightedCodeProps> = ({ code, language, startLineNumber, wrapLines }) => {
|
export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language, startLineNumber, wrapLines }) => {
|
||||||
const [dom, setDom] = useState<ReactElement[]>()
|
|
||||||
|
|
||||||
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) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<span {...cypressId('linenumber')} className={styles['linenumber']}>
|
|
||||||
{(startLineNumber || 1) + index}
|
|
||||||
</span>
|
|
||||||
<div {...cypressId('codeline')} className={styles['codeline']}>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
))
|
|
||||||
setDom(replacedDom)
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
log.error('Error while loading highlight.js', error)
|
|
||||||
})
|
|
||||||
}, [code, language, startLineNumber])
|
|
||||||
|
|
||||||
const showGutter = startLineNumber !== undefined
|
const showGutter = startLineNumber !== undefined
|
||||||
|
const { loading, error, value: highlightedLines } = useAsyncHighlightedCodeDom(code, language)
|
||||||
|
const wrappedDomLines = useAttachLineNumbers(highlightedLines, startLineNumber)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
|
<AsyncLibraryLoadingBoundary loading={loading} error={error} componentName={'highlight.js'}>
|
||||||
<code
|
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
|
||||||
{...cypressId('code-highlighter')}
|
<code
|
||||||
{...cypressAttribute('showgutter', showGutter ? 'true' : 'false')}
|
{...cypressId('code-highlighter')}
|
||||||
{...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')}
|
{...cypressAttribute('showgutter', showGutter ? 'true' : 'false')}
|
||||||
className={`hljs ${showGutter ? styles['showGutter'] : ''} ${wrapLines ? styles['wrapLines'] : ''}`}>
|
{...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')}
|
||||||
{dom}
|
className={`hljs ${showGutter ? styles['showGutter'] : ''} ${wrapLines ? styles['wrapLines'] : ''}`}>
|
||||||
</code>
|
{wrappedDomLines}
|
||||||
<div className={'text-right button-inside'}>
|
</code>
|
||||||
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
|
<div className={'text-right button-inside'}>
|
||||||
|
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AsyncLibraryLoadingBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) => (
|
||||||
|
<Fragment key={lineIndex}>
|
||||||
|
{convertHtmlToReact(line, {
|
||||||
|
preprocessNodes: nodeProcessor
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReactElement[]> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span {...cypressId('linenumber')} className={styles['linenumber']}>
|
||||||
|
{startLineNumber + index}
|
||||||
|
</span>
|
||||||
|
<div {...cypressId('codeline')} className={styles['codeline']}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)),
|
||||||
|
[startLineNumber, lines]
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue