mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-22 11:15:23 -04:00
refactor: organize app extensions
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
8cddc96881
commit
1e4709c087
209 changed files with 286 additions and 243 deletions
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { AlertMarkdownExtension } from './alert-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const alertRegex = /(?:^|\s):(?::|::|::\w+)?/
|
||||
|
||||
/**
|
||||
* Adds alert boxes to the markdown rendering.
|
||||
*/
|
||||
export class AlertAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new AlertMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'alert' }]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
basicCompletion(alertRegex, ':::success\n\n:::', t('editor.autocompletions.successBox') ?? undefined),
|
||||
basicCompletion(alertRegex, ':::info\n\n:::', t('editor.autocompletions.infoBox') ?? undefined),
|
||||
basicCompletion(alertRegex, ':::warning\n\n:::', t('editor.autocompletions.warningBox') ?? undefined),
|
||||
basicCompletion(alertRegex, ':::danger\n\n:::', t('editor.autocompletions.errorBox') ?? undefined)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import markdownItContainer from 'markdown-it-container'
|
||||
import type Renderer from 'markdown-it/lib/renderer'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
|
||||
export const alertLevels = ['success', 'danger', 'info', 'warning']
|
||||
|
||||
/**
|
||||
* Adds alert boxes to the markdown rendering.
|
||||
*/
|
||||
export class AlertMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
alertLevels.forEach((level) => {
|
||||
markdownItContainer(markdownIt, level, {
|
||||
render: (tokens: Token[], index: number, options: MarkdownIt.Options, env: unknown, self: Renderer) => {
|
||||
tokens[index].attrJoin('role', 'alert')
|
||||
tokens[index].attrJoin('class', 'alert')
|
||||
tokens[index].attrJoin('class', `alert-${level}`)
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { BasicMarkdownSyntaxMarkdownExtension } from './basic-markdown-syntax-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export class BasicMarkdownSyntaxAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new BasicMarkdownSyntaxMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'basics.basicFormatting',
|
||||
categoryI18nKey: 'basic'
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.abbreviation',
|
||||
categoryI18nKey: 'basic'
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.footnote',
|
||||
categoryI18nKey: 'basic'
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.headlines',
|
||||
categoryI18nKey: 'basic',
|
||||
entries: [
|
||||
{
|
||||
i18nKey: 'hashtag'
|
||||
},
|
||||
{
|
||||
i18nKey: 'equal'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.code',
|
||||
categoryI18nKey: 'basic',
|
||||
entries: [{ i18nKey: 'inline' }, { i18nKey: 'block' }]
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.lists',
|
||||
categoryI18nKey: 'basic',
|
||||
entries: [{ i18nKey: 'unordered' }, { i18nKey: 'ordered' }]
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.images',
|
||||
categoryI18nKey: 'basic',
|
||||
entries: [{ i18nKey: 'basic' }, { i18nKey: 'size' }]
|
||||
},
|
||||
{
|
||||
i18nKey: 'basics.links',
|
||||
categoryI18nKey: 'basic'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [basicCompletion(/(^|\s)\[/, '[](https://)', t('editor.autocompletions.link') ?? undefined)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { imageSize } from '@hedgedoc/markdown-it-plugins'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import abbreviation from 'markdown-it-abbr'
|
||||
import definitionList from 'markdown-it-deflist'
|
||||
import footnote from 'markdown-it-footnote'
|
||||
import inserted from 'markdown-it-ins'
|
||||
import marked from 'markdown-it-mark'
|
||||
import subscript from 'markdown-it-sub'
|
||||
import superscript from 'markdown-it-sup'
|
||||
|
||||
/**
|
||||
* Adds some common markdown syntaxes to the markdown rendering.
|
||||
*/
|
||||
export class BasicMarkdownSyntaxMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
abbreviation(markdownIt)
|
||||
definitionList(markdownIt)
|
||||
subscript(markdownIt)
|
||||
superscript(markdownIt)
|
||||
inserted(markdownIt)
|
||||
marked(markdownIt)
|
||||
footnote(markdownIt)
|
||||
imageSize(markdownIt)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`blockquote extra tag renders the tag "> [color=#f00] text" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<blockquote
|
||||
style="border-left-color: #f00;"
|
||||
>
|
||||
|
||||
|
||||
<p>
|
||||
text
|
||||
</p>
|
||||
|
||||
|
||||
</blockquote>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[=value]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[=value]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[color=#abcdef]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
style="color: rgb(171, 205, 239);"
|
||||
>
|
||||
BootstrapIconMock_Tag
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[color=#dfe]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
style="color: rgb(221, 255, 238);"
|
||||
>
|
||||
BootstrapIconMock_Tag
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[color=]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[color=]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[color=notarealcolor]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
>
|
||||
notarealcolor
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[color=white]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
style="color: white;"
|
||||
>
|
||||
BootstrapIconMock_Tag
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[key=]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[key=]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[key]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[key]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[name=]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[name=]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[name=giowehg]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
>
|
||||
giowehg
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[time=]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
[time=]
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`blockquote extra tag renders the tag "[time=tomorrow]" correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
blockquote
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<span
|
||||
class="blockquote-extra"
|
||||
>
|
||||
tomorrow
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const blockquoteTagRegex = /(?:^|\s)\[(?:\w+)?/
|
||||
|
||||
/**
|
||||
* Adds support for generic blockquote extra tags and blockquote color extra tags.
|
||||
*/
|
||||
export class BlockquoteAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new BlockquoteExtraTagMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'blockquoteTags', entries: [{ i18nKey: 'name' }, { i18nKey: 'color' }, { i18nKey: 'time' }] }]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
basicCompletion(blockquoteTagRegex, '[name=]', t('editor.autocompletions.tagName') ?? undefined),
|
||||
basicCompletion(blockquoteTagRegex, '[time=]', t('editor.autocompletions.tagTime') ?? undefined),
|
||||
basicCompletion(blockquoteTagRegex, '[color=]', t('editor.autocompletions.tagColor') ?? undefined)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { TravelerNodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/traveler-node-processor'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Element, Node } from 'domhandler'
|
||||
import { isTag, isText } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Detects blockquotes with blockquote color tags and uses them to color the blockquote border.
|
||||
*/
|
||||
export class BlockquoteBorderColorNodePreprocessor extends TravelerNodeProcessor {
|
||||
protected processNode(node: Node): void {
|
||||
if (!isTag(node) || isBlockquoteWithChildren(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
Optional.ofNullable(findBlockquoteColorDefinitionAndParent(node.children)).ifPresent(([color, parentParagraph]) => {
|
||||
removeColorDefinitionsFromParagraph(parentParagraph)
|
||||
if (!cssColor.test(color)) {
|
||||
return
|
||||
}
|
||||
setLeftBorderColor(node, color)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const cssColor =
|
||||
/^(#(?:[0-9a-f]{2}){2,4}|#[0-9a-f]{3}|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)$/i
|
||||
|
||||
/**
|
||||
* Checks if the given {@link Element} is a blockquote with children.
|
||||
*
|
||||
* @param element The {@link Element} to check
|
||||
* @return {@link true} if the element is a blockquote with children.
|
||||
*/
|
||||
const isBlockquoteWithChildren = (element: Element): boolean => {
|
||||
return element.name !== 'blockquote' || !element.children || element.children.length < 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a blockquote color definition tag.
|
||||
*
|
||||
* @param elements The {@link Element} elements that should be searched through.
|
||||
* @return The parent paragraph and the extracted color if a color definition was found. {@link undefined} otherwise.
|
||||
*/
|
||||
const findBlockquoteColorDefinitionAndParent = (
|
||||
elements: Node[]
|
||||
): [color: string, parentParagraph: Element] | undefined => {
|
||||
for (const paragraph of elements) {
|
||||
if (!isTag(paragraph) || paragraph.name !== 'p' || paragraph.children.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const colorDefinition of paragraph.children) {
|
||||
if (!isTag(colorDefinition)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = extractBlockquoteColorDefinition(colorDefinition)
|
||||
if (content !== undefined) {
|
||||
return [content, paragraph]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given node is a blockquote color definition.
|
||||
*
|
||||
* @param element The {@link Element} to check
|
||||
* @return true if the checked node is a blockquote color definition
|
||||
*/
|
||||
const extractBlockquoteColorDefinition = (element: Element): string | undefined => {
|
||||
if (
|
||||
element.name === BlockquoteExtraTagMarkdownExtension.tagName &&
|
||||
element.attribs['data-label'] === 'color' &&
|
||||
element.children.length === 1 &&
|
||||
isText(element.children[0])
|
||||
) {
|
||||
return element.children[0].data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all color definition elements from the given paragraph {@link Element}.
|
||||
*
|
||||
* @param paragraph The {@link Element} whose children should be filtered
|
||||
*/
|
||||
const removeColorDefinitionsFromParagraph = (paragraph: Element): void => {
|
||||
const childElements = paragraph.children
|
||||
paragraph.children = childElements.filter((elem) => !isTag(elem) || !extractBlockquoteColorDefinition(elem))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the left border color of the given {@link Element}.
|
||||
*
|
||||
* @param element The {@link Element} to change
|
||||
* @param color The border color
|
||||
*/
|
||||
const setLeftBorderColor = (element: Element, color: string): void => {
|
||||
element.attribs = Object.assign(element.attribs || {}, { style: `border-left-color: ${color};` })
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { UiIcon } from '../../../components/common/icons/ui-icon'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { cssColor } from './blockquote-border-color-node-preprocessor'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Element } from 'domhandler'
|
||||
import { isText } from 'domhandler'
|
||||
import type { Text } from 'domhandler/lib/node'
|
||||
import { Tag as IconTag } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Replaces <blockquote-tag> elements with "color" as label and a valid color as content
|
||||
* with an colored label icon.
|
||||
*
|
||||
* @see BlockquoteTagMarkdownItPlugin
|
||||
*/
|
||||
export class BlockquoteColorExtraTagReplacer extends ComponentReplacer {
|
||||
replace(element: Element): NodeReplacement {
|
||||
return Optional.of(element)
|
||||
.filter(
|
||||
(element) =>
|
||||
element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs?.['data-label'] === 'color'
|
||||
)
|
||||
.map((element) => element.children[0])
|
||||
.filter(isText)
|
||||
.map((child) => (child as Text).data)
|
||||
.filter((content) => cssColor.test(content))
|
||||
.map((color) => (
|
||||
<span className={'blockquote-extra'} key={1} style={{ color: color }}>
|
||||
<UiIcon icon={IconTag} key='icon' className={'mx-1'} />
|
||||
</span>
|
||||
))
|
||||
.orElse(DO_NOT_REPLACE)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
describe('blockquote extra tag', () => {
|
||||
it.each([
|
||||
'[color=white]',
|
||||
'[color=#dfe]',
|
||||
'[color=notarealcolor]',
|
||||
'[color=#abcdef]',
|
||||
'[color=]',
|
||||
'[name=giowehg]',
|
||||
'[name=]',
|
||||
'[time=tomorrow]',
|
||||
'[time=]',
|
||||
'[key]',
|
||||
'[key=]',
|
||||
'[=value]',
|
||||
'> [color=#f00] text'
|
||||
])(`renders the tag "%s" correctly`, async (content) => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new BlockquoteExtraTagMarkdownExtension()]}
|
||||
content={'blockquote\n\n' + content}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('blockquote')
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { BlockquoteBorderColorNodePreprocessor } from './blockquote-border-color-node-preprocessor'
|
||||
import { BlockquoteColorExtraTagReplacer } from './blockquote-color-extra-tag-replacer'
|
||||
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
|
||||
import { BlockquoteExtraTagReplacer } from './blockquote-extra-tag-replacer'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds support for generic blockquote extra tags and blockquote color extra tags.
|
||||
*/
|
||||
export class BlockquoteExtraTagMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-blockquote-tag'
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
new BlockquoteExtraTagMarkdownItPlugin('color', 'tag').registerRule(markdownIt)
|
||||
new BlockquoteExtraTagMarkdownItPlugin('name', 'person').registerRule(markdownIt)
|
||||
new BlockquoteExtraTagMarkdownItPlugin('time', 'clock').registerRule(markdownIt)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new BlockquoteColorExtraTagReplacer(), new BlockquoteExtraTagReplacer()]
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new BlockquoteBorderColorNodePreprocessor()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [BlockquoteExtraTagMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Quote extra syntax parser', () => {
|
||||
let markdownIt: MarkdownIt
|
||||
|
||||
beforeEach(() => {
|
||||
markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
new BlockquoteExtraTagMarkdownItPlugin('abc', 'markdown').registerRule(markdownIt)
|
||||
})
|
||||
|
||||
it('should parse a valid tag', () => {
|
||||
expect(markdownIt.renderInline('[abc=markdown]')).toEqual(
|
||||
'<app-blockquote-tag data-label=\'abc\' data-icon="markdown">markdown</app-blockquote-tag>'
|
||||
)
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no opener bracket", () => {
|
||||
expect(markdownIt.renderInline('abc=def]')).toEqual('abc=def]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no closing bracket", () => {
|
||||
expect(markdownIt.renderInline('[abc=def')).toEqual('[abc=def')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no separation character", () => {
|
||||
expect(markdownIt.renderInline('[abcdef]')).toEqual('[abcdef]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty label", () => {
|
||||
expect(markdownIt.renderInline('[=def]')).toEqual('[=def]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty value", () => {
|
||||
expect(markdownIt.renderInline('[abc=]')).toEqual('[abc=]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty body", () => {
|
||||
expect(markdownIt.renderInline('[]')).toEqual('[]')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { BootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type { RuleInline } from 'markdown-it/lib/parser_inline'
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
|
||||
export interface QuoteExtraTagValues {
|
||||
labelStartIndex: number
|
||||
labelEndIndex: number
|
||||
valueStartIndex: number
|
||||
valueEndIndex: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the blockquote extra tag syntax `[label=value]` and creates <blockquote-tag> elements.
|
||||
*/
|
||||
export class BlockquoteExtraTagMarkdownItPlugin {
|
||||
private static readonly BlockquoteExtraTagRuleName = 'blockquote_extra_tag'
|
||||
|
||||
constructor(private tagName: string, private icon: BootstrapIconName) {}
|
||||
|
||||
/**
|
||||
* Registers an inline rule that detects blockquote extra tags and replaces them with blockquote tokens.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown-it} in which the inline rule should be registered.
|
||||
*/
|
||||
public registerRule(markdownIt: MarkdownIt): void {
|
||||
markdownIt.inline.ruler.before('link', `blockquote_extra_tag_${this.tagName}`, this.createInlineRuler())
|
||||
BlockquoteExtraTagMarkdownItPlugin.registerRenderer(markdownIt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link RuleInline markdown-it inline rule} that detects the configured blockquote extra tag.
|
||||
*
|
||||
* @return The created inline rule
|
||||
*/
|
||||
private createInlineRuler(): RuleInline {
|
||||
return (state) =>
|
||||
this.parseBlockquoteExtraTag(state.src, state.pos, state.posMax)
|
||||
.map((parseResults) => {
|
||||
this.createNewBlockquoteExtraTagToken(state, parseResults.label, parseResults.value)
|
||||
state.pos = parseResults.valueEndIndex + 1
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the markdown-it renderer that translated `blockquote_tag` tokens into HTML
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown-it} in which the render should be registered.
|
||||
*/
|
||||
private static registerRenderer(markdownIt: MarkdownIt): void {
|
||||
if (markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName]) {
|
||||
return
|
||||
}
|
||||
markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName] = (
|
||||
tokens,
|
||||
idx,
|
||||
options: MarkdownIt.Options,
|
||||
env: unknown
|
||||
) => {
|
||||
const token = tokens[idx]
|
||||
const innerTokens = token.children
|
||||
const label = token.attrGet('label') ?? ''
|
||||
const icon = token.attrGet('icon')
|
||||
const iconAttribute = icon === null ? '' : ` data-icon="${icon}"`
|
||||
const innerHtml = innerTokens === null ? '' : markdownIt.renderer.renderInline(innerTokens, options, env)
|
||||
return `<${BlockquoteExtraTagMarkdownExtension.tagName} data-label='${label}'${iconAttribute}>${innerHtml}</${BlockquoteExtraTagMarkdownExtension.tagName}>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new blockquote extra {@link Token token} using the given values.
|
||||
*
|
||||
* @param state The state in which the token should be inserted
|
||||
* @param label The label for the extra token
|
||||
* @param value The value for the extra token that will be inline parsed
|
||||
* @return The generated token
|
||||
*/
|
||||
private createNewBlockquoteExtraTagToken(state: StateInline, label: string, value: string): Token {
|
||||
const token = state.push(BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName, '', 0)
|
||||
token.attrSet('label', label)
|
||||
token.attrSet('icon', this.icon)
|
||||
token.children = BlockquoteExtraTagMarkdownItPlugin.parseInlineContent(state, value)
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given content using the markdown-it instance of the given state.
|
||||
*
|
||||
* @param state The state whose inline parser should be used to parse the given content
|
||||
* @param content The content to parse
|
||||
* @return The generated tokens
|
||||
*/
|
||||
private static parseInlineContent(state: StateInline, content: string): Token[] {
|
||||
const childTokens: Token[] = []
|
||||
state.md.inline.parse(content, state.md, state.env, childTokens)
|
||||
return childTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a blockquote tag. The syntax is [label=value].
|
||||
*
|
||||
* @param line The line in which the tag should be looked for.
|
||||
* @param startIndex The start index for the search.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
*/
|
||||
private parseBlockquoteExtraTag(
|
||||
line: string,
|
||||
startIndex: number,
|
||||
dontSearchAfterIndex: number
|
||||
): Optional<QuoteExtraTagValues> {
|
||||
if (line[startIndex] !== '[') {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const labelStartIndex = startIndex + 1
|
||||
const labelEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseLabel(line, labelStartIndex, dontSearchAfterIndex)
|
||||
if (!labelEndIndex || labelStartIndex === labelEndIndex) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const label = line.slice(labelStartIndex, labelEndIndex)
|
||||
if (label !== this.tagName) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const valueStartIndex = labelEndIndex + 1
|
||||
const valueEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseValue(line, valueStartIndex, dontSearchAfterIndex)
|
||||
if (!valueEndIndex || valueStartIndex === valueEndIndex) {
|
||||
return Optional.empty()
|
||||
}
|
||||
const value = line.slice(valueStartIndex, valueEndIndex)
|
||||
|
||||
return Optional.of({
|
||||
labelStartIndex,
|
||||
labelEndIndex,
|
||||
valueStartIndex,
|
||||
valueEndIndex,
|
||||
label,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the value part of a blockquote tag. That is [notthis=THIS] part. It also detects nested [] blocks.
|
||||
*
|
||||
* @param line The line in which the tag is.
|
||||
* @param startIndex The start index of the tag.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
* @return The value part of the blockquote tag
|
||||
*/
|
||||
private static parseValue(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
|
||||
let level = 0
|
||||
for (let position = startIndex; position <= dontSearchAfterIndex; position += 1) {
|
||||
const currentCharacter = line[position]
|
||||
if (currentCharacter === ']') {
|
||||
if (level === 0) {
|
||||
return position
|
||||
}
|
||||
level -= 1
|
||||
} else if (currentCharacter === '[') {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the label part of a blockquote tag. That is [THIS=notthis] part.
|
||||
*
|
||||
* @param line The line in which the tag is.
|
||||
* @param startIndex The start index of the tag.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
* @return The label of the blockquote tag.
|
||||
*/
|
||||
private static parseLabel(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
|
||||
for (let pos = startIndex; pos <= dontSearchAfterIndex; pos += 1) {
|
||||
if (line[pos] === '=') {
|
||||
return pos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { BootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
|
||||
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
|
||||
import type { LazyBootstrapIconProps } from '../../../components/common/icons/lazy-bootstrap-icon'
|
||||
import { LazyBootstrapIcon } from '../../../components/common/icons/lazy-bootstrap-icon'
|
||||
import type {
|
||||
NodeReplacement,
|
||||
SubNodeTransform
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Element } from 'domhandler'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
/**
|
||||
* Replaces <blockquote-tag> elements with an icon and a small text.
|
||||
*
|
||||
* @see BlockquoteTagMarkdownItPlugin
|
||||
* @see ColoredBlockquoteNodePreprocessor
|
||||
*/
|
||||
export class BlockquoteExtraTagReplacer extends ComponentReplacer {
|
||||
replace(element: Element, subNodeTransform: SubNodeTransform): NodeReplacement {
|
||||
return Optional.of(element)
|
||||
.filter(
|
||||
(element) => element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs !== undefined
|
||||
)
|
||||
.map((element) => (
|
||||
<span className={'blockquote-extra'} key={1}>
|
||||
{this.buildIconElement(element)}
|
||||
{BlockquoteExtraTagReplacer.transformChildren(element, subNodeTransform)}
|
||||
</span>
|
||||
))
|
||||
.orElse(DO_NOT_REPLACE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an icon name from the node and builds a {@link LazyBootstrapIcon icon react element}.
|
||||
*
|
||||
* @param node The node that holds the "data-icon" attribute.
|
||||
* @return the {@link LazyBootstrapIcon icon react element} or {@link undefined} if no icon name was found.
|
||||
*/
|
||||
private buildIconElement(node: Element): ReactElement<LazyBootstrapIconProps> | undefined {
|
||||
return Optional.ofNullable(node.attribs['data-icon'] as BootstrapIconName)
|
||||
.filter((iconName) => isBootstrapIconName(iconName))
|
||||
.map((iconName) => <LazyBootstrapIcon key='icon' className={'mx-1'} icon={iconName} />)
|
||||
.orElse(undefined)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`bootstrap icon markdown extension doesn't render invalid icon 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
:bi-INVALIDICONNAME:
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`bootstrap icon markdown extension doesn't render missing icon 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
:bi-:
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`bootstrap icon markdown extension renders correct icon 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<span
|
||||
data-svg-mock="true"
|
||||
data-testid="lazy-bootstrap-icon-alarm"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
width="1em"
|
||||
/>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { BootstrapLazyIcons } from '../../../components/common/icons/bootstrap-icons'
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { regexCompletion } from '../../../components/editor-page/editor-pane/autocompletions/regex-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const bootstrapIconNames = Object.keys(BootstrapLazyIcons)
|
||||
|
||||
export class BootstrapIconAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new BootstrapIconMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'bootstrapIcon', readMoreUrl: new URL('https://icons.getbootstrap.com/') }]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
regexCompletion(
|
||||
/:(?:[\w-]+:?)?/,
|
||||
bootstrapIconNames.map((icon) => ({
|
||||
detail: t('editor.autocompletions.icon') ?? undefined,
|
||||
label: `:bi-${icon}:`
|
||||
}))
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
|
||||
import { LazyBootstrapIcon } from '../../../components/common/icons/lazy-bootstrap-icon'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Replaces a bootstrap icon tag with the bootstrap icon react component.
|
||||
*
|
||||
* @see BootstrapIcon
|
||||
*/
|
||||
export class BootstrapIconComponentReplacer extends ComponentReplacer {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public replace(node: Element): NodeReplacement {
|
||||
const iconName = this.extractIconName(node)
|
||||
if (!iconName || !isBootstrapIconName(iconName)) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
return React.createElement(LazyBootstrapIcon, { icon: iconName })
|
||||
}
|
||||
|
||||
private extractIconName(element: Element): string | undefined {
|
||||
return element.name === BootstrapIconMarkdownExtension.tagName && element.attribs && element.attribs.id
|
||||
? element.attribs.id
|
||||
: undefined
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
describe('bootstrap icon markdown extension', () => {
|
||||
it('renders correct icon', async () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-alarm:'} />
|
||||
)
|
||||
await screen.findByTestId('lazy-bootstrap-icon-alarm')
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("doesn't render missing icon", () => {
|
||||
const view = render(<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-:'} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("doesn't render invalid icon", () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-INVALIDICONNAME:'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { BootstrapIconComponentReplacer } from './bootstrap-icon-component-replacer'
|
||||
import { replaceBootstrapIconsMarkdownItPlugin } from './replace-bootstrap-icons'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds Bootstrap icons via the :bi-$name: syntax.
|
||||
*/
|
||||
export class BootstrapIconMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-bootstrap-icon'
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
replaceBootstrapIconsMarkdownItPlugin(markdownIt)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new BootstrapIconComponentReplacer()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [BootstrapIconMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { replaceBootstrapIconsMarkdownItPlugin } from './replace-bootstrap-icons'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Replace bootstrap icons', () => {
|
||||
let markdownIt: MarkdownIt
|
||||
|
||||
beforeEach(() => {
|
||||
markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(replaceBootstrapIconsMarkdownItPlugin)
|
||||
})
|
||||
it(`can detect a correct icon`, () => {
|
||||
expect(markdownIt.renderInline(':bi-alarm:')).toBe('<app-bootstrap-icon id="alarm"></app-bootstrap-icon>')
|
||||
})
|
||||
|
||||
it("won't detect an invalid id", () => {
|
||||
const invalidIcon = ':bi-invalid:'
|
||||
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
|
||||
})
|
||||
|
||||
it("won't detect an empty id", () => {
|
||||
const invalidIcon = ':bi-:'
|
||||
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
|
||||
})
|
||||
|
||||
it("won't detect a wrong id", () => {
|
||||
const invalidIcon = ':bi-%?(:'
|
||||
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
|
||||
const biRegex = /:bi-([\w-]+):/i
|
||||
|
||||
/**
|
||||
* Replacer for bootstrap icon via the :bi-$name: syntax.
|
||||
*/
|
||||
export const replaceBootstrapIconsMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'bootstrap-icons',
|
||||
regex: biRegex,
|
||||
replace: (match) => {
|
||||
if (isBootstrapIconName(match)) {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<${BootstrapIconMarkdownExtension.tagName} id="${match}"></${BootstrapIconMarkdownExtension.tagName}>`
|
||||
} else {
|
||||
return `:bi-${match}:`
|
||||
}
|
||||
}
|
||||
} as RegexOptions)
|
|
@ -0,0 +1,17 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CSV Table Markdown Extension renders a csv codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<code
|
||||
class="csv"
|
||||
>
|
||||
a;b;c
|
||||
d;e;f
|
||||
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,79 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CSV Table renders correctly with header 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
a
|
||||
</th>
|
||||
<th>
|
||||
b
|
||||
</th>
|
||||
<th>
|
||||
c
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
d
|
||||
</td>
|
||||
<td>
|
||||
e
|
||||
</td>
|
||||
<td>
|
||||
f
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CSV Table renders correctly without code 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<tbody />
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CSV Table renders correctly without header 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
a
|
||||
</td>
|
||||
<td>
|
||||
b
|
||||
</td>
|
||||
<td>
|
||||
c
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
d
|
||||
</td>
|
||||
<td>
|
||||
e
|
||||
</td>
|
||||
<td>
|
||||
f
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 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)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 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, '\\$&')
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { CsvTable } from './csv-table'
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Detects code blocks with "csv" as language and renders them as table.
|
||||
*/
|
||||
export class CsvReplacer extends ComponentReplacer {
|
||||
public replace(codeNode: Element): NodeReplacement {
|
||||
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(codeNode, 'csv')
|
||||
if (!code) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
const extraData = codeNode.attribs['data-extra']
|
||||
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
|
||||
const extraInfos = extraRegex.exec(extraData)
|
||||
|
||||
const delimiter = extraInfos?.[2] ?? ','
|
||||
const showHeader = extraInfos?.[3] !== undefined
|
||||
|
||||
return <CsvTable code={code} delimiter={delimiter} showHeader={showHeader} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import {
|
||||
basicCompletion,
|
||||
codeFenceRegex
|
||||
} from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
|
||||
/**
|
||||
* Adds support for csv tables to the markdown rendering.
|
||||
*/
|
||||
export class CsvTableAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new CsvTableMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'csv', entries: [{ i18nKey: 'table' }, { i18nKey: 'header' }] }]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [basicCompletion(codeFenceRegex, '```csv delimiter=;\n\n```')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||
import * as CsvTableModule from '../csv/csv-table'
|
||||
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('../csv/csv-table')
|
||||
|
||||
describe('CSV Table Markdown Extension', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(CsvTableModule, 'CsvTable').mockImplementation((({ code }) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for csv frame
|
||||
<code>{code}</code>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<CodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders a csv codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new CsvTableMarkdownExtension()]} content={'```csv\na;b;c\nd;e;f\n```'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { CsvReplacer } from './csv-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for csv tables to the markdown rendering using code fences with "csv" as language.
|
||||
*/
|
||||
export class CsvTableMarkdownExtension extends MarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new CsvReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CsvTable } from './csv-table'
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
describe('CSV Table', () => {
|
||||
it('renders correctly with header', () => {
|
||||
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={true} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders correctly without header', () => {
|
||||
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={false} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders correctly without code', () => {
|
||||
const view = render(<CsvTable code={''} delimiter={';'} showHeader={false} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { parseCsv } from './csv-parser'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
export interface CsvTableProps {
|
||||
code: string
|
||||
delimiter: string
|
||||
showHeader: boolean
|
||||
tableRowClassName?: string
|
||||
tableColumnClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a csv table.
|
||||
*
|
||||
* @param code The csv code
|
||||
* @param delimiter The delimiter to be used
|
||||
* @param showHeader If the header should be shown.
|
||||
* @param tableRowClassName Additional class name for the rows.
|
||||
* @param tableColumnClassNameA Additional class name for the columns.
|
||||
*/
|
||||
export const CsvTable: React.FC<CsvTableProps> = ({
|
||||
code,
|
||||
delimiter,
|
||||
showHeader,
|
||||
tableRowClassName,
|
||||
tableColumnClassName
|
||||
}) => {
|
||||
const { rowsWithColumns, headerRow } = useMemo(() => {
|
||||
const rowsWithColumns = parseCsv(code.trim(), delimiter)
|
||||
const headerRow = showHeader ? rowsWithColumns.splice(0, 1)[0] : []
|
||||
return { rowsWithColumns, headerRow }
|
||||
}, [code, delimiter, showHeader])
|
||||
|
||||
const renderTableHeader = useMemo(() => {
|
||||
return headerRow.length === 0 ? undefined : (
|
||||
<thead>
|
||||
<tr>
|
||||
{headerRow.map((column, columnNumber) => (
|
||||
<th key={`header-${columnNumber}`}>{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}, [headerRow])
|
||||
|
||||
const renderTableBody = useMemo(
|
||||
() => (
|
||||
<tbody>
|
||||
{rowsWithColumns.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>
|
||||
),
|
||||
[rowsWithColumns, tableColumnClassName, tableRowClassName]
|
||||
)
|
||||
|
||||
return (
|
||||
<table className={'csv-html-table table-striped'} {...cypressId('csv-html-table')}>
|
||||
{renderTableHeader}
|
||||
{renderTableBody}
|
||||
</table>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Emoji Markdown Extension renders a skin tone code 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
🏽
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Emoji Markdown Extension renders an emoji code 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
😄
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { regexCompletion } from '../../../components/editor-page/editor-pane/autocompletions/regex-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { EmojiMarkdownRendererExtension } from './emoji-markdown-renderer-extension'
|
||||
import { emojiShortcodes } from './mapping'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export class EmojiAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new EmojiMarkdownRendererExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'emoji',
|
||||
readMoreUrl: new URL('https://twemoji.twitter.com/')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
regexCompletion(
|
||||
/:(?:[\w-+]+:?)?/,
|
||||
emojiShortcodes.map((shortcode) => ({
|
||||
detail: t('editor.autocompletions.emoji') ?? undefined,
|
||||
label: `:${shortcode}:`
|
||||
}))
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||
import { EmojiMarkdownRendererExtension } from './emoji-markdown-renderer-extension'
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
describe('Emoji Markdown Extension', () => {
|
||||
beforeAll(async () => {
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders an emoji code', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new EmojiMarkdownRendererExtension()]} content={':smile:'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders a skin tone code', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new EmojiMarkdownRendererExtension()]} content={':skin-tone-3:'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { combinedEmojiData } from './mapping'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import emoji from 'markdown-it-emoji/bare'
|
||||
|
||||
/**
|
||||
* Adds support for utf-8 emojis.
|
||||
*/
|
||||
export class EmojiMarkdownRendererExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownIt.use(emoji, {
|
||||
defs: combinedEmojiData
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
|
||||
|
||||
interface EmojiEntry {
|
||||
shortcodes: string[]
|
||||
emoji: string
|
||||
}
|
||||
|
||||
type ShortCodeMap = { [key: string]: string }
|
||||
|
||||
const shortCodeMap = (emojiData as unknown as EmojiEntry[]).reduce((reduceObject, emoji) => {
|
||||
emoji.shortcodes.forEach((shortcode) => {
|
||||
reduceObject[shortcode] = emoji.emoji
|
||||
})
|
||||
return reduceObject
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
const emojiSkinToneModifierMap = [1, 2, 3, 4, 5].reduce((reduceObject, modifierValue) => {
|
||||
const lightSkinCode = 127995
|
||||
const codepoint = lightSkinCode + (modifierValue - 1)
|
||||
const shortcode = `skin-tone-${modifierValue}`
|
||||
reduceObject[shortcode] = `&#${codepoint};`
|
||||
return reduceObject
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
export const combinedEmojiData = {
|
||||
...shortCodeMap,
|
||||
...emojiSkinToneModifierMap
|
||||
}
|
||||
|
||||
export const emojiShortcodes = Object.keys(combinedEmojiData)
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AlertAppExtension } from './alert/alert-app-extension'
|
||||
import { BasicMarkdownSyntaxAppExtension } from './basic-markdown-syntax/basic-markdown-syntax-app-extension'
|
||||
import { BlockquoteAppExtension } from './blockquote/blockquote-app-extension'
|
||||
import { BootstrapIconAppExtension } from './bootstrap-icons/bootstrap-icon-app-extension'
|
||||
import { CsvTableAppExtension } from './csv/csv-table-app-extension'
|
||||
import { EmojiAppExtension } from './emoji/emoji-app-extension'
|
||||
import { ExtractFirstHeadlineAppExtension } from './extract-first-headline/extract-first-headline-app-extension'
|
||||
import { ForkAwesomeHtmlTagAppExtension } from './fork-awesome-html-tag/fork-awesome-html-tag-app-extension'
|
||||
import { HeadlineAnchorsAppExtension } from './headline-anchors/headline-anchors-app-extension'
|
||||
import { HighlightedCodeFenceAppExtension } from './highlighted-code-fence/highlighted-code-fence-app-extension'
|
||||
import { IframeCapsuleAppExtension } from './iframe-capsule/iframe-capsule-app-extension'
|
||||
import { ImagePlaceholderAppExtension } from './image-placeholder/image-placeholder-app-extension'
|
||||
import { LegacyShortcodesAppExtension } from './legacy-short-codes/legacy-shortcodes-app-extension'
|
||||
import { SpoilerAppExtension } from './spoiler/spoiler-app-extension'
|
||||
import { TableOfContentsAppExtension } from './table-of-contents/table-of-contents-app-extension'
|
||||
import { TaskListCheckboxAppExtension } from './task-list/task-list-checkbox-app-extension'
|
||||
|
||||
/**
|
||||
* Contains all app extensions that are adding essential features or additional features developed by the HedgeDoc maintainers.
|
||||
*/
|
||||
export const essentialAppExtensions = [
|
||||
new AlertAppExtension(),
|
||||
new BasicMarkdownSyntaxAppExtension(),
|
||||
new BlockquoteAppExtension(),
|
||||
new BootstrapIconAppExtension(),
|
||||
new CsvTableAppExtension(),
|
||||
new EmojiAppExtension(),
|
||||
new ExtractFirstHeadlineAppExtension(),
|
||||
new ForkAwesomeHtmlTagAppExtension(),
|
||||
new HighlightedCodeFenceAppExtension(),
|
||||
new IframeCapsuleAppExtension(),
|
||||
new ImagePlaceholderAppExtension(),
|
||||
new LegacyShortcodesAppExtension(),
|
||||
new SpoilerAppExtension(),
|
||||
new TableOfContentsAppExtension(),
|
||||
new TaskListCheckboxAppExtension(),
|
||||
new HeadlineAnchorsAppExtension()
|
||||
]
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { RendererType } from '../../../components/render-page/window-post-message-communicator/rendering-message'
|
||||
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { ExtractFirstHeadlineEditorExtension } from './extract-first-headline-editor-extension'
|
||||
import { ExtractFirstHeadlineMarkdownExtension } from './extract-first-headline-markdown-extension'
|
||||
|
||||
/**
|
||||
* Provides first headline extraction
|
||||
*/
|
||||
export class ExtractFirstHeadlineAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
|
||||
if (options.rendererType === RendererType.SIMPLE) {
|
||||
return []
|
||||
}
|
||||
return [new ExtractFirstHeadlineMarkdownExtension(options.eventEmitter)]
|
||||
}
|
||||
|
||||
buildEditorExtensionComponent(): React.FC {
|
||||
return ExtractFirstHeadlineEditorExtension
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { updateNoteTitleByFirstHeading } from '../../../redux/note-details/methods'
|
||||
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
|
||||
import type React from 'react'
|
||||
|
||||
/**
|
||||
* Receives the {@link ExtractFirstHeadlineNodeProcessor.EVENT_NAME first heading extraction event}
|
||||
* and saves the title in the global application state.
|
||||
*/
|
||||
export const ExtractFirstHeadlineEditorExtension: React.FC = () => {
|
||||
useExtensionEventEmitterHandler(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, updateNoteTitleByFirstHeading)
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
|
||||
import type { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
|
||||
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
|
||||
|
||||
/**
|
||||
* Adds first heading extraction to the renderer
|
||||
*/
|
||||
export class ExtractFirstHeadlineMarkdownExtension extends EventMarkdownRendererExtension {
|
||||
buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new ExtractFirstHeadlineNodeProcessor(this.eventEmitter)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
|
||||
import { extractFirstHeading } from '@hedgedoc/commons'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Document } from 'domhandler'
|
||||
import type { EventEmitter2 } from 'eventemitter2'
|
||||
|
||||
/**
|
||||
* Searches for the first headline tag and extracts its plain text content.
|
||||
*/
|
||||
export class ExtractFirstHeadlineNodeProcessor extends NodeProcessor {
|
||||
public static readonly EVENT_NAME = 'HeadlineExtracted'
|
||||
|
||||
constructor(private eventEmitter: EventEmitter2) {
|
||||
super()
|
||||
}
|
||||
|
||||
process(nodes: Document): Document {
|
||||
Optional.ofNullable(extractFirstHeading(nodes))
|
||||
.filter((text) => text !== '')
|
||||
.ifPresent((text) => this.eventEmitter.emit(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, text))
|
||||
return nodes
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
|
||||
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const forkAwesomeRegex = /<i class=["'][\w\s]*fa-[\w-]+[\w\s-]*["'][^>]*\/?>(?:<\/i>)?/
|
||||
|
||||
/**
|
||||
* Adds a linter for the icon html tag.
|
||||
*/
|
||||
export class ForkAwesomeHtmlTagAppExtension extends AppExtension {
|
||||
buildCodeMirrorLinter(): Linter[] {
|
||||
return [
|
||||
new SingleLineRegexLinter(
|
||||
forkAwesomeRegex,
|
||||
t('editor.linter.fork-awesome', { link: 'https://docs.hedgedoc.org' }) // ToDo: Add correct link here
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { RendererType } from '../../../components/render-page/window-post-message-communicator/rendering-message'
|
||||
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { HeadlineAnchorsMarkdownRendererExtension } from './headline-anchors-markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Provides anchor links for headlines
|
||||
*/
|
||||
export class HeadlineAnchorsAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
|
||||
return options.rendererType === RendererType.DOCUMENT ? [new HeadlineAnchorsMarkdownRendererExtension()] : []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import anchor from 'markdown-it-anchor'
|
||||
|
||||
/**
|
||||
* Adds headline anchors to the markdown rendering.
|
||||
*/
|
||||
export class HeadlineAnchorsMarkdownRendererExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
anchor(markdownIt, {
|
||||
permalink: anchor.permalink.ariaHidden({
|
||||
symbol: '🔗',
|
||||
class: 'heading-anchor text-dark',
|
||||
renderHref: (slug: string): string => `#${slug}`,
|
||||
placement: 'before'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Highlighted code markdown extension renders with just the language and line wrapping doesn't show a gutter 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with just the language doesn't show a gutter 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language and show gutter and line wrapping shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language and show gutter shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number and continues in another codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
let y = 1
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let y = 2
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
102
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number and continues in another codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
let y = 1
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let y = 2
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
102
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { codeFenceRegex } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
|
||||
/**
|
||||
* Adds code highlighting to the markdown rendering.
|
||||
*/
|
||||
export class HighlightedCodeFenceAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new HighlightedCodeMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'codeHighlighting',
|
||||
entries: [{ i18nKey: 'language' }, { i18nKey: 'lineNumbers' }, { i18nKey: 'lineWrapping' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const match = context.matchBefore(codeFenceRegex)
|
||||
if (!match || (match.from === match.to && !context.explicit)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
from: match.from,
|
||||
options: languages.map((lang) => ({
|
||||
detail: lang.name,
|
||||
label: '```' + lang.alias[0] + '\n\n```'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as HighlightedCodeModule from '../../../components/common/highlighted-code/highlighted-code'
|
||||
import type { HighlightedCodeProps } from '../../../components/common/highlighted-code/highlighted-code'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { mockI18n } from '../../../test-utils/mock-i18n'
|
||||
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('../../../components/common/highlighted-code/highlighted-code')
|
||||
|
||||
describe('Highlighted code markdown extension', () => {
|
||||
describe('renders', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(HighlightedCodeModule, 'HighlightedCode').mockImplementation((({
|
||||
code,
|
||||
language,
|
||||
startLineNumber,
|
||||
wrapLines
|
||||
}) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>{code}</code>
|
||||
<span>language: {language}</span>
|
||||
<span>start line number: {startLineNumber}</span>
|
||||
<span>wrap line: {wrapLines ? 'true' : 'false'}</span>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<HighlightedCodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
describe('with just the language', () => {
|
||||
it("doesn't show a gutter", () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it("doesn't show a gutter", () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the language and show gutter', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript= \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the language, show gutter with a start number', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100 \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('shows the correct line number and continues in another codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('shows the correct line number and continues in another codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { HighlightedCodeReplacer } from './highlighted-code-replacer'
|
||||
|
||||
/**
|
||||
* Adds code highlighting to the markdown rendering.
|
||||
* Every code fence that is not replaced by another replacer is highlighted using highlight-js.
|
||||
*/
|
||||
export class HighlightedCodeMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new HighlightedCodeReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import HighlightedCode from '../../../components/common/highlighted-code/highlighted-code'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Detects code blocks and renders them as highlighted code blocks
|
||||
*/
|
||||
export class HighlightedCodeReplacer extends ComponentReplacer {
|
||||
private lastLineNumber = 0
|
||||
|
||||
private static extractCode(codeNode: Element): string | undefined {
|
||||
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
|
||||
? ComponentReplacer.extractTextChildContent(codeNode)
|
||||
: undefined
|
||||
}
|
||||
|
||||
public replace(codeNode: Element): NodeReplacement {
|
||||
const code = HighlightedCodeReplacer.extractCode(codeNode)
|
||||
if (!code) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
const language = codeNode.attribs['data-highlight-language']
|
||||
const extraData = codeNode.attribs['data-extra']
|
||||
const extraInfos = /(=(\d+|\+)?)?(!?)/.exec(extraData)
|
||||
const showLineNumbers = extraInfos ? extraInfos[1]?.startsWith('=') : false
|
||||
const startLineNumberAttribute = extraInfos?.[2] ?? ''
|
||||
const 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.lastLineNumber = 0
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { IframeCapsuleMarkdownExtension } from './iframe-capsule-markdown-extension'
|
||||
|
||||
export class IframeCapsuleAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new IframeCapsuleMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'iframeCapsule',
|
||||
categoryI18nKey: 'embedding'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { IframeCapsuleReplacer } from './iframe-capsule-replacer'
|
||||
|
||||
/**
|
||||
* Adds a replacer that capsules iframes in a click shield.
|
||||
*/
|
||||
export class IframeCapsuleMarkdownExtension extends MarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new IframeCapsuleReplacer()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return ['iframe']
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ClickShield } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
|
||||
import type {
|
||||
NativeRenderer,
|
||||
NodeReplacement,
|
||||
SubNodeTransform
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { Globe as IconGlobe } from 'react-bootstrap-icons'
|
||||
|
||||
/**
|
||||
* Capsules <iframe> elements with a click shield.
|
||||
*
|
||||
* @see ClickShield
|
||||
*/
|
||||
export class IframeCapsuleReplacer extends ComponentReplacer {
|
||||
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||
return node.name !== 'iframe' ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<ClickShield
|
||||
hoverIcon={IconGlobe}
|
||||
targetDescription={node.attribs.src}
|
||||
data-cypress-id={'iframe-capsule-click-shield'}>
|
||||
{nativeRenderer()}
|
||||
</ClickShield>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
/**
|
||||
* A {@link MarkdownIt.PluginSimple markdown it plugin} that adds the line number of the markdown code to every placeholder image.
|
||||
*
|
||||
* @param markdownIt The markdown it instance to which the plugin should be added
|
||||
*/
|
||||
export const addLineToPlaceholderImageTags: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
|
||||
markdownIt.core.ruler.push('image-placeholder', (state) => {
|
||||
state.tokens.forEach((token) => {
|
||||
if (token.type !== 'inline') {
|
||||
return
|
||||
}
|
||||
token.children?.forEach((childToken) => {
|
||||
if (
|
||||
childToken.type === 'image' &&
|
||||
childToken.attrGet('src') === ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL
|
||||
) {
|
||||
const line = token.map?.[0]
|
||||
if (line !== undefined) {
|
||||
childToken.attrSet('data-line', String(line))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useRendererToEditorCommunicator } from '../../../../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||
import { CommunicationMessageType } from '../../../../components/render-page/window-post-message-communicator/rendering-message'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { FileContentFormat, readFile } from '../../../../utils/read-file'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const log = new Logger('useOnImageUpload')
|
||||
|
||||
/**
|
||||
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
|
||||
*
|
||||
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||
* @param placeholderIndexInLine The index of the placeholder in the markdown content line
|
||||
*/
|
||||
export const useOnImageUpload = (
|
||||
lineIndex: number | undefined,
|
||||
placeholderIndexInLine: number | undefined
|
||||
): ((file: File) => void) => {
|
||||
const communicator = useRendererToEditorCommunicator()
|
||||
|
||||
return useCallback(
|
||||
(file: File) => {
|
||||
readFile(file, FileContentFormat.DATA_URL)
|
||||
.then((dataUri) => {
|
||||
communicator.sendMessageToOtherSide({
|
||||
type: CommunicationMessageType.IMAGE_UPLOAD,
|
||||
dataUri,
|
||||
fileName: file.name,
|
||||
lineIndex,
|
||||
placeholderIndexInLine
|
||||
})
|
||||
})
|
||||
.catch((error: ProgressEvent) => log.error('Error while uploading image', error))
|
||||
},
|
||||
[communicator, placeholderIndexInLine, lineIndex]
|
||||
)
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { calculatePlaceholderContainerSize } from '../utils/build-placeholder-size-css'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Creates the style attribute for a placeholder container with width and height.
|
||||
*
|
||||
* @param width The wanted width
|
||||
* @param height The wanted height
|
||||
* @return The created style attributes
|
||||
*/
|
||||
export const usePlaceholderSizeStyle = (width?: string | number, height?: string | number): CSSProperties => {
|
||||
return useMemo(() => {
|
||||
const [convertedWidth, convertedHeight] = calculatePlaceholderContainerSize(width, height)
|
||||
|
||||
return {
|
||||
width: `${convertedWidth}px`,
|
||||
height: `${convertedHeight}px`
|
||||
}
|
||||
}, [height, width])
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export class ImagePlaceholderAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new ImagePlaceholderMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'imagePlaceholder'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
basicCompletion(/(^|\s)!\[?/, '', t('editor.autocompletions.image') ?? undefined),
|
||||
basicCompletion(
|
||||
/(^|\s)!\[?/,
|
||||
'',
|
||||
t('editor.autocompletions.imageWithDimensions') ?? undefined
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { addLineToPlaceholderImageTags } from './add-line-to-placeholder-image-tags'
|
||||
import { ImagePlaceholderReplacer } from './image-placeholder-replacer'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
/**
|
||||
* Adds support for {@link ImagePlaceholder}.
|
||||
*/
|
||||
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly PLACEHOLDER_URL = 'https://'
|
||||
|
||||
configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
addLineToPlaceholderImageTags(markdownIt)
|
||||
}
|
||||
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new ImagePlaceholderReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { ImagePlaceholder } from './image-placeholder'
|
||||
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||
import type { Element } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Replaces every image tag that has the {@link ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL placeholder url} with the {@link ImagePlaceholder image placeholder element}.
|
||||
*/
|
||||
export class ImagePlaceholderReplacer extends ComponentReplacer {
|
||||
private countPerSourceLine = new Map<number, number>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.countPerSourceLine = new Map<number, number>()
|
||||
}
|
||||
|
||||
replace(node: Element): NodeReplacement {
|
||||
if (node.name !== 'img' || node.attribs?.src !== ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
const lineIndex = Number(node.attribs['data-line'])
|
||||
const indexInLine = this.countPerSourceLine.get(lineIndex) ?? 0
|
||||
this.countPerSourceLine.set(lineIndex, indexInLine + 1)
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
alt={node.attribs.alt}
|
||||
title={node.attribs.title}
|
||||
width={node.attribs.width}
|
||||
height={node.attribs.height}
|
||||
lineIndex={isNaN(lineIndex) ? undefined : lineIndex}
|
||||
placeholderIndexInLine={indexInLine}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.image-drop {
|
||||
border: 3px dashed var(--bs-dark);
|
||||
|
||||
border-radius: 3px;
|
||||
transition: background-color 50ms, color 50ms;
|
||||
|
||||
.altText {
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { UiIcon } from '../../../components/common/icons/ui-icon'
|
||||
import { acceptedMimeTypes } from '../../../components/common/upload-image-mimetypes'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { useOnImageUpload } from './hooks/use-on-image-upload'
|
||||
import { usePlaceholderSizeStyle } from './hooks/use-placeholder-size-style'
|
||||
import styles from './image-placeholder.module.scss'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Upload as IconUpload } from 'react-bootstrap-icons'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
export interface PlaceholderImageFrameProps {
|
||||
alt?: string
|
||||
title?: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
lineIndex?: number
|
||||
placeholderIndexInLine?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a placeholder for an actual image with the possibility to upload images via button or drag'n'drop.
|
||||
*
|
||||
* @param alt The alt text of the image. Will be shown in the placeholder
|
||||
* @param title The title text of the image. Will be shown in the placeholder
|
||||
* @param width The width of the placeholder
|
||||
* @param height The height of the placeholder
|
||||
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||
* @param placeholderIndexInLine The index of the placeholder in the markdown line
|
||||
*/
|
||||
export const ImagePlaceholder: React.FC<PlaceholderImageFrameProps> = ({
|
||||
alt,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
lineIndex,
|
||||
placeholderIndexInLine
|
||||
}) => {
|
||||
useTranslation()
|
||||
const fileInputReference = useRef<HTMLInputElement>(null)
|
||||
const onImageUpload = useOnImageUpload(lineIndex, placeholderIndexInLine)
|
||||
|
||||
const [showDragStatus, setShowDragStatus] = useState(false)
|
||||
|
||||
const onDropHandler = useCallback(
|
||||
(event: React.DragEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault()
|
||||
if (event?.dataTransfer?.files?.length > 0) {
|
||||
onImageUpload(event.dataTransfer.files[0])
|
||||
}
|
||||
},
|
||||
[onImageUpload]
|
||||
)
|
||||
|
||||
const onDragOverHandler = useCallback((event: React.DragEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault()
|
||||
setShowDragStatus(true)
|
||||
}, [])
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
setShowDragStatus(false)
|
||||
}, [])
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = event.target.files
|
||||
if (!fileList || fileList.length < 1) {
|
||||
return
|
||||
}
|
||||
onImageUpload(fileList[0])
|
||||
},
|
||||
[onImageUpload]
|
||||
)
|
||||
|
||||
const uploadButtonClicked = useCallback(() => fileInputReference.current?.click(), [])
|
||||
const containerStyle = usePlaceholderSizeStyle(width, height)
|
||||
|
||||
const containerDragClasses = useMemo(() => (showDragStatus ? 'bg-primary text-white' : 'text-dark'), [showDragStatus])
|
||||
|
||||
return (
|
||||
<span
|
||||
{...cypressId('image-placeholder-image-drop')}
|
||||
className={`${styles['image-drop']} d-inline-flex flex-column align-items-center ${containerDragClasses} p-1`}
|
||||
style={containerStyle}
|
||||
onDrop={onDropHandler}
|
||||
onDragOver={onDragOverHandler}
|
||||
onDragLeave={onDragLeave}>
|
||||
<input
|
||||
type='file'
|
||||
className='d-none'
|
||||
accept={acceptedMimeTypes}
|
||||
onChange={onChangeHandler}
|
||||
ref={fileInputReference}
|
||||
/>
|
||||
<div className={'align-items-center flex-column justify-content-center flex-fill d-flex'}>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<span className='my-2'>
|
||||
<Trans i18nKey={'editor.embeddings.placeholderImage.placeholderText'} />
|
||||
</span>
|
||||
<span className={styles['altText']}>{alt ?? title ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size={'sm'} variant={'primary'} onClick={uploadButtonClicked}>
|
||||
<UiIcon icon={IconUpload} className='my-2' />
|
||||
<Trans i18nKey={'editor.embeddings.placeholderImage.upload'} className='my-2' />
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { calculatePlaceholderContainerSize, parseSizeNumber } from './build-placeholder-size-css'
|
||||
|
||||
describe('parseSizeNumber', () => {
|
||||
it('undefined', () => {
|
||||
expect(parseSizeNumber(undefined)).toBe(undefined)
|
||||
})
|
||||
it('zero as number', () => {
|
||||
expect(parseSizeNumber(0)).toBe(0)
|
||||
})
|
||||
it('positive number', () => {
|
||||
expect(parseSizeNumber(234)).toBe(234)
|
||||
})
|
||||
it('negative number', () => {
|
||||
expect(parseSizeNumber(-123)).toBe(-123)
|
||||
})
|
||||
it('zero as string', () => {
|
||||
expect(parseSizeNumber('0')).toBe(0)
|
||||
})
|
||||
it('negative number as string', () => {
|
||||
expect(parseSizeNumber('-123')).toBe(-123)
|
||||
})
|
||||
it('positive number as string', () => {
|
||||
expect(parseSizeNumber('345')).toBe(345)
|
||||
})
|
||||
it('positive number with px as string', () => {
|
||||
expect(parseSizeNumber('456px')).toBe(456)
|
||||
})
|
||||
it('negative number with px as string', () => {
|
||||
expect(parseSizeNumber('-456px')).toBe(-456)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePlaceholderContainerSize', () => {
|
||||
it('width undefined | height undefined', () => {
|
||||
expect(calculatePlaceholderContainerSize(undefined, undefined)).toStrictEqual([500, 200])
|
||||
})
|
||||
it('width 200 | height undefined', () => {
|
||||
expect(calculatePlaceholderContainerSize(200, undefined)).toStrictEqual([200, 80])
|
||||
})
|
||||
it('width undefined | height 100', () => {
|
||||
expect(calculatePlaceholderContainerSize(undefined, 100)).toStrictEqual([250, 100])
|
||||
})
|
||||
it('width "0" | height 0', () => {
|
||||
expect(calculatePlaceholderContainerSize('0', 0)).toStrictEqual([0, 0])
|
||||
})
|
||||
it('width 0 | height "0"', () => {
|
||||
expect(calculatePlaceholderContainerSize(0, '0')).toStrictEqual([0, 0])
|
||||
})
|
||||
it('width -345 | height 234', () => {
|
||||
expect(calculatePlaceholderContainerSize(-345, 234)).toStrictEqual([-345, 234])
|
||||
})
|
||||
it('width 345 | height -234', () => {
|
||||
expect(calculatePlaceholderContainerSize(345, -234)).toStrictEqual([345, -234])
|
||||
})
|
||||
it('width "-345" | height -234', () => {
|
||||
expect(calculatePlaceholderContainerSize('-345', -234)).toStrictEqual([-345, -234])
|
||||
})
|
||||
it('width -345 | height "-234"', () => {
|
||||
expect(calculatePlaceholderContainerSize(-345, '-234')).toStrictEqual([-345, -234])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const regex = /^(-?[0-9]+)px$/
|
||||
|
||||
/**
|
||||
* Inspects the given value and checks if it is a number or a pixel size string.
|
||||
*
|
||||
* @param value The value to check
|
||||
* @return the number representation of the string or undefined if it couldn't be parsed
|
||||
*/
|
||||
export const parseSizeNumber = (value: string | number | undefined): number | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
const regexMatches = regex.exec(value)
|
||||
if (regexMatches !== null) {
|
||||
if (regexMatches && regexMatches.length > 1) {
|
||||
return parseInt(regexMatches[1])
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
return parseInt(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the final width and height for a placeholder container.
|
||||
* Every parameter that is empty will be defaulted using a 500:200 ratio.
|
||||
*
|
||||
* @param width The wanted width
|
||||
* @param height The wanted height
|
||||
* @return the calculated size
|
||||
*/
|
||||
export const calculatePlaceholderContainerSize = (
|
||||
width: string | number | undefined,
|
||||
height: string | number | undefined
|
||||
): [width: number, height: number] => {
|
||||
const defaultWidth = 500
|
||||
const defaultHeight = 200
|
||||
const ratio = defaultWidth / defaultHeight
|
||||
|
||||
const convertedWidth = parseSizeNumber(width)
|
||||
const convertedHeight = parseSizeNumber(height)
|
||||
|
||||
if (convertedWidth === undefined && convertedHeight !== undefined) {
|
||||
return [convertedHeight * ratio, convertedHeight]
|
||||
} else if (convertedWidth !== undefined && convertedHeight === undefined) {
|
||||
return [convertedWidth, convertedWidth * (1 / ratio)]
|
||||
} else if (convertedWidth !== undefined && convertedHeight !== undefined) {
|
||||
return [convertedWidth, convertedHeight]
|
||||
} else {
|
||||
return [defaultWidth, defaultHeight]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a pdf short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
>
|
||||
https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a slideshare short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.slideshare.net/example/123456789"
|
||||
>
|
||||
https://www.slideshare.net/example/123456789
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a speakerdeck short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://speakerdeck.com/example/123456789"
|
||||
>
|
||||
https://speakerdeck.com/example/123456789
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
|
||||
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
|
||||
import { legacyPdfRegex } from './replace-legacy-pdf-short-code'
|
||||
import { legacySlideshareRegex } from './replace-legacy-slideshare-short-code'
|
||||
import { legacySpeakerdeckRegex } from './replace-legacy-speakerdeck-short-code'
|
||||
import { t } from 'i18next'
|
||||
|
||||
/**
|
||||
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) from HedgeDoc 1 to the markdown renderer.
|
||||
*/
|
||||
export class LegacyShortcodesAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new LegacyShortcodesMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCodeMirrorLinter(): Linter[] {
|
||||
return [
|
||||
new SingleLineRegexLinter(
|
||||
legacySpeakerdeckRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'SpeakerDeck' }),
|
||||
(match: string) => `https://speakerdeck.com/${match}`
|
||||
),
|
||||
new SingleLineRegexLinter(
|
||||
legacySlideshareRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'SlideShare' }),
|
||||
(match: string) => `https://www.slideshare.net/${match}`
|
||||
),
|
||||
new SingleLineRegexLinter(
|
||||
legacyPdfRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'PDF' }),
|
||||
(match: string) => match
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
describe('Legacy shortcodes markdown extension', () => {
|
||||
it('transforms a pdf short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
it('transforms a slideshare short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%slideshare example/123456789 %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
it('transforms a speakerdeck short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%speakerdeck example/123456789 %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
|
||||
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
|
||||
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) by replacing them with anchor elements.
|
||||
*/
|
||||
export class LegacyShortcodesMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
legacyPdfShortCode(markdownIt)
|
||||
legacySlideshareShortCode(markdownIt)
|
||||
legacySpeakerdeckShortCode(markdownIt)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Legacy pdf short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacyPdfShortCode)
|
||||
expect(
|
||||
markdownIt.renderInline('{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}')
|
||||
).toEqual(
|
||||
`<a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf">https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf</a>`
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
export const legacyPdfRegex = /^{%pdf\s+(\S*)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 pdf shortcodes as html links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacyPdfShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-pdf-short-code',
|
||||
regex: legacyPdfRegex,
|
||||
replace: (match) => `<a href="${match}">${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Legacy slideshare short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacySlideshareShortCode)
|
||||
expect(markdownIt.renderInline('{%slideshare example/123456789 %}')).toEqual(
|
||||
"<a href='https://www.slideshare.net/example/123456789'>https://www.slideshare.net/example/123456789</a>"
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
export const legacySlideshareRegex = /^{%slideshare\s+(\w+\/[\w-]+)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 slideshare shortcodes as HTML links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacySlideshareShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-slideshare-short-code',
|
||||
regex: legacySlideshareRegex,
|
||||
replace: (match) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Legacy speakerdeck short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacySpeakerdeckShortCode)
|
||||
expect(markdownIt.renderInline('{%speakerdeck example/123456789 %}')).toEqual(
|
||||
'<a href="https://speakerdeck.com/example/123456789">https://speakerdeck.com/example/123456789</a>'
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
export const legacySpeakerdeckRegex = /^{%speakerdeck\s+(\w+\/[\w-]+)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 speakerdeck shortcodes as HTML links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacySpeakerdeckShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-speakerdeck-short-code',
|
||||
regex: legacySpeakerdeckRegex,
|
||||
replace: (match) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { SpoilerMarkdownExtension } from './spoiler-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const spoilerRegex = /(?:^|\s):(?::|::|::\w+)?/
|
||||
|
||||
/**
|
||||
* Adds support for html spoiler tags.
|
||||
*
|
||||
* @see https://www.w3schools.com/tags/tag_details.asp
|
||||
*/
|
||||
export class SpoilerAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new SpoilerMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'spoiler' }]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [
|
||||
basicCompletion(spoilerRegex, ':::spoiler Label text\n\n:::', t('editor.autocompletions.spoiler') ?? undefined)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import markdownItContainer from 'markdown-it-container'
|
||||
import { escapeHtml } from 'markdown-it/lib/common/utils'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
|
||||
/**
|
||||
* Adds support for html spoiler tags.
|
||||
*
|
||||
* @see https://www.w3schools.com/tags/tag_details.asp
|
||||
*/
|
||||
export class SpoilerMarkdownExtension extends MarkdownRendererExtension {
|
||||
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
|
||||
|
||||
private static renderSpoilerContainer(tokens: Token[], index: number): string {
|
||||
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
|
||||
|
||||
return tokens[index].nesting === 1 && matches && matches[1]
|
||||
? `<details><summary>${escapeHtml(matches[1])}</summary>`
|
||||
: '</details>\n'
|
||||
}
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownItContainer(markdownIt, 'spoiler', {
|
||||
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
|
||||
render: SpoilerMarkdownExtension.renderSpoilerContainer.bind(this)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
|
||||
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { TableOfContentsMarkdownExtension } from './table-of-contents-markdown-extension'
|
||||
import type { CompletionSource } from '@codemirror/autocomplete'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export class TableOfContentsAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
|
||||
return [new TableOfContentsMarkdownExtension(options.eventEmitter)]
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [
|
||||
{
|
||||
i18nKey: 'toc',
|
||||
entries: [
|
||||
{
|
||||
i18nKey: 'basic'
|
||||
},
|
||||
{
|
||||
i18nKey: 'levelLimit'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
buildAutocompletion(): CompletionSource[] {
|
||||
return [basicCompletion(/\[(?:t|to|toc)?/, '[toc]', t('editor.autocompletions.toc') ?? undefined)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { tocSlugify } from '../../../components/editor-page/table-of-contents/toc-slugify'
|
||||
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
|
||||
import type { TocAst } from '@hedgedoc/markdown-it-plugins'
|
||||
import { toc } from '@hedgedoc/markdown-it-plugins'
|
||||
import equal from 'fast-deep-equal'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds table of content to the markdown rendering.
|
||||
*/
|
||||
export class TableOfContentsMarkdownExtension extends EventMarkdownRendererExtension {
|
||||
public static readonly EVENT_NAME = 'TocChange'
|
||||
private lastAst: TocAst | undefined = undefined
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
const eventEmitter = this.eventEmitter
|
||||
if (eventEmitter !== undefined) {
|
||||
toc(markdownIt, {
|
||||
listType: 'ul',
|
||||
level: [1, 2, 3],
|
||||
callback: (ast: TocAst): void => {
|
||||
if (equal(ast, this.lastAst)) {
|
||||
return
|
||||
}
|
||||
this.lastAst = ast
|
||||
eventEmitter.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
|
||||
},
|
||||
slugify: tocSlugify
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the markdown line prefix for a task list checkbox.
|
||||
*
|
||||
* @param state The check state of the checkbox.
|
||||
*/
|
||||
export const createCheckboxContent = (state: boolean) => {
|
||||
return `[${state ? 'x' : ' '}]`
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useExtensionEventEmitter } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import type { TaskCheckedChangeHandler, TaskListProps } from './task-list-checkbox'
|
||||
import { TaskListCheckbox } from './task-list-checkbox'
|
||||
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
type EventEmittingTaskListCheckboxProps = Omit<TaskListProps, 'onTaskCheckedChange' | 'disabled'>
|
||||
|
||||
export interface TaskCheckedEventPayload {
|
||||
lineInMarkdown: number
|
||||
newCheckedState: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link TaskListCheckbox} but sends state changes to the current {@link EventEmitter2 event emitter}.
|
||||
*
|
||||
* @param props Props that will be forwarded to the checkbox.
|
||||
*/
|
||||
export const EventEmittingTaskListCheckbox: React.FC<EventEmittingTaskListCheckboxProps> = (props) => {
|
||||
const emitter = useExtensionEventEmitter()
|
||||
const sendEvent: TaskCheckedChangeHandler = useCallback(
|
||||
(lineInMarkdown: number, checked: boolean) => {
|
||||
emitter?.emit(TaskListCheckboxAppExtension.EVENT_NAME, {
|
||||
lineInMarkdown,
|
||||
newCheckedState: checked
|
||||
} as TaskCheckedEventPayload)
|
||||
},
|
||||
[emitter]
|
||||
)
|
||||
|
||||
return <TaskListCheckbox onTaskCheckedChange={sendEvent} disabled={emitter === undefined} {...props} />
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
|
||||
const TASK_REGEX = /^(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]?])/
|
||||
|
||||
/**
|
||||
* Checks if the given markdown content contains a task list checkbox at the given line index.
|
||||
*
|
||||
* @param markdownContent The content that should be checked
|
||||
* @param lineIndex The index of the line that should be checked for a task list checkbox
|
||||
* @return An {@link Optional} that contains the start and end index of the found checkbox
|
||||
*/
|
||||
export const findCheckBox = (
|
||||
markdownContent: string,
|
||||
lineIndex: number
|
||||
): Optional<[startIndex: number, endIndex: number]> => {
|
||||
const lines = markdownContent.split('\n')
|
||||
const lineStartIndex = findStartIndexOfLine(lines, lineIndex)
|
||||
return Optional.ofNullable(TASK_REGEX.exec(lines[lineIndex])).map(([, beforeCheckbox, oldCheckbox]) => [
|
||||
lineStartIndex + beforeCheckbox.length,
|
||||
lineStartIndex + beforeCheckbox.length + oldCheckbox.length
|
||||
])
|
||||
}
|
||||
|
||||
const findStartIndexOfLine = (lines: string[], wantedLineIndex: number): number => {
|
||||
return lines
|
||||
.map((value) => value.length)
|
||||
.filter((value, index) => index < wantedLineIndex)
|
||||
.reduce((state, lineLength) => state + lineLength + 1, 0)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtensionComponentProps } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { createCheckboxContent } from './create-checkbox-content'
|
||||
import type { TaskCheckedEventPayload } from './event-emitting-task-list-checkbox'
|
||||
import { findCheckBox } from './find-check-box'
|
||||
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
|
||||
import type React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Receives task-checkbox-change events and modify the current editor content.
|
||||
*/
|
||||
export const SetCheckboxInCheatsheet: React.FC<CheatsheetExtensionComponentProps> = ({ setContent }) => {
|
||||
useExtensionEventEmitterHandler(
|
||||
TaskListCheckboxAppExtension.EVENT_NAME,
|
||||
useCallback(
|
||||
(event: TaskCheckedEventPayload) => {
|
||||
setContent((previousContent) =>
|
||||
findCheckBox(previousContent, event.lineInMarkdown)
|
||||
.map(
|
||||
([startIndex, endIndex]) =>
|
||||
previousContent.slice(0, startIndex) +
|
||||
createCheckboxContent(event.newCheckedState) +
|
||||
previousContent.slice(endIndex)
|
||||
)
|
||||
.orElse(previousContent)
|
||||
)
|
||||
},
|
||||
[setContent]
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useChangeEditorContentCallback } from '../../../components/editor-page/change-content-context/use-change-editor-content-callback'
|
||||
import type { ContentEdits } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/changes'
|
||||
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { store } from '../../../redux'
|
||||
import { createCheckboxContent } from './create-checkbox-content'
|
||||
import type { TaskCheckedEventPayload } from './event-emitting-task-list-checkbox'
|
||||
import { findCheckBox } from './find-check-box'
|
||||
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
|
||||
import type React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Receives task-checkbox-change events and modify the current editor content.
|
||||
*/
|
||||
export const SetCheckboxInEditor: React.FC = () => {
|
||||
const changeCallback = useSetCheckboxInEditor()
|
||||
useExtensionEventEmitterHandler(TaskListCheckboxAppExtension.EVENT_NAME, changeCallback)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a callback that changes the state of a checkbox in a given line in the current codemirror instance.
|
||||
*/
|
||||
export const useSetCheckboxInEditor = () => {
|
||||
const changeEditorContent = useChangeEditorContentCallback()
|
||||
|
||||
return useCallback(
|
||||
({ lineInMarkdown, newCheckedState }: TaskCheckedEventPayload): void => {
|
||||
changeEditorContent?.(({ markdownContent }) => {
|
||||
const correctedLineIndex = lineInMarkdown + store.getState().noteDetails.frontmatterRendererInfo.lineOffset
|
||||
const edits = findCheckBox(markdownContent, correctedLineIndex)
|
||||
.map(([startIndex, endIndex]) => createCheckboxContentEdit(startIndex, endIndex, newCheckedState))
|
||||
.orElse([])
|
||||
return [edits, undefined]
|
||||
})
|
||||
},
|
||||
[changeEditorContent]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ContentEdits content edit} for the change of a checkbox at a given position.
|
||||
*
|
||||
* @param checkboxStartIndex The start index of the old checkbox code
|
||||
* @param checkboxEndIndex The end index of the old checkbox code
|
||||
* @param newCheckboxState The new status of the checkbox
|
||||
* @return the created {@link ContentEdits edit}
|
||||
*/
|
||||
const createCheckboxContentEdit = (
|
||||
checkboxStartIndex: number,
|
||||
checkboxEndIndex: number,
|
||||
newCheckboxState: boolean
|
||||
): ContentEdits => {
|
||||
return [
|
||||
{
|
||||
from: checkboxStartIndex,
|
||||
to: checkboxEndIndex,
|
||||
insert: createCheckboxContent(newCheckboxState)
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
|
||||
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
|
||||
import { AppExtension } from '../../_base-classes/app-extension'
|
||||
import { SetCheckboxInCheatsheet } from './set-checkbox-in-cheatsheet'
|
||||
import { SetCheckboxInEditor } from './set-checkbox-in-editor'
|
||||
import { TaskListMarkdownExtension } from './task-list-markdown-extension'
|
||||
import type React from 'react'
|
||||
|
||||
/**
|
||||
* Adds support for interactive checkbox lists to the markdown renderer.
|
||||
*/
|
||||
export class TaskListCheckboxAppExtension extends AppExtension {
|
||||
public static readonly EVENT_NAME = 'TaskListCheckbox'
|
||||
|
||||
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): TaskListMarkdownExtension[] {
|
||||
return [new TaskListMarkdownExtension(options.eventEmitter)]
|
||||
}
|
||||
|
||||
buildEditorExtensionComponent(): React.FC {
|
||||
return SetCheckboxInEditor
|
||||
}
|
||||
|
||||
buildCheatsheetExtensions(): CheatsheetExtension[] {
|
||||
return [{ i18nKey: 'taskList', cheatsheetExtensionComponent: SetCheckboxInCheatsheet }]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void
|
||||
|
||||
export interface TaskListProps {
|
||||
onTaskCheckedChange?: TaskCheckedChangeHandler
|
||||
checked: boolean
|
||||
lineInMarkdown?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
disabled
|
||||
}) => {
|
||||
const onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (onTaskCheckedChange && disabled !== true && lineInMarkdown !== undefined) {
|
||||
onTaskCheckedChange(lineInMarkdown, event.currentTarget.checked)
|
||||
}
|
||||
},
|
||||
[disabled, lineInMarkdown, onTaskCheckedChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<input
|
||||
disabled={disabled !== true && onTaskCheckedChange === undefined}
|
||||
className='task-list-item-checkbox'
|
||||
type='checkbox'
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { TaskListReplacer } from './task-list-replacer'
|
||||
import { taskLists } from '@hedgedoc/markdown-it-plugins'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
|
||||
*/
|
||||
export class TaskListMarkdownExtension extends EventMarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
taskLists(markdownIt, {
|
||||
enabled: true,
|
||||
label: true,
|
||||
lineNumber: true
|
||||
})
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new TaskListReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { EventEmittingTaskListCheckbox } from './event-emitting-task-list-checkbox'
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Detects task lists and renders them as checkboxes that execute a callback if clicked.
|
||||
*/
|
||||
export class TaskListReplacer extends ComponentReplacer {
|
||||
public replace(node: Element): NodeReplacement {
|
||||
if (node.attribs?.class !== 'task-list-item-checkbox') {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
const lineInMarkdown = Number(node.attribs['data-line'])
|
||||
return isNaN(lineInMarkdown) ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<EventEmittingTaskListCheckbox checked={node.attribs.checked !== undefined} lineInMarkdown={lineInMarkdown} />
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue