mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-19 17:55:17 -04:00
Introduce Markdown extensions (#1614)
* Introduce markdown extensions Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
e9defd60dc
commit
8a8bacc0aa
148 changed files with 1878 additions and 1128 deletions
|
@ -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'} />
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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, '\\$&')
|
||||
}
|
|
@ -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} />
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'} />
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, '&')
|
||||
.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 }) => {
|
||||
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
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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'} />
|
||||
|
||||
<span>{alt ?? title ?? ''}</span>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<ProxyImageFrame alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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} />
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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'} />
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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>`
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue