Introduce Markdown extensions (#1614)

* Introduce markdown extensions

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-11-15 17:04:49 +01:00 committed by GitHub
parent e9defd60dc
commit 8a8bacc0aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
148 changed files with 1878 additions and 1128 deletions

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useRef } from 'react'
import './abc.scss'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('AbcFrame')
export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!container.current) {
return
}
const actualContainer = container.current
import(/* webpackChunkName: "abc.js" */ 'abcjs')
.then((importedLibrary) => {
importedLibrary.renderAbc(actualContainer, code, {})
})
.catch((error: Error) => {
log.error('Error while loading abcjs', error)
})
}, [code])
return <div ref={container} className={'abcjs-score bg-white text-black svg-container'} />
}

View file

@ -1,22 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.abcjs-score {
@import "../../../../style/variables.scss";
.markdown-body & {
overflow-x: auto !important;
}
& > svg {
max-width: unset !important;
}
&, text {
font-family: $font-family-base;
}
}

View file

@ -1,33 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { ClickShield } from '../click-shield/click-shield'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
/**
* Renders an embedding for https://asciinema.org
*
* @param id The id from the asciinema url
*/
export const AsciinemaFrame: React.FC<IdProps> = ({ id }) => {
return (
<ClickShield
hoverIcon={'play'}
targetDescription={'asciinema'}
fallbackPreviewImageUrl={`https://asciinema.org/a/${id}.png`}
fallbackBackgroundColor={'#d40000'}
data-cypress-id={'click-shield-asciinema'}>
<span className={'embed-responsive embed-responsive-16by9'}>
<iframe
className='embed-responsive-item'
title={`asciinema cast ${id}`}
src={`https://asciinema.org/a/${id}/embed?autoplay=1`}
/>
</span>
</ClickShield>
)
}

View file

@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import type MarkdownIt from 'markdown-it/lib'
import markdownItRegex from 'markdown-it-regex'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:asciinema\.org\/a\/)/
const idRegex = /(\d+)/
const tailRegex = /(?:[./?#].*)?/
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
export const asciinemaMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
markdownItRegex(markdownIt, replaceAsciinemaLink)
}
export const replaceAsciinemaLink: RegexOptions = {
name: 'asciinema-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-asciinema id="${match}"></app-asciinema>`
}
}

View file

@ -9,12 +9,12 @@ import { Trans, useTranslation } from 'react-i18next'
import type { IconName } from '../../../common/fork-awesome/types'
import { ShowIf } from '../../../common/show-if/show-if'
import './click-shield.scss'
import { ProxyImageFrame } from '../image/proxy-image-frame'
import { Logger } from '../../../../utils/logger'
import type { Property } from 'csstype'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { ProxyImageFrame } from '../../markdown-extension/image/proxy-image-frame'
const log = new Logger('OneClickEmbedding')

View file

@ -1,70 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import { isTag } from 'domhandler'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../component-replacer'
import { ComponentReplacer } from '../component-replacer'
/**
* Checks if the given node is a blockquote color definition
*
* @param node The node to check
* @return true if the checked node is a blockquote color definition
*/
const isBlockquoteColorDefinition = (node: Element | undefined): boolean => {
if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) {
return false
}
return node.name === 'span' && node.attribs.class === 'quote-extra'
}
/**
* Checks if any of the given nodes is the parent element of a color extra element.
*
* @param nodes The array of nodes to check
* @return the found element or undefined if no element was found
*/
const findBlockquoteColorParentElement = (nodes: Element[]): Element | undefined => {
return nodes.find((child) => {
if (child.name !== 'p' || !child.children || child.children.length < 1) {
return false
}
return child.children.filter(isTag).find(isBlockquoteColorDefinition) !== undefined
})
}
/**
* Detects blockquotes and checks if they contain a color tag.
* If a color tag was found then the color will be applied to the node as border.
*/
export class ColoredBlockquoteReplacer extends ComponentReplacer {
public replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): ValidReactDomElement | undefined {
if (node.name !== 'blockquote' || !node.children || node.children.length < 1) {
return
}
const paragraph = findBlockquoteColorParentElement(node.children.filter(isTag))
if (!paragraph) {
return
}
const childElements = paragraph.children || []
const optionsTag = childElements.filter(isTag).find(isBlockquoteColorDefinition)
if (!optionsTag) {
return
}
paragraph.children = childElements.filter((elem) => !isTag(elem) || !isBlockquoteColorDefinition(elem))
const attributes = optionsTag.attribs
if (!attributes || !attributes['data-color']) {
return
}
node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${attributes['data-color']};` })
return nativeRenderer()
}
}

View file

@ -4,14 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type { Element, Node } from 'domhandler'
import { isText } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import type { ReactElement } from 'react'
export type ValidReactDomElement = ReactElement | string | null
export type SubNodeTransform = (node: Element, subKey: number | string) => ValidReactDomElement | void
export type SubNodeTransform = (node: Node, subKey: number | string) => NodeReplacement
export type NativeRenderer = () => ValidReactDomElement
@ -38,6 +38,17 @@ export abstract class ComponentReplacer {
return isText(childrenTextNode) ? childrenTextNode.data : ''
}
/**
* Applies the given {@link SubNodeTransform sub node transformer} to every children of the given {@link Node}
*
* @param node The node whose children should be transformed
* @param subNodeTransform The transformer that should be used.
* @return The children as react elements.
*/
protected static transformChildren(node: Element, subNodeTransform: SubNodeTransform): NodeReplacement[] {
return node.children.map((value, index) => subNodeTransform(value, index))
}
/**
* Checks if the current node should be altered or replaced and does if needed.
*

View file

@ -1,34 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parseCsv } from './csv-parser'
describe('test CSV parser', () => {
it('normal table', () => {
const input = 'A;B;C\nD;E;F\nG;H;I'
const expected = [
['A', 'B', 'C'],
['D', 'E', 'F'],
['G', 'H', 'I']
]
expect(parseCsv(input, ';')).toEqual(expected)
})
it('blank lines', () => {
const input = 'A;B;C\n\nG;H;I'
const expected = [
['A', 'B', 'C'],
['G', 'H', 'I']
]
expect(parseCsv(input, ';')).toEqual(expected)
})
it('items with delimiter', () => {
const input = 'A;B;C\n"D;E;F"\nG;H;I'
const expected = [['A', 'B', 'C'], ['"D;E;F"'], ['G', 'H', 'I']]
expect(parseCsv(input, ';')).toEqual(expected)
})
})

View file

@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Parses a given text as comma separated values (CSV).
*
* @param csvText The raw csv text
* @param csvColumnDelimiter The delimiter for the columns
* @return the values splitted by rows and columns
*/
export const parseCsv = (csvText: string, csvColumnDelimiter: string): string[][] => {
const rows = csvText.split('\n')
if (!rows || rows.length === 0) {
return []
}
const splitRegex = new RegExp(`${escapeRegexCharacters(csvColumnDelimiter)}(?=(?:[^"]*"[^"]*")*[^"]*$)`)
return rows.filter((row) => row !== '').map((row) => row.split(splitRegex))
}
/**
* Escapes regex characters in the given string so it can be used as literal string in another regex.
* @param unsafe The unescaped string
* @return The escaped string
*/
const escapeRegexCharacters = (unsafe: string): string => {
return unsafe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View file

@ -1,37 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../component-replacer'
import { CsvTable } from './csv-table'
import { CodeBlockComponentReplacer } from '../code-block-component-replacer'
/**
* Detects code blocks with "csv" as language and renders them as table.
*/
export class CsvReplacer extends ComponentReplacer {
public replace(codeNode: Element): React.ReactElement | undefined {
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(codeNode, 'csv')
if (!code) {
return
}
const extraData = codeNode.attribs['data-extra']
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
const extraInfos = extraRegex.exec(extraData)
let delimiter = ','
let showHeader = false
if (extraInfos) {
delimiter = extraInfos[2] || delimiter
showHeader = extraInfos[3] !== undefined
}
return <CsvTable code={code} delimiter={delimiter} showHeader={showHeader} />
}
}

View file

@ -1,70 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { parseCsv } from './csv-parser'
export interface CsvTableProps {
code: string
delimiter: string
showHeader: boolean
tableRowClassName?: string
tableColumnClassName?: string
}
export const CsvTable: React.FC<CsvTableProps> = ({
code,
delimiter,
showHeader,
tableRowClassName,
tableColumnClassName
}) => {
const { rowsWithColumns, headerRow } = useMemo(() => {
const rowsWithColumns = parseCsv(code.trim(), delimiter)
let headerRow: string[] = []
if (showHeader) {
headerRow = rowsWithColumns.splice(0, 1)[0]
}
return { rowsWithColumns, headerRow }
}, [code, delimiter, showHeader])
const renderTableHeader = (row: string[]) => {
if (row !== []) {
return (
<thead>
<tr>
{row.map((column, columnNumber) => (
<th key={`header-${columnNumber}`}>{column}</th>
))}
</tr>
</thead>
)
}
}
const renderTableBody = (rows: string[][]) => {
return (
<tbody>
{rows.map((row, rowNumber) => (
<tr className={tableRowClassName} key={`row-${rowNumber}`}>
{row.map((column, columnIndex) => (
<td className={tableColumnClassName} key={`cell-${rowNumber}-${columnIndex}`}>
{column.replace(/^"|"$/g, '')}
</td>
))}
</tr>
))}
</tbody>
)
}
return (
<table className={'csv-html-table table-striped'}>
{renderTableHeader(headerRow)}
{renderTableBody(rowsWithColumns)}
</table>
)
}

View file

@ -34,8 +34,6 @@ export class CustomTagWithIdComponentReplacer extends ComponentReplacer {
* @return the extracted id or undefined if the element isn't a custom tag or has no id attribute.
*/
private extractId(element: Element): string | undefined {
return element.name === `app-${this.tagName}` && element.attribs && element.attribs.id
? element.attribs.id
: undefined
return element.name === this.tagName && element.attribs && element.attribs.id ? element.attribs.id : undefined
}
}

View file

@ -1,66 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
const log = new Logger('FlowChart')
export interface FlowChartProps {
code: string
}
export const FlowChart: React.FC<FlowChartProps> = ({ code }) => {
const diagramRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState(false)
const darkModeActivated = useIsDarkModeActivated()
useTranslation()
useEffect(() => {
if (diagramRef.current === null) {
return
}
const currentDiagramRef = diagramRef.current
import(/* webpackChunkName: "flowchart.js" */ 'flowchart.js')
.then((importedLibrary) => {
const parserOutput = importedLibrary.parse(code)
try {
parserOutput.drawSVG(currentDiagramRef, {
'line-width': 2,
fill: 'none',
'font-size': 16,
'line-color': darkModeActivated ? '#ffffff' : '#000000',
'element-color': darkModeActivated ? '#ffffff' : '#000000',
'font-color': darkModeActivated ? '#ffffff' : '#000000',
'font-family': 'Source Sans Pro, "Twemoji", monospace'
})
setError(false)
} catch (error) {
setError(true)
}
})
.catch((error: Error) => log.error('Error while loading flowchart.js', error))
return () => {
Array.from(currentDiagramRef.children).forEach((value) => value.remove())
}
}, [code, darkModeActivated])
if (error) {
return (
<Alert variant={'danger'}>
<Trans i18nKey={'renderer.flowchart.invalidSyntax'} />
</Alert>
)
} else {
return <div ref={diagramRef} {...cypressId('flowchart')} className={'text-center'} />
}
}

View file

@ -1,20 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.gist-resizer-row {
display: flex;
justify-content: center;
.gist-resizer {
display: flex;
width: 48px;
height: 5px;
background: white;
border-radius: 90px;
cursor: row-resize;
box-shadow: black 0 0 3px;
}
}

View file

@ -1,49 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { cypressId } from '../../../../utils/cypress-attribute'
import './gist-frame.scss'
import { useResizeGistFrame } from './use-resize-gist-frame'
import { ClickShield } from '../click-shield/click-shield'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
/**
* This component renders a GitHub Gist by placing the gist URL in an {@link HTMLIFrameElement iframe}.
*
* @param id The id of the gist
*/
export const GistFrame: React.FC<IdProps> = ({ id }) => {
const [frameHeight, onStartResizing] = useResizeGistFrame(150)
const onStart = useCallback(
(event: React.MouseEvent | React.TouchEvent) => {
onStartResizing(event)
},
[onStartResizing]
)
return (
<ClickShield
fallbackBackgroundColor={'#161b22'}
hoverIcon={'github'}
targetDescription={'GitHub Gist'}
data-cypress-id={'click-shield-gist'}>
<iframe
sandbox=''
{...cypressId('gh-gist')}
width='100%'
height={`${frameHeight}px`}
frameBorder='0'
title={`gist ${id}`}
src={`https://gist.github.com/${id}.pibb`}
/>
<span className={'gist-resizer-row'}>
<span className={'gist-resizer'} onMouseDown={onStart} onTouchStart={onStart} />
</span>
</ClickShield>
)
}

View file

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

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:gist\.github\.com\/)/
const idRegex = /(\w+\/\w+)/
const tailRegex = /(?:[./?#].*)?/
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
export const replaceGistLink: RegexOptions = {
name: 'gist-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-gist id="${match}"></app-gist>`
}
}

View file

@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%gist (\w+\/\w+) ?%}$/
export const replaceLegacyGistShortCode: RegexOptions = {
name: 'legacy-gist-short-code',
regex: finalRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-gist id="${match}"></app-gist>`
}
}

View file

@ -1,104 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Determines if the left mouse button is pressed in the given event
*
* @param mouseEvent the mouse event that should be checked
* @return {@code true} if the left mouse button is pressed. {@code false} otherwise.
*/
const isLeftMouseButtonPressed = (mouseEvent: MouseEvent): boolean => {
return mouseEvent.buttons === 1
}
/**
* Extracts the absolute vertical position of the mouse or touch point from the event.
*
* @param moveEvent the vertical position of the mouse pointer or the first touch pointer.
* @return the extracted vertical position.
*/
const extractVerticalPointerPosition = (
moveEvent: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent
): number => {
if (isMouseEvent(moveEvent)) {
return moveEvent.pageY
} else {
return moveEvent.touches[0]?.pageY
}
}
/**
* Checks if the given {@link Event} is a {@link MouseEvent} or a {@link React.MouseEvent}
* @param event the event to check
* @return {@code true} if the given event is a {@link MouseEvent} or a {@link React.MouseEvent}
*/
const isMouseEvent = (event: Event | React.UIEvent): event is MouseEvent | React.MouseEvent => {
return (event as MouseEvent).buttons !== undefined
}
export type PointerEvent = React.MouseEvent | React.TouchEvent
export type PointerEventHandler = (event: PointerEvent) => void
/**
* Provides logic for resizing a {@link GistFrame gist frame} by dragging an element.
*
* @param initialFrameHeight The initial size for the frame
* @return An array containing the current frame height and function to start the resizing
*/
export const useResizeGistFrame = (initialFrameHeight: number): [number, PointerEventHandler] => {
const [frameHeight, setFrameHeight] = useState(initialFrameHeight)
const lastYPosition = useRef<number | undefined>(undefined)
const onMove = useCallback((moveEvent: MouseEvent | TouchEvent) => {
if (lastYPosition.current === undefined) {
return
}
if (isMouseEvent(moveEvent) && !isLeftMouseButtonPressed(moveEvent)) {
lastYPosition.current = undefined
moveEvent.preventDefault()
return undefined
}
const currentPointerPosition = extractVerticalPointerPosition(moveEvent)
const deltaPointerPosition = currentPointerPosition - lastYPosition.current
lastYPosition.current = currentPointerPosition
setFrameHeight((oldFrameHeight) => Math.max(0, oldFrameHeight + deltaPointerPosition))
moveEvent.preventDefault()
}, [])
const onStartResizing = useCallback((event: React.MouseEvent | React.TouchEvent) => {
lastYPosition.current = extractVerticalPointerPosition(event)
}, [])
const onStopResizing = useCallback(() => {
if (lastYPosition.current !== undefined) {
lastYPosition.current = undefined
}
}, [])
useEffect(() => {
const moveHandler = onMove
const stopResizeHandler = onStopResizing
window.addEventListener('touchmove', moveHandler)
window.addEventListener('mousemove', moveHandler)
window.addEventListener('touchcancel', stopResizeHandler)
window.addEventListener('touchend', stopResizeHandler)
window.addEventListener('mouseup', stopResizeHandler)
return () => {
window.removeEventListener('touchmove', moveHandler)
window.removeEventListener('mousemove', moveHandler)
window.removeEventListener('touchcancel', stopResizeHandler)
window.removeEventListener('touchend', stopResizeHandler)
window.removeEventListener('mouseup', stopResizeHandler)
}
}, [onMove, onStopResizing])
return [frameHeight, onStartResizing]
}

View file

@ -1,72 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { ShowIf } from '../../../common/show-if/show-if'
import { useFrontendBaseUrl } from '../../../../hooks/common/use-frontend-base-url'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('GraphvizFrame')
export const GraphvizFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const showError = useCallback((error: string) => {
if (!container.current) {
return
}
setError(error)
log.error(error)
container.current.querySelectorAll('svg').forEach((child) => child.remove())
}, [])
const frontendBaseUrl = useFrontendBaseUrl()
useEffect(() => {
if (!container.current) {
return
}
const actualContainer = container.current
import(/* webpackChunkName: "d3-graphviz" */ '@hpcc-js/wasm')
.then((wasmPlugin) => {
wasmPlugin.wasmFolder(`${frontendBaseUrl}/static/js`)
})
.then(() => import(/* webpackChunkName: "d3-graphviz" */ 'd3-graphviz'))
.then((graphvizImport) => {
try {
setError(undefined)
graphvizImport
.graphviz(actualContainer, {
useWorker: false,
zoom: false
})
.onerror(showError)
.renderDot(code)
} catch (error) {
showError(error as string)
}
})
.catch((error: Error) => {
log.error('Error while loading graphviz', error)
})
}, [code, error, frontendBaseUrl, showError])
return (
<Fragment>
<ShowIf condition={!!error}>
<Alert variant={'warning'}>{error}</Alert>
</ShowIf>
<div className={'svg-container'} {...cypressId('graphviz')} ref={container} />
</Fragment>
)
}
export default GraphvizFrame

View file

@ -1,66 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.code-highlighter {
@import '../../../../../../node_modules/highlight.js/styles/github';
body.dark & {
@import '../../../../../../node_modules/highlight.js/styles/github-dark';
}
position: relative;
code.hljs {
overflow-x: auto;
background-color: rgba(27, 31, 35, .05);
body.dark & {
background-color: rgb(27, 31, 35);
}
body.dark &, & {
padding: 16px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
.codeline {
grid-column: 2;
white-space: pre;
}
.linenumber {
grid-column: 1;
position: relative;
cursor: default;
z-index: 4;
padding: 0 8px 0 0;
min-width: 20px;
box-sizing: content-box;
color: #afafaf;
border-right: 3px solid #6ce26c;
flex-direction: column;
overflow: hidden;
user-select: none;
align-items: flex-end;
display: none;
}
&.showGutter {
.linenumber {
display: flex;
}
.codeline {
margin: 0 0 0 16px;
}
}
&.wrapLines .codeline {
white-space: pre-wrap;
}
}
}
}

View file

@ -1,80 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* 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 { CopyToClipboardButton } from '../../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
import '../../../utils/button-inside.scss'
import './highlighted-code.scss'
import { Logger } from '../../../../../utils/logger'
import { cypressId } from '../../../../../utils/cypress-attribute'
const log = new Logger('HighlightedCode')
export interface HighlightedCodeProps {
code: string
language?: string
startLineNumber?: number
wrapLines: boolean
}
/*
TODO: Test method or rewrite code so this is not necessary anymore
*/
const escapeHtml = (unsafe: string): string => {
return unsafe
.replaceAll(/&/g, '&amp;')
.replaceAll(/</g, '&lt;')
.replaceAll(/>/g, '&gt;')
.replaceAll(/"/g, '&quot;')
.replaceAll(/'/g, '&#039;')
}
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 }) => {
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 className={'linenumber'}>{(startLineNumber || 1) + index}</span>
<div className={'codeline'}>{line}</div>
</Fragment>
))
setDom(replacedDom)
})
.catch((error: Error) => {
log.error('Error while loading highlight.js', error)
})
}, [code, language, startLineNumber])
return (
<div className={'code-highlighter'}>
<code className={`hljs ${startLineNumber !== undefined ? 'showGutter' : ''} ${wrapLines ? 'wrapLines' : ''}`}>
{dom}
</code>
<div className={'text-right button-inside'}>
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
</div>
</div>
)
}
export default HighlightedCode

View file

@ -1,60 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../component-replacer'
import { HighlightedCode } from './highlighted-code/highlighted-code'
/**
* Detects code blocks and renders them as highlighted code blocks
*/
export class HighlightedCodeReplacer extends ComponentReplacer {
private lastLineNumber = 0
private extractCode(codeNode: Element): string | undefined {
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
? ComponentReplacer.extractTextChildContent(codeNode)
: undefined
}
public replace(codeNode: Element): React.ReactElement | undefined {
const code = this.extractCode(codeNode)
if (!code) {
return
}
const language = codeNode.attribs['data-highlight-language']
const extraData = codeNode.attribs['data-extra']
const extraInfos = /(=(\d+|\+)?)?(!?)/.exec(extraData)
let showLineNumbers = false
let startLineNumberAttribute = ''
let wrapLines = false
if (extraInfos) {
showLineNumbers = extraInfos[1]?.startsWith('=') || false
startLineNumberAttribute = extraInfos[2]
wrapLines = extraInfos[3] === '!'
}
const startLineNumber =
startLineNumberAttribute === '+' ? this.lastLineNumber : parseInt(startLineNumberAttribute) || 1
if (showLineNumbers) {
this.lastLineNumber = startLineNumber + code.split('\n').filter((line) => !!line).length
}
return (
<HighlightedCode
language={language}
startLineNumber={showLineNumbers ? startLineNumber : undefined}
wrapLines={wrapLines}
code={code}
/>
)
}
}

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Modal } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import './lightbox.scss'
import { ProxyImageFrame } from './proxy-image-frame'
export interface ImageLightboxModalProps {
show: boolean
onHide: () => void
alt?: string
src?: string
title?: string
}
export const ImageLightboxModal: React.FC<ImageLightboxModalProps> = ({ show, onHide, src, alt, title }) => {
return (
<Modal
animation={true}
centered={true}
dialogClassName={'text-dark lightbox'}
show={show && !!src}
onHide={onHide}
size={'xl'}>
<Modal.Header closeButton={true}>
<Modal.Title className={'h6'}>
<ForkAwesomeIcon icon={'picture-o'} />
&nbsp;
<span>{alt ?? title ?? ''}</span>
</Modal.Title>
</Modal.Header>
<ProxyImageFrame alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide} />
</Modal>
)
}

View file

@ -1,41 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import { ComponentReplacer } from '../component-replacer'
import { ProxyImageFrame } from './proxy-image-frame'
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void
/**
* Detects image tags and loads them via image proxy if configured.
*/
export class ImageReplacer extends ComponentReplacer {
private readonly clickHandler?: ImageClickHandler
constructor(clickHandler?: ImageClickHandler) {
super()
this.clickHandler = clickHandler
}
public replace(node: Element): React.ReactElement | undefined {
if (node.name === 'img') {
return (
<ProxyImageFrame
id={node.attribs.id}
className={`${node.attribs.class} cursor-zoom-in`}
src={node.attribs.src}
alt={node.attribs.alt}
title={node.attribs.title}
width={node.attribs.width}
height={node.attribs.height}
onClick={this.clickHandler}
/>
)
}
}
}

View file

@ -1,11 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.lightbox img {
max-width: calc(100vw - 3.5rem);
max-height: calc(100vh - 3.5rem - 75px);
object-fit: contain;
}

View file

@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useState } from 'react'
import { getProxiedUrl } from '../../../../api/media'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { Logger } from '../../../../utils/logger'
const log = new Logger('ProxyImageFrame')
export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = ({ src, title, alt, ...props }) => {
const [imageUrl, setImageUrl] = useState('')
const imageProxyEnabled = useApplicationState((state) => state.config.useImageProxy)
useEffect(() => {
if (!imageProxyEnabled || !src) {
return
}
getProxiedUrl(src)
.then((proxyResponse) => setImageUrl(proxyResponse.src))
.catch((err) => log.error(err))
}, [imageProxyEnabled, src])
return <img src={imageProxyEnabled ? imageUrl : src ?? ''} title={title ?? alt ?? ''} alt={alt} {...props} />
}

View file

@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface ImageProxyResponse {
src: string
}

View file

@ -1,64 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import { isTag } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import mathJax from 'markdown-it-mathjax'
import React from 'react'
import { ComponentReplacer, DO_NOT_REPLACE } from '../component-replacer'
import './katex.scss'
/**
* Checks if the given node is a KaTeX block.
*
* @param node the node to check
* @return The given node if it is a KaTeX block element, undefined otherwise.
*/
const containsKatexBlock = (node: Element): Element | undefined => {
if (node.name !== 'p' || !node.children || node.children.length === 0) {
return
}
return node.children.filter(isTag).find((subnode) => {
return isKatexTag(subnode, false) ? subnode : undefined
})
}
/**
* Checks if the given node is a KaTeX element.
*
* @param node the node to check
* @param expectedInline defines if the found katex element is expected to be an inline or block element.
* @return {@code true} if the given node is a katex element.
*/
const isKatexTag = (node: Element, expectedInline: boolean) => {
return node.name === 'app-katex' && (node.attribs?.['data-inline'] !== undefined) === expectedInline
}
const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ '@matejmazur/react-katex'))
/**
* Detects LaTeX syntax and renders it with KaTeX.
*/
export class KatexReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = mathJax({
beforeMath: '<app-katex>',
afterMath: '</app-katex>',
beforeInlineMath: '<app-katex data-inline="true">',
afterInlineMath: '</app-katex>',
beforeDisplayMath: '<app-katex>',
afterDisplayMath: '</app-katex>'
})
public replace(node: Element): React.ReactElement | undefined {
if (!(isKatexTag(node, true) || containsKatexBlock(node)) || node.children?.[0] === undefined) {
return DO_NOT_REPLACE
}
const latexContent = ComponentReplacer.extractTextChildContent(node)
const isInline = !!node.attribs?.['data-inline']
return <KaTeX block={!isInline} math={latexContent} errorColor={'#cc0000'} />
}
}

View file

@ -1,7 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@import '../../../../../node_modules/katex/dist/katex.min';

View file

@ -1,86 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
export interface LineMarkers {
startLine: number
endLine: number
}
export type LineNumberMarkerOptions = (lineMarkers: LineMarkers[]) => void
/**
* This plugin adds markers to the dom, that are used to map line numbers to dom elements.
* It also provides a list of line numbers for the top level dom elements.
*/
export const lineNumberMarker: (options: LineNumberMarkerOptions, lineOffset: number) => MarkdownIt.PluginSimple =
(options, lineOffset = 0) =>
(md: MarkdownIt) => {
// add app_linemarker token before each opening or self-closing level-0 tag
md.core.ruler.push('line_number_marker', (state) => {
const lineMarkers: LineMarkers[] = []
tagTokens(state.tokens, lineMarkers, lineOffset)
if (options) {
options(lineMarkers)
}
return true
})
md.renderer.rules.app_linemarker = (tokens: Token[], index: number): string => {
const startLineNumber = tokens[index].attrGet('data-start-line')
const endLineNumber = tokens[index].attrGet('data-end-line')
if (!startLineNumber || !endLineNumber) {
// don't render broken linemarkers without a linenumber
return ''
}
// noinspection CheckTagEmptyBody
return `<app-linemarker data-start-line='${startLineNumber}' data-end-line='${endLineNumber}'></app-linemarker>`
}
const insertNewLineMarker = (
startLineNumber: number,
endLineNumber: number,
tokenPosition: number,
level: number,
tokens: Token[]
) => {
const startToken = new Token('app_linemarker', 'app-linemarker', 0)
startToken.level = level
startToken.attrPush(['data-start-line', `${startLineNumber}`])
startToken.attrPush(['data-end-line', `${endLineNumber}`])
tokens.splice(tokenPosition, 0, startToken)
}
const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[], lineOffset: number) => {
for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) {
const token = tokens[tokenPosition]
if (token.hidden) {
continue
}
if (!token.map) {
continue
}
const startLineNumber = token.map[0] + 1
const endLineNumber = token.map[1] + 1
if (token.level === 0) {
lineMarkers.push({ startLine: startLineNumber + lineOffset, endLine: endLineNumber + lineOffset })
}
insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens)
tokenPosition += 1
if (token.children) {
tagTokens(token.children, lineMarkers, lineOffset)
}
}
}
}

View file

@ -1,17 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import { ComponentReplacer } from '../component-replacer'
/**
* Detects line markers and suppresses them in the resulting DOM.
*/
export class LinemarkerReplacer extends ComponentReplacer {
public replace(codeNode: Element): null | undefined {
return codeNode.name === 'app-linemarker' ? null : undefined
}
}

View file

@ -1,55 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import React from 'react'
import type { NativeRenderer, SubNodeTransform, ValidReactDomElement } from '../component-replacer'
import { ComponentReplacer } from '../component-replacer'
export const createJumpToMarkClickEventHandler = (id: string) => {
return (event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
document.getElementById(id)?.scrollIntoView()
event.preventDefault()
}
}
/**
* Detects link tags and polishs them.
* This replacer prevents data and javascript links,
* extends relative links with the base url and creates working jump links.
*/
export class LinkReplacer extends ComponentReplacer {
constructor(private baseUrl?: string) {
super()
}
public replace(
node: Element,
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): ValidReactDomElement | undefined {
if (node.name !== 'a' || !node.attribs || !node.attribs.href) {
return undefined
}
const url = node.attribs.href.trim()
const isJumpMark = url.substr(0, 1) === '#'
const id = url.substr(1)
try {
node.attribs.href = new URL(url, this.baseUrl).toString()
} catch (e) {
node.attribs.href = url
}
if (isJumpMark) {
return <span onClick={createJumpToMarkClickEventHandler(id)}>{nativeRenderer()}</span>
} else {
node.attribs.rel = 'noreferer noopener'
node.attribs.target = '_blank'
return nativeRenderer()
}
}
}

View file

@ -1,78 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LockButton } from '../../../common/lock-button/lock-button'
import '../../utils/button-inside.scss'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('MarkmapFrame')
const blockHandler = (event: Event): void => {
event.stopPropagation()
}
export const MarkmapFrame: React.FC<CodeProps> = ({ code }) => {
const { t } = useTranslation()
const diagramContainer = useRef<HTMLDivElement>(null)
const [disablePanAndZoom, setDisablePanAndZoom] = useState(true)
useEffect(() => {
if (diagramContainer.current) {
if (disablePanAndZoom) {
diagramContainer.current.addEventListener('wheel', blockHandler, true)
diagramContainer.current.addEventListener('mousedown', blockHandler, true)
diagramContainer.current.addEventListener('click', blockHandler, true)
diagramContainer.current.addEventListener('dblclick', blockHandler, true)
diagramContainer.current.addEventListener('touchstart', blockHandler, true)
} else {
diagramContainer.current.removeEventListener('wheel', blockHandler, true)
diagramContainer.current.removeEventListener('mousedown', blockHandler, true)
diagramContainer.current.removeEventListener('click', blockHandler, true)
diagramContainer.current.removeEventListener('dblclick', blockHandler, true)
diagramContainer.current.removeEventListener('touchstart', blockHandler, true)
}
}
}, [diagramContainer, disablePanAndZoom])
useEffect(() => {
if (!diagramContainer.current) {
return
}
const actualContainer = diagramContainer.current
import(/* webpackChunkName: "markmap" */ './markmap-loader')
.then(({ markmapLoader }) => {
try {
const svg: SVGSVGElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('width', '100%')
actualContainer.querySelectorAll('svg').forEach((child) => child.remove())
actualContainer.appendChild(svg)
markmapLoader(svg, code)
} catch (error) {
log.error(error)
}
})
.catch((error: Error) => {
log.error('Error while loading markmap', error)
})
}, [code])
return (
<div {...cypressId('markmap')} className={'position-relative'}>
<div className={'svg-container'} ref={diagramContainer} />
<div className={'text-right button-inside'}>
<LockButton
locked={disablePanAndZoom}
onLockedChanged={(newState) => setDisablePanAndZoom(newState)}
title={disablePanAndZoom ? t('renderer.markmap.locked') : t('renderer.markmap.unlocked')}
/>
</div>
</div>
)
}

View file

@ -1,15 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Transformer } from 'markmap-lib/dist/index.esm'
import { Markmap } from 'markmap-view'
const transformer: Transformer = new Transformer()
export const markmapLoader = (svg: SVGSVGElement, code: string): void => {
const { root } = transformer.transform(code)
Markmap.create(svg, {}, root)
}

View file

@ -1,84 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ShowIf } from '../../../common/show-if/show-if'
import './mermaid.scss'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('MermaidChart')
interface MermaidParseError {
str: string
}
let mermaidInitialized = false
export const MermaidChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const { t } = useTranslation()
useEffect(() => {
if (!mermaidInitialized) {
import(/* webpackChunkName: "mermaid" */ 'mermaid')
.then((mermaid) => {
mermaid.default.initialize({ startOnLoad: false })
mermaidInitialized = true
})
.catch((error: Error) => {
log.error('Error while loading mermaid', error)
})
}
}, [])
const showError = useCallback(
(error: string) => {
setError(error)
log.error(error)
if (!diagramContainer.current) {
return
}
diagramContainer.current.querySelectorAll('svg').forEach((child) => child.remove())
},
[setError]
)
useEffect(() => {
if (!diagramContainer.current) {
return
}
import(/* webpackChunkName: "mermaid" */ 'mermaid')
.then((mermaid) => {
try {
if (!diagramContainer.current) {
return
}
mermaid.default.parse(code)
delete diagramContainer.current.dataset.processed
diagramContainer.current.textContent = code
mermaid.default.init(diagramContainer.current)
setError(undefined)
} catch (error) {
const message = (error as MermaidParseError).str
showError(message || t('renderer.mermaid.unknownError'))
}
})
.catch(() => showError('Error while loading mermaid'))
}, [code, showError, t])
return (
<Fragment>
<ShowIf condition={!!error}>
<Alert variant={'warning'}>{error}</Alert>
</ShowIf>
<div className={'text-center mermaid text-black'} ref={diagramContainer} />
</Fragment>
)
}

View file

@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.mermaid > svg {
background-color: #f8f9fa;
}

View file

@ -1,26 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import links from '../../../../links.json'
import { cypressId } from '../../../../utils/cypress-attribute'
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
export const DeprecationWarning: React.FC = () => {
useTranslation()
return (
<Alert {...cypressId('yaml')} className={'mt-2'} variant={'warning'}>
<span className={'text-wrap'}>
<Trans i18nKey={'renderer.sequence.deprecationWarning'} />
</span>
<br />
<TranslatedExternalLink i18nKey={'common.readForMoreInfo'} className={'text-primary'} href={links.faq} />
</Alert>
)
}

View file

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

View file

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

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type { ReactElement } from 'react'
import React from 'react'
import { ComponentReplacer } from '../component-replacer'
import { TaskListCheckbox } from './task-list-checkbox'
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void
/**
* Detects task lists and renders them as checkboxes that execute a callback if clicked.
*/
export class TaskListReplacer extends ComponentReplacer {
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
constructor(frontmatterLinesToSkip?: number, onTaskCheckedChange?: TaskCheckedChangeHandler) {
super()
this.onTaskCheckedChange = (lineInMarkdown, checked) => {
if (onTaskCheckedChange === undefined || frontmatterLinesToSkip === undefined) {
return
}
onTaskCheckedChange(frontmatterLinesToSkip + lineInMarkdown, checked)
}
}
public replace(node: Element): ReactElement | undefined {
if (node.attribs?.class !== 'task-list-item-checkbox') {
return
}
const lineInMarkdown = Number(node.attribs['data-line'])
if (isNaN(lineInMarkdown)) {
return undefined
}
return (
<TaskListCheckbox
onTaskCheckedChange={this.onTaskCheckedChange}
checked={node.attribs.checked !== undefined}
lineInMarkdown={lineInMarkdown}
/>
)
}
}

View file

@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import type { VisualizationSpec } from 'vega-embed'
import { ShowIf } from '../../../common/show-if/show-if'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer'
const log = new Logger('VegaChart')
export const VegaChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const { t } = useTranslation()
const showError = useCallback((error: string) => {
if (!diagramContainer.current) {
return
}
log.error(error)
setError(error)
}, [])
useEffect(() => {
if (!diagramContainer.current) {
return
}
import(/* webpackChunkName: "vega" */ 'vega-embed')
.then((embed) => {
try {
if (!diagramContainer.current) {
return
}
const spec = JSON.parse(code) as VisualizationSpec
embed
.default(diagramContainer.current, spec, {
actions: {
export: true,
source: false,
compiled: false,
editor: false
},
i18n: {
PNG_ACTION: t('renderer.vega-lite.png'),
SVG_ACTION: t('renderer.vega-lite.svg')
}
})
.then(() => setError(undefined))
.catch((error: Error) => showError(error.message))
} catch (error) {
showError(t('renderer.vega-lite.errorJson'))
}
})
.catch((error: Error) => {
log.error('Error while loading vega-light', error)
})
}, [code, showError, t])
return (
<Fragment>
<ShowIf condition={!!error}>
<Alert variant={'danger'}>{error}</Alert>
</ShowIf>
<div className={'text-center'}>
<div ref={diagramContainer} />
</div>
</Fragment>
)
}

View file

@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
export const replaceLegacyVimeoShortCode: RegexOptions = {
name: 'legacy-vimeo-short-code',
regex: /^{%vimeo ([\d]{6,11}) ?%}$/,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-vimeo id="${match}"></app-vimeo>`
}
}

View file

@ -1,26 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:player\.)?(?:vimeo\.com\/)(?:(?:channels|album|ondemand|groups)\/\w+\/)?(?:video\/)?/
const idRegex = /([\d]{6,11})/
const tailRegex = /(?:[?#].*)?/
const vimeoVideoUrlRegex = new RegExp(
`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`
)
const linkRegex = new RegExp(`^${vimeoVideoUrlRegex.source}$`, 'i')
export const replaceVimeoLink: RegexOptions = {
name: 'vimeo-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-vimeo id="${match}"></app-vimeo>`
}
}

View file

@ -1,57 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { ClickShield } from '../click-shield/click-shield'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
interface VimeoApiResponse {
// Vimeo uses strange names for their fields. ESLint doesn't like that.
// eslint-disable-next-line camelcase
thumbnail_large?: string
}
/**
* Renders a video player embedding for https://vimeo.com
*
* @param id The id from the vimeo video url
*/
export const VimeoFrame: React.FC<IdProps> = ({ id }) => {
const getPreviewImageLink = useCallback(async () => {
const response = await fetch(`https://vimeo.com/api/v2/video/${id}.json`, {
credentials: 'omit',
referrerPolicy: 'no-referrer'
})
if (response.status !== 200) {
throw new Error('Error while loading data from vimeo api')
}
const vimeoResponse: VimeoApiResponse[] = (await response.json()) as VimeoApiResponse[]
if (vimeoResponse[0] && vimeoResponse[0].thumbnail_large) {
return vimeoResponse[0].thumbnail_large
} else {
throw new Error('Invalid vimeo response')
}
}, [id])
return (
<ClickShield
hoverIcon={'vimeo-square'}
targetDescription={'Vimeo'}
onImageFetch={getPreviewImageLink}
fallbackBackgroundColor={'#00adef'}
data-cypress-id={'click-shield-vimeo'}>
<span className={'embed-responsive embed-responsive-16by9'}>
<iframe
className='embed-responsive-item'
title={`vimeo video of ${id}`}
src={`https://player.vimeo.com/video/${id}?autoplay=1`}
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
/>
</span>
</ClickShield>
)
}

View file

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

View file

@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
export const replaceLegacyYoutubeShortCode: RegexOptions = {
name: 'legacy-youtube-short-code',
regex: /^{%youtube ([^"&?\\/\s]{11}) ?%}$/,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-youtube id="${match}"></app-youtube>`
}
}

View file

@ -1,27 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const subdomainRegex = /(?:www.)?/
const pathRegex = /(?:youtube(?:-nocookie)?\.com\/(?:[^\\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)/
const idRegex = /([^"&?\\/\s]{11})/
const tailRegex = /(?:[?&#].*)?/
const youtubeVideoUrlRegex = new RegExp(
`(?:${protocolRegex.source}${subdomainRegex.source}${pathRegex.source}${idRegex.source}${tailRegex.source})`
)
const linkRegex = new RegExp(`^${youtubeVideoUrlRegex.source}$`, 'i')
export const replaceYouTubeLink: RegexOptions = {
name: 'youtube-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<app-youtube id="${match}"></app-youtube>`
}
}

View file

@ -1,34 +0,0 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { ClickShield } from '../click-shield/click-shield'
import type { IdProps } from '../custom-tag-with-id-component-replacer'
/**
* Renders a video player embedding for https://youtube.com
*
* @param id The id from the youtube video url
*/
export const YouTubeFrame: React.FC<IdProps> = ({ id }) => {
return (
<ClickShield
hoverIcon={'youtube-play'}
targetDescription={'YouTube'}
fallbackPreviewImageUrl={`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`}
fallbackBackgroundColor={'#ff0000'}
data-cypress-id={'click-shield-youtube'}>
<span className={'embed-responsive embed-responsive-16by9'}>
<iframe
className='embed-responsive-item'
title={`youtube video of ${id}`}
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1`}
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
/>
</span>
</ClickShield>
)
}

View file

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