diff --git a/src/components/editor/app-bar/help-button/cheatsheet.tsx b/src/components/editor/app-bar/help-button/cheatsheet.tsx index c02065a6f..da83c0602 100644 --- a/src/components/editor/app-bar/help-button/cheatsheet.tsx +++ b/src/components/editor/app-bar/help-button/cheatsheet.tsx @@ -1,7 +1,10 @@ -import React from 'react' +import MarkdownIt from 'markdown-it' +import markdownItContainer from 'markdown-it-container' +import React, { useCallback } from 'react' import { Table } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' -import { MarkdownRenderer } from '../../../markdown-renderer/markdown-renderer' +import { BasicMarkdownRenderer } from '../../../markdown-renderer/basic-markdown-renderer' +import { createRenderContainer, validAlertLevels } from '../../../markdown-renderer/markdown-it-plugins/alert-container' import { HighlightedCode } from '../../../markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code' import './cheatsheet.scss' @@ -27,6 +30,13 @@ export const Cheatsheet: React.FC = () => { ':smile:', `:::info\n${t('editor.help.cheatsheet.exampleAlert')}\n:::` ] + + const markdownItPlugins = useCallback((md: MarkdownIt) => { + validAlertLevels.forEach(level => { + md.use(markdownItContainer, level, { render: createRenderContainer(level) }) + }) + }, []) + return ( @@ -40,14 +50,10 @@ export const Cheatsheet: React.FC = () => { return (
- null} - onTocChange={() => false} - onMetaDataChange={() => false} - onFirstHeadingChange={() => false} - onLineMarkerPositionChanged={() => false} + onConfigureMarkdownIt={markdownItPlugins} /> diff --git a/src/components/editor/app-bar/help-button/help-button.tsx b/src/components/editor/app-bar/help-button/help-button.tsx index 148280d99..fed9ef53c 100644 --- a/src/components/editor/app-bar/help-button/help-button.tsx +++ b/src/components/editor/app-bar/help-button/help-button.tsx @@ -6,7 +6,7 @@ import { Cheatsheet } from './cheatsheet' import { Links } from './links' import { Shortcut } from './shortcuts' -enum HelpTabStatus { +export enum HelpTabStatus { Cheatsheet='cheatsheet.title', Shortcuts='shortcuts.title', Links='links.title' diff --git a/src/components/editor/document-renderer-pane/document-render-pane.tsx b/src/components/editor/document-renderer-pane/document-render-pane.tsx index 71557a74e..6fdd778ca 100644 --- a/src/components/editor/document-renderer-pane/document-render-pane.tsx +++ b/src/components/editor/document-renderer-pane/document-render-pane.tsx @@ -4,7 +4,8 @@ import useResizeObserver from 'use-resize-observer' import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ShowIf } from '../../common/show-if/show-if' -import { LineMarkerPosition, MarkdownRenderer } from '../../markdown-renderer/markdown-renderer' +import { LineMarkerPosition } from '../../markdown-renderer/types' +import { FullMarkdownRenderer } from '../../markdown-renderer/full-markdown-renderer' import { ScrollProps, ScrollState } from '../scroll/scroll-props' import { findLineMarks } from '../scroll/utils' import { TableOfContents } from '../table-of-contents/table-of-contents' @@ -108,16 +109,18 @@ export const DocumentRenderPane: React.FC
- setTocAst(tocAst)} - wide={wide} - /> +
+ setTocAst(tocAst)} + wide={wide} + /> +
= 1280 && !!tocAst}> diff --git a/src/components/editor/scroll/utils.ts b/src/components/editor/scroll/utils.ts index 6fa271998..4f2a039e4 100644 --- a/src/components/editor/scroll/utils.ts +++ b/src/components/editor/scroll/utils.ts @@ -1,4 +1,5 @@ -import { LineMarkerPosition } from '../../markdown-renderer/markdown-renderer' +import { LineMarkerPosition } from '../../markdown-renderer/types' + export const findLineMarks = (lineMarks: LineMarkerPosition[], lineNumber: number): { lastMarkBefore: LineMarkerPosition | undefined, firstMarkAfter: LineMarkerPosition | undefined } => { let lastMarkBefore let firstMarkAfter diff --git a/src/components/markdown-renderer/basic-markdown-renderer.tsx b/src/components/markdown-renderer/basic-markdown-renderer.tsx new file mode 100644 index 000000000..34c0c1198 --- /dev/null +++ b/src/components/markdown-renderer/basic-markdown-renderer.tsx @@ -0,0 +1,106 @@ +import MarkdownIt from 'markdown-it' +import abbreviation from 'markdown-it-abbr' +import definitionList from 'markdown-it-deflist' +import emoji from 'markdown-it-emoji' +import footnote from 'markdown-it-footnote' +import imsize from 'markdown-it-imsize' +import inserted from 'markdown-it-ins' +import marked from 'markdown-it-mark' +import subscript from 'markdown-it-sub' +import superscript from 'markdown-it-sup' +import React, { ReactElement, RefObject, useMemo, useRef } from 'react' +import { Alert } from 'react-bootstrap' +import ReactHtmlParser from 'react-html-parser' +import { Trans } from 'react-i18next' +import { useSelector } from 'react-redux' +import { ApplicationState } from '../../redux' +import { ShowIf } from '../common/show-if/show-if' +import { combinedEmojiData } from './markdown-it-plugins/emoji/mapping' +import { linkifyExtra } from './markdown-it-plugins/linkify-extra' +import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger' +import './markdown-renderer.scss' +import { ComponentReplacer } from './replace-components/ComponentReplacer' +import { AdditionalMarkdownRendererProps, LineKeys } from './types' +import { buildTransformer } from './utils/html-react-transformer' +import { calculateNewLineNumberMapping } from './utils/line-number-mapping' + +export interface BasicMarkdownRendererProps { + componentReplacers?: ComponentReplacer[], + onConfigureMarkdownIt?: (md: MarkdownIt) => void, + documentReference?: RefObject + onBeforeRendering?: () => void +} + +export const BasicMarkdownRenderer: React.FC = ({ + className, + content, + wide, + componentReplacers, + onConfigureMarkdownIt, + documentReference, + onBeforeRendering +}) => { + const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) + + const markdownIt = useMemo(() => { + const md = new MarkdownIt('default', { + html: true, + breaks: true, + langPrefix: '', + typographer: true + }) + + md.use(emoji, { + defs: combinedEmojiData + }) + md.use(abbreviation) + md.use(definitionList) + md.use(subscript) + md.use(superscript) + md.use(inserted) + md.use(marked) + md.use(footnote) + md.use(imsize) + + if (onConfigureMarkdownIt) { + onConfigureMarkdownIt(md) + } + + md.use(linkifyExtra) + if (process.env.NODE_ENV !== 'production') { + md.use(MarkdownItParserDebugger) + } + + return md + }, [onConfigureMarkdownIt]) + + const oldMarkdownLineKeys = useRef() + const lastUsedLineId = useRef(0) + + const markdownReactDom: ReactElement[] = useMemo(() => { + if (onBeforeRendering) { + onBeforeRendering() + } + const trimmedContent = content.substr(0, maxLength) + const html: string = markdownIt.render(trimmedContent) + const contentLines = trimmedContent.split('\n') + const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping(contentLines, oldMarkdownLineKeys.current ?? [], lastUsedLineId.current) + oldMarkdownLineKeys.current = newLines + lastUsedLineId.current = newLastUsedLineId + const transformer = componentReplacers ? buildTransformer(newLines, componentReplacers) : undefined + return ReactHtmlParser(html, { transform: transformer }) + }, [onBeforeRendering, content, maxLength, markdownIt, componentReplacers]) + + return ( +
+ maxLength}> + + + + +
+ {markdownReactDom} +
+
+ ) +} diff --git a/src/components/markdown-renderer/full-markdown-renderer.tsx b/src/components/markdown-renderer/full-markdown-renderer.tsx new file mode 100644 index 000000000..09583cafe --- /dev/null +++ b/src/components/markdown-renderer/full-markdown-renderer.tsx @@ -0,0 +1,227 @@ +import markdownItTaskLists from '@hedgedoc/markdown-it-task-lists' +import yaml from 'js-yaml' +import MarkdownIt from 'markdown-it' +import anchor from 'markdown-it-anchor' +import markdownItContainer from 'markdown-it-container' +import frontmatter from 'markdown-it-front-matter' +import mathJax from 'markdown-it-mathjax' +import plantuml from 'markdown-it-plantuml' +import markdownItRegex from 'markdown-it-regex' +import toc from 'markdown-it-toc-done-right' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { Alert } from 'react-bootstrap' +import { Trans } from 'react-i18next' +import { useSelector } from 'react-redux' +import { TocAst } from '../../external-types/markdown-it-toc-done-right/interface' +import { ApplicationState } from '../../redux' +import { InternalLink } from '../common/links/internal-link' +import { ShowIf } from '../common/show-if/show-if' +import { slugify } from '../editor/table-of-contents/table-of-contents' +import { RawYAMLMetadata, YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata' +import { BasicMarkdownRenderer } from './basic-markdown-renderer' +import { createRenderContainer, validAlertLevels } from './markdown-it-plugins/alert-container' +import { firstHeaderExtractor } from './markdown-it-plugins/first-header-extractor' +import { highlightedCode } from './markdown-it-plugins/highlighted-code' +import { LineMarkers, lineNumberMarker } from './markdown-it-plugins/line-number-marker' +import { plantumlError } from './markdown-it-plugins/plantuml-error' +import { replaceAsciinemaLink } from './regex-plugins/replace-asciinema-link' +import { replaceGistLink } from './regex-plugins/replace-gist-link' +import { replaceLegacyGistShortCode } from './regex-plugins/replace-legacy-gist-short-code' +import { replaceLegacySlideshareShortCode } from './regex-plugins/replace-legacy-slideshare-short-code' +import { replaceLegacySpeakerdeckShortCode } from './regex-plugins/replace-legacy-speakerdeck-short-code' +import { replaceLegacyVimeoShortCode } from './regex-plugins/replace-legacy-vimeo-short-code' +import { replaceLegacyYoutubeShortCode } from './regex-plugins/replace-legacy-youtube-short-code' +import { replacePdfShortCode } from './regex-plugins/replace-pdf-short-code' +import { replaceQuoteExtraAuthor } from './regex-plugins/replace-quote-extra-author' +import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-color' +import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time' +import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link' +import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link' +import { AbcReplacer } from './replace-components/abc/abc-replacer' +import { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer' +import { CsvReplacer } from './replace-components/csv/csv-replacer' +import { FlowchartReplacer } from './replace-components/flow/flowchart-replacer' +import { GistReplacer } from './replace-components/gist/gist-replacer' +import { HighlightedCodeReplacer } from './replace-components/highlighted-fence/highlighted-fence-replacer' +import { ImageReplacer } from './replace-components/image/image-replacer' +import { KatexReplacer } from './replace-components/katex/katex-replacer' +import { LinemarkerReplacer } from './replace-components/linemarker/linemarker-replacer' +import { MermaidReplacer } from './replace-components/mermaid/mermaid-replacer' +import { PdfReplacer } from './replace-components/pdf/pdf-replacer' +import { PossibleWiderReplacer } from './replace-components/possible-wider/possible-wider-replacer' +import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-options-replacer' +import { SequenceDiagramReplacer } from './replace-components/sequence-diagram/sequence-diagram-replacer' +import { TaskListReplacer } from './replace-components/task-list/task-list-replacer' +import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer' +import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer' +import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions' +import { AdditionalMarkdownRendererProps, LineMarkerPosition } from './types' +import { usePostMetaDataOnChange } from './utils/use-post-meta-data-on-change' +import { usePostTocAstOnChange } from './utils/use-post-toc-ast-on-change' + +export interface FullMarkdownRendererProps { + onFirstHeadingChange?: (firstHeading: string | undefined) => void + onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void + onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void + onTaskCheckedChange: (lineInMarkdown: number, checked: boolean) => void + onTocChange?: (ast: TocAst) => void +} + +export const FullMarkdownRenderer: React.FC = ({ + onFirstHeadingChange, + onLineMarkerPositionChanged, + onMetaDataChange, + onTaskCheckedChange, + onTocChange, + content, + className, + wide +}) => { + const allReplacers = useMemo(() => { + return [ + new LinemarkerReplacer(), + new PossibleWiderReplacer(), + new GistReplacer(), + new YoutubeReplacer(), + new VimeoReplacer(), + new AsciinemaReplacer(), + new AbcReplacer(), + new PdfReplacer(), + new ImageReplacer(), + new SequenceDiagramReplacer(), + new CsvReplacer(), + new FlowchartReplacer(), + new MermaidReplacer(), + new HighlightedCodeReplacer(), + new QuoteOptionsReplacer(), + new KatexReplacer(), + new TaskListReplacer(onTaskCheckedChange) + ] + }, [onTaskCheckedChange]) + + const [yamlError, setYamlError] = useState(false) + + const plantumlServer = useSelector((state: ApplicationState) => state.config.plantumlServer) + + const rawMetaRef = useRef() + const firstHeadingRef = useRef() + const documentElement = useRef(null) + const currentLineMarkers = useRef() + usePostMetaDataOnChange(rawMetaRef.current, firstHeadingRef.current, onMetaDataChange, onFirstHeadingChange) + useCalculateLineMarkerPosition(documentElement, currentLineMarkers.current, onLineMarkerPositionChanged, documentElement.current?.offsetTop ?? 0) + + const tocAst = useRef() + usePostTocAstOnChange(tocAst, onTocChange) + + const configureMarkdownIt = useCallback((md: MarkdownIt): void => { + if (onFirstHeadingChange) { + md.use(firstHeaderExtractor(), { + firstHeaderFound: (firstHeader: string | undefined) => { + firstHeadingRef.current = firstHeader + } + }) + } + + if (onMetaDataChange) { + md.use(frontmatter, (rawMeta: string) => { + try { + const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata + setYamlError(false) + rawMetaRef.current = meta + } catch (e) { + console.error(e) + setYamlError(true) + } + }) + } + md.use(markdownItTaskLists, { lineNumber: true }) + if (plantumlServer) { + md.use(plantuml, { + openMarker: '```plantuml', + closeMarker: '```', + server: plantumlServer + }) + } else { + md.use(plantumlError) + } + + if (onMetaDataChange) { + md.use(frontmatter, (rawMeta: string) => { + try { + const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata + setYamlError(false) + rawMetaRef.current = meta + } catch (e) { + console.error(e) + setYamlError(true) + rawMetaRef.current = ({} as RawYAMLMetadata) + } + }) + } + // noinspection CheckTagEmptyBody + md.use(anchor, { + permalink: true, + permalinkBefore: true, + permalinkClass: 'heading-anchor text-dark', + permalinkSymbol: '' + }) + md.use(mathJax({ + beforeMath: '', + afterMath: '', + beforeInlineMath: '', + afterInlineMath: '', + beforeDisplayMath: '', + afterDisplayMath: '' + })) + md.use(markdownItRegex, replaceLegacyYoutubeShortCode) + md.use(markdownItRegex, replaceLegacyVimeoShortCode) + md.use(markdownItRegex, replaceLegacyGistShortCode) + md.use(markdownItRegex, replaceLegacySlideshareShortCode) + md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode) + md.use(markdownItRegex, replacePdfShortCode) + md.use(markdownItRegex, replaceAsciinemaLink) + md.use(markdownItRegex, replaceYouTubeLink) + md.use(markdownItRegex, replaceVimeoLink) + md.use(markdownItRegex, replaceGistLink) + md.use(highlightedCode) + md.use(markdownItRegex, replaceQuoteExtraAuthor) + md.use(markdownItRegex, replaceQuoteExtraColor) + md.use(markdownItRegex, replaceQuoteExtraTime) + md.use(toc, { + placeholder: '(\\[TOC\\]|\\[toc\\])', + listType: 'ul', + level: [1, 2, 3], + callback: (code: string, ast: TocAst): void => { + tocAst.current = ast + }, + slugify: slugify + }) + validAlertLevels.forEach(level => { + md.use(markdownItContainer, level, { render: createRenderContainer(level) }) + }) + md.use(lineNumberMarker(), { + postLineMarkers: (lineMarkers) => { + currentLineMarkers.current = lineMarkers + } + }) + }, [onFirstHeadingChange, onMetaDataChange, plantumlServer]) + + const clearMetadata = useCallback(() => { + rawMetaRef.current = undefined + }, []) + + return ( +
+ + + + + + + + +
+ ) +} diff --git a/src/components/markdown-renderer/markdown-it-plugins/emoji/mapping.ts b/src/components/markdown-renderer/markdown-it-plugins/emoji/mapping.ts new file mode 100644 index 000000000..79285a6c7 --- /dev/null +++ b/src/components/markdown-renderer/markdown-it-plugins/emoji/mapping.ts @@ -0,0 +1,36 @@ +import emojiData from 'emoji-mart/data/twitter.json' +import { Data } from 'emoji-mart/dist-es/utils/data' +import { ForkAwesomeIcons } from '../../../editor/editor-pane/tool-bar/emoji-picker/icon-names' + +export const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis) + .reduce((reduceObject, emojiIdentifier) => { + const emoji = (emojiData as unknown as Data).emojis[emojiIdentifier] + const emojiCodes = emoji.unified ?? emoji.b + if (emojiCodes) { + reduceObject[emojiIdentifier] = emojiCodes.split('-').map(char => `&#x${char};`).join('') + } + return reduceObject + }, {} as { [key: string]: string }) + +export const emojiSkinToneModifierMap = [2, 3, 4, 5, 6] + .reduce((reduceObject, modifierValue) => { + const lightSkinCode = 127995 + const codepoint = lightSkinCode + (modifierValue - 2) + const shortcode = `skin-tone-${modifierValue}` + reduceObject[shortcode] = `&#${codepoint};` + return reduceObject + }, {} as { [key: string]: string }) + +export const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons) + .reduce((reduceObject, icon) => { + const shortcode = `fa-${icon}` + // noinspection CheckTagEmptyBody + reduceObject[shortcode] = `` + return reduceObject + }, {} as { [key: string]: string }) + +export const combinedEmojiData = { + ...markdownItTwitterEmojis, + ...emojiSkinToneModifierMap, + ...forkAwesomeIconMap +} diff --git a/src/components/markdown-renderer/markdown-it-plugins/first-header-extractor.ts b/src/components/markdown-renderer/markdown-it-plugins/first-header-extractor.ts new file mode 100644 index 000000000..96d183f87 --- /dev/null +++ b/src/components/markdown-renderer/markdown-it-plugins/first-header-extractor.ts @@ -0,0 +1,26 @@ +import MarkdownIt from 'markdown-it/lib' + +export interface FirstHeaderExtractorOptions { + firstHeaderFound: (firstHeader: string|undefined) => void +} + +export const firstHeaderExtractor: () => MarkdownIt.PluginWithOptions = () => { + return (md, options) => { + if (!options?.firstHeaderFound) { + return + } + md.core.ruler.after('normalize', 'extract first L1 heading', (state) => { + const lines = state.src.split('\n') + const linkAltTextRegex = /!?\[([^\]]*)]\([^)]*\)/ + for (const line of lines) { + if (line.startsWith('# ')) { + const headerLine = line.replace('# ', '').replace(linkAltTextRegex, '$1') + options.firstHeaderFound(headerLine) + return true + } + } + options.firstHeaderFound(undefined) + return true + }) + } +} diff --git a/src/components/markdown-renderer/markdown-renderer.tsx b/src/components/markdown-renderer/markdown-renderer.tsx deleted file mode 100644 index 0ebba77c6..000000000 --- a/src/components/markdown-renderer/markdown-renderer.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import emojiData from 'emoji-mart/data/twitter.json' -import { Data } from 'emoji-mart/dist-es/utils/data' -import equal from 'fast-deep-equal' -import yaml from 'js-yaml' -import MarkdownIt from 'markdown-it' -import abbreviation from 'markdown-it-abbr' -import anchor from 'markdown-it-anchor' -import markdownItContainer from 'markdown-it-container' -import definitionList from 'markdown-it-deflist' -import emoji from 'markdown-it-emoji' -import footnote from 'markdown-it-footnote' -import frontmatter from 'markdown-it-front-matter' -import imsize from 'markdown-it-imsize' -import inserted from 'markdown-it-ins' -import marked from 'markdown-it-mark' -import mathJax from 'markdown-it-mathjax' -import plantuml from 'markdown-it-plantuml' -import markdownItRegex from 'markdown-it-regex' -import subscript from 'markdown-it-sub' -import superscript from 'markdown-it-sup' -import toc from 'markdown-it-toc-done-right' -import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import markdownItTaskLists from '@hedgedoc/markdown-it-task-lists' -import { Alert } from 'react-bootstrap' -import ReactHtmlParser from 'react-html-parser' -import { Trans } from 'react-i18next' -import { useSelector } from 'react-redux' -import useResizeObserver from 'use-resize-observer' -import { TocAst } from '../../external-types/markdown-it-toc-done-right/interface' -import { ApplicationState } from '../../redux' -import { InternalLink } from '../common/links/internal-link' -import { ShowIf } from '../common/show-if/show-if' -import { ForkAwesomeIcons } from '../editor/editor-pane/tool-bar/emoji-picker/icon-names' -import { slugify } from '../editor/table-of-contents/table-of-contents' -import { RawYAMLMetadata, YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata' -import { createRenderContainer, validAlertLevels } from './markdown-it-plugins/alert-container' -import { highlightedCode } from './markdown-it-plugins/highlighted-code' -import { LineMarkers, lineNumberMarker } from './markdown-it-plugins/line-number-marker' -import { linkifyExtra } from './markdown-it-plugins/linkify-extra' -import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger' -import { plantumlError } from './markdown-it-plugins/plantuml-error' -import { replaceAsciinemaLink } from './regex-plugins/replace-asciinema-link' -import { replaceGistLink } from './regex-plugins/replace-gist-link' -import { replaceLegacyGistShortCode } from './regex-plugins/replace-legacy-gist-short-code' -import { replaceLegacySlideshareShortCode } from './regex-plugins/replace-legacy-slideshare-short-code' -import { replaceLegacySpeakerdeckShortCode } from './regex-plugins/replace-legacy-speakerdeck-short-code' -import { replaceLegacyVimeoShortCode } from './regex-plugins/replace-legacy-vimeo-short-code' -import { replaceLegacyYoutubeShortCode } from './regex-plugins/replace-legacy-youtube-short-code' -import { replacePdfShortCode } from './regex-plugins/replace-pdf-short-code' -import { replaceQuoteExtraAuthor } from './regex-plugins/replace-quote-extra-author' -import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-color' -import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time' -import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link' -import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link' -import { buildTransformer, calculateNewLineNumberMapping, LineKeys } from './renderer-utils' -import { AbcReplacer } from './replace-components/abc/abc-replacer' -import { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer' -import { LinemarkerReplacer } from './replace-components/linemarker/linemarker-replacer' -import { ComponentReplacer } from './replace-components/ComponentReplacer' -import { CsvReplacer } from './replace-components/csv/csv-replacer' -import { FlowchartReplacer } from './replace-components/flow/flowchart-replacer' -import { GistReplacer } from './replace-components/gist/gist-replacer' -import { HighlightedCodeReplacer } from './replace-components/highlighted-fence/highlighted-fence-replacer' -import { ImageReplacer } from './replace-components/image/image-replacer' -import { KatexReplacer } from './replace-components/katex/katex-replacer' -import { MermaidReplacer } from './replace-components/mermaid/mermaid-replacer' -import { PdfReplacer } from './replace-components/pdf/pdf-replacer' -import { PossibleWiderReplacer } from './replace-components/possible-wider/possible-wider-replacer' -import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-options-replacer' -import { SequenceDiagramReplacer } from './replace-components/sequence-diagram/sequence-diagram-replacer' -import { TaskListReplacer } from './replace-components/task-list/task-list-replacer' -import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer' -import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer' -import './markdown-renderer.scss' - -export interface LineMarkerPosition { - line: number - position: number -} - -export interface MarkdownRendererProps { - className?: string - content: string - onFirstHeadingChange?: (firstHeading: string | undefined) => void - onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void - onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void - onTaskCheckedChange: (lineInMarkdown: number, checked: boolean) => void - onTocChange?: (ast: TocAst) => void - wide?: boolean -} - -const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis) - .reduce((reduceObject, emojiIdentifier) => { - const emoji = (emojiData as unknown as Data).emojis[emojiIdentifier] - const emojiCodes = emoji.unified ?? emoji.b - if (emojiCodes) { - reduceObject[emojiIdentifier] = emojiCodes.split('-').map(char => `&#x${char};`).join('') - } - return reduceObject - }, {} as { [key: string]: string }) - -const emojiSkinToneModifierMap = [2, 3, 4, 5, 6] - .reduce((reduceObject, modifierValue) => { - const lightSkinCode = 127995 - const codepoint = lightSkinCode + (modifierValue - 2) - const shortcode = `skin-tone-${modifierValue}` - reduceObject[shortcode] = `&#${codepoint};` - return reduceObject - }, {} as { [key: string]: string }) - -const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons) - .reduce((reduceObject, icon) => { - const shortcode = `fa-${icon}` - // noinspection CheckTagEmptyBody - reduceObject[shortcode] = `` - return reduceObject - }, {} as { [key: string]: string }) - -export const MarkdownRenderer: React.FC = ({ - className, - content, - onFirstHeadingChange, - onLineMarkerPositionChanged, - onMetaDataChange, - onTaskCheckedChange, - onTocChange, - wide -}) => { - const [tocAst, setTocAst] = useState() - const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) - const lastTocAst = useRef() - const [yamlError, setYamlError] = useState(false) - const rawMetaRef = useRef() - const oldMetaRef = useRef() - const firstHeadingRef = useRef() - const oldFirstHeadingRef = useRef() - const documentElement = useRef(null) - const lastLineMarkerPositions = useRef() - const currentLineMarkers = useRef() - - const calculateLineMarkerPositions = useCallback(() => { - if (!(documentElement.current && onLineMarkerPositionChanged)) { - return - } - if (currentLineMarkers.current === undefined) { - return - } - const lineMarkers = currentLineMarkers.current - const children: HTMLCollection = documentElement.current.children - const lineMarkerPositions:LineMarkerPosition[] = [] - - Array.from(children).forEach((child, childIndex) => { - const htmlChild = (child as HTMLElement) - if (htmlChild.offsetTop === undefined) { - return - } - const currentLineMarker = lineMarkers[childIndex] - if (currentLineMarker === undefined) { - return - } - - const lastPosition = lineMarkerPositions[lineMarkerPositions.length - 1] - if (!lastPosition || lastPosition.line !== currentLineMarker.startLine) { - lineMarkerPositions.push({ - line: currentLineMarker.startLine, - position: htmlChild.offsetTop - }) - } - - lineMarkerPositions.push({ - line: currentLineMarker.endLine, - position: htmlChild.offsetTop + htmlChild.offsetHeight - }) - }) - - if (!equal(lineMarkerPositions, lastLineMarkerPositions.current)) { - lastLineMarkerPositions.current = lineMarkerPositions - onLineMarkerPositionChanged(lineMarkerPositions) - } - }, [onLineMarkerPositionChanged]) - - useEffect(() => { - calculateLineMarkerPositions() - }, [calculateLineMarkerPositions, content]) - - useResizeObserver({ - ref: documentElement, - onResize: () => calculateLineMarkerPositions() - }) - - useEffect(() => { - if (onMetaDataChange && !equal(oldMetaRef.current, rawMetaRef.current)) { - if (rawMetaRef.current) { - const newMetaData = new YAMLMetaData(rawMetaRef.current) - onMetaDataChange(newMetaData) - } else { - onMetaDataChange(undefined) - } - oldMetaRef.current = rawMetaRef.current - } - if (onFirstHeadingChange && !equal(firstHeadingRef.current, oldFirstHeadingRef.current)) { - onFirstHeadingChange(firstHeadingRef.current || undefined) - oldFirstHeadingRef.current = firstHeadingRef.current - } - }) - - const plantumlServer = useSelector((state: ApplicationState) => state.config.plantumlServer) - - const markdownIt = useMemo(() => { - const md = new MarkdownIt('default', { - html: true, - breaks: true, - langPrefix: '', - typographer: true - }) - if (onFirstHeadingChange) { - md.core.ruler.after('normalize', 'extract first L1 heading', (state) => { - const lines = state.src.split('\n') - const linkAltTextRegex = /!?\[([^\]]*)]\([^)]*\)/ - for (const line of lines) { - if (line.startsWith('# ')) { - firstHeadingRef.current = line.replace('# ', '').replace(linkAltTextRegex, '$1') - return true - } - } - firstHeadingRef.current = undefined - return true - }) - } - if (onMetaDataChange) { - md.use(frontmatter, (rawMeta: string) => { - try { - const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata - setYamlError(false) - rawMetaRef.current = meta - } catch (e) { - console.error(e) - setYamlError(true) - } - }) - } - md.use(markdownItTaskLists, { lineNumber: true }) - if (plantumlServer) { - md.use(plantuml, { - openMarker: '```plantuml', - closeMarker: '```', - server: plantumlServer - }) - } else { - md.use(plantumlError) - } - md.use(emoji, { - defs: { - ...markdownItTwitterEmojis, - ...emojiSkinToneModifierMap, - ...forkAwesomeIconMap - } - }) - md.use(abbreviation) - md.use(definitionList) - md.use(subscript) - md.use(superscript) - md.use(inserted) - md.use(marked) - md.use(footnote) - if (onMetaDataChange) { - md.use(frontmatter, (rawMeta: string) => { - try { - const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata - setYamlError(false) - rawMetaRef.current = meta - } catch (e) { - console.error(e) - setYamlError(true) - rawMetaRef.current = ({} as RawYAMLMetadata) - } - }) - } - md.use(imsize) - // noinspection CheckTagEmptyBody - md.use(anchor, { - permalink: true, - permalinkBefore: true, - permalinkClass: 'heading-anchor text-dark', - permalinkSymbol: '' - }) - md.use(mathJax({ - beforeMath: '', - afterMath: '', - beforeInlineMath: '', - afterInlineMath: '', - beforeDisplayMath: '', - afterDisplayMath: '' - })) - md.use(markdownItRegex, replaceLegacyYoutubeShortCode) - md.use(markdownItRegex, replaceLegacyVimeoShortCode) - md.use(markdownItRegex, replaceLegacyGistShortCode) - md.use(markdownItRegex, replaceLegacySlideshareShortCode) - md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode) - md.use(markdownItRegex, replacePdfShortCode) - md.use(markdownItRegex, replaceAsciinemaLink) - md.use(markdownItRegex, replaceYouTubeLink) - md.use(markdownItRegex, replaceVimeoLink) - md.use(markdownItRegex, replaceGistLink) - md.use(highlightedCode) - md.use(markdownItRegex, replaceQuoteExtraAuthor) - md.use(markdownItRegex, replaceQuoteExtraColor) - md.use(markdownItRegex, replaceQuoteExtraTime) - md.use(toc, { - placeholder: '(\\[TOC\\]|\\[toc\\])', - listType: 'ul', - level: [1, 2, 3], - callback: (code: string, ast: TocAst): void => { - setTocAst(ast) - }, - slugify: slugify - }) - md.use(linkifyExtra) - validAlertLevels.forEach(level => { - md.use(markdownItContainer, level, { render: createRenderContainer(level) }) - }) - md.use(lineNumberMarker(), { - postLineMarkers: (lineMarkers) => { - currentLineMarkers.current = lineMarkers - } - }) - if (process.env.NODE_ENV !== 'production') { - md.use(MarkdownItParserDebugger) - } - - return md - }, [onMetaDataChange, onFirstHeadingChange, plantumlServer]) - - useEffect(() => { - if (onTocChange && tocAst && !equal(tocAst, lastTocAst.current)) { - lastTocAst.current = tocAst - onTocChange(tocAst) - } - }, [tocAst, onTocChange]) - - const oldMarkdownLineKeys = useRef() - const lastUsedLineId = useRef(0) - - const markdownReactDom: ReactElement[] = useMemo(() => { - const allReplacers: ComponentReplacer[] = [ - new LinemarkerReplacer(), - new PossibleWiderReplacer(), - new GistReplacer(), - new YoutubeReplacer(), - new VimeoReplacer(), - new AsciinemaReplacer(), - new AbcReplacer(), - new PdfReplacer(), - new ImageReplacer(), - new SequenceDiagramReplacer(), - new CsvReplacer(), - new FlowchartReplacer(), - new MermaidReplacer(), - new HighlightedCodeReplacer(), - new QuoteOptionsReplacer(), - new KatexReplacer(), - new TaskListReplacer(onTaskCheckedChange) - ] - if (onMetaDataChange) { - // This is used if the front-matter callback is never called, because the user deleted everything regarding metadata from the document - rawMetaRef.current = undefined - } - const trimmedContent = content.substr(0, maxLength) - const html: string = markdownIt.render(trimmedContent) - const contentLines = trimmedContent.split('\n') - const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping(contentLines, oldMarkdownLineKeys.current ?? [], lastUsedLineId.current) - oldMarkdownLineKeys.current = newLines - lastUsedLineId.current = newLastUsedLineId - return ReactHtmlParser(html, { transform: buildTransformer(newLines, allReplacers) }) - }, [content, markdownIt, onMetaDataChange, onTaskCheckedChange, maxLength]) - - return ( -
-
- - - - - - - - maxLength}> - - - - -
- {markdownReactDom} -
-
-
- ) -} diff --git a/src/components/markdown-renderer/types.d.ts b/src/components/markdown-renderer/types.d.ts new file mode 100644 index 000000000..1849263cc --- /dev/null +++ b/src/components/markdown-renderer/types.d.ts @@ -0,0 +1,15 @@ +export interface LineKeys { + line: string, + id: number +} + +export interface LineMarkerPosition { + line: number + position: number +} + +export interface AdditionalMarkdownRendererProps { + className?: string, + content: string, + wide?: boolean, +} diff --git a/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts b/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts new file mode 100644 index 000000000..ae77a5d15 --- /dev/null +++ b/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts @@ -0,0 +1,63 @@ +import equal from 'fast-deep-equal' +import { RefObject, useCallback, useEffect, useRef } from 'react' +import useResizeObserver from 'use-resize-observer' +import { LineMarkerPosition } from '../types' +import { LineMarkers } from '../markdown-it-plugins/line-number-marker' + +export const calculateLineMarkerPositions = (documentElement: HTMLDivElement, currentLineMarkers: LineMarkers[], offset?: number): LineMarkerPosition[] => { + const lineMarkers = currentLineMarkers + const children: HTMLCollection = documentElement.children + const lineMarkerPositions:LineMarkerPosition[] = [] + + Array.from(children).forEach((child, childIndex) => { + const htmlChild = (child as HTMLElement) + if (htmlChild.offsetTop === undefined) { + return + } + const currentLineMarker = lineMarkers[childIndex] + if (currentLineMarker === undefined) { + return + } + + const lastPosition = lineMarkerPositions[lineMarkerPositions.length - 1] + if (!lastPosition || lastPosition.line !== currentLineMarker.startLine) { + lineMarkerPositions.push({ + line: currentLineMarker.startLine, + position: htmlChild.offsetTop + (offset ?? 0) + }) + } + + lineMarkerPositions.push({ + line: currentLineMarker.endLine, + position: htmlChild.offsetTop + htmlChild.offsetHeight + (offset ?? 0) + }) + }) + + return lineMarkerPositions +} + +export const useCalculateLineMarkerPosition = (documentElement: RefObject, lineMarkers?: LineMarkers[], onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void, offset?: number) : void => { + const lastLineMarkerPositions = useRef() + + const calculateNewLineMarkerPositions = useCallback(() => { + if (!documentElement.current || !onLineMarkerPositionChanged || !lineMarkers) { + return + } + + const newLines = calculateLineMarkerPositions(documentElement.current, lineMarkers, offset) + + if (!equal(newLines, lastLineMarkerPositions)) { + lastLineMarkerPositions.current = newLines + onLineMarkerPositionChanged(newLines) + } + }, [documentElement, lineMarkers, offset, onLineMarkerPositionChanged]) + + useEffect(() => { + calculateNewLineMarkerPositions() + }, [calculateNewLineMarkerPositions]) + + useResizeObserver({ + ref: documentElement, + onResize: () => calculateNewLineMarkerPositions() + }) +} diff --git a/src/components/markdown-renderer/renderer-utils.tsx b/src/components/markdown-renderer/utils/html-react-transformer.tsx similarity index 66% rename from src/components/markdown-renderer/renderer-utils.tsx rename to src/components/markdown-renderer/utils/html-react-transformer.tsx index 99ea0cdb2..225619557 100644 --- a/src/components/markdown-renderer/renderer-utils.tsx +++ b/src/components/markdown-renderer/utils/html-react-transformer.tsx @@ -1,51 +1,17 @@ -import { diffArrays } from 'diff' import { DomElement } from 'domhandler' import React, { Fragment, ReactElement } from 'react' import { convertNodeToElement, Transform } from 'react-html-parser' import { ComponentReplacer, SubNodeTransform -} from './replace-components/ComponentReplacer' +} from '../replace-components/ComponentReplacer' +import { LineKeys } from '../types' export interface TextDifferenceResult { lines: LineKeys[], lastUsedLineId: number } -export interface LineKeys { - line: string, - id: number -} - -export const calculateNewLineNumberMapping = (newMarkdownLines: string[], oldLineKeys: LineKeys[], lastUsedLineId: number): TextDifferenceResult => { - const lineDifferences = diffArrays(newMarkdownLines, oldLineKeys, { - comparator: (left:string|LineKeys, right:string|LineKeys) => { - const leftLine = (left as LineKeys).line ?? (left as string) - const rightLine = (right as LineKeys).line ?? (right as string) - return leftLine === rightLine - } - }) - - const newLines: LineKeys[] = [] - - lineDifferences - .filter((change) => change.added === undefined || !change.added) - .forEach((value) => { - if (value.removed) { - (value.value as string[]) - .forEach(line => { - lastUsedLineId += 1 - newLines.push({ line: line, id: lastUsedLineId }) - }) - } else { - (value.value as LineKeys[]) - .forEach((line) => newLines.push(line)) - } - }) - - return { lines: newLines, lastUsedLineId: lastUsedLineId } -} - export const calculateKeyFromLineMarker = (node: DomElement, lineKeys?: LineKeys[]): number|undefined => { if (!node.attribs || lineKeys === undefined) { return diff --git a/src/components/markdown-renderer/utils/line-number-mapping.ts b/src/components/markdown-renderer/utils/line-number-mapping.ts new file mode 100644 index 000000000..6a7908835 --- /dev/null +++ b/src/components/markdown-renderer/utils/line-number-mapping.ts @@ -0,0 +1,32 @@ +import { diffArrays } from 'diff' +import { TextDifferenceResult } from './html-react-transformer' +import { LineKeys } from '../types' + +export const calculateNewLineNumberMapping = (newMarkdownLines: string[], oldLineKeys: LineKeys[], lastUsedLineId: number): TextDifferenceResult => { + const lineDifferences = diffArrays(newMarkdownLines, oldLineKeys, { + comparator: (left:string|LineKeys, right:string|LineKeys) => { + const leftLine = (left as LineKeys).line ?? (left as string) + const rightLine = (right as LineKeys).line ?? (right as string) + return leftLine === rightLine + } + }) + + const newLines: LineKeys[] = [] + + lineDifferences + .filter((change) => change.added === undefined || !change.added) + .forEach((value) => { + if (value.removed) { + (value.value as string[]) + .forEach(line => { + lastUsedLineId += 1 + newLines.push({ line: line, id: lastUsedLineId }) + }) + } else { + (value.value as LineKeys[]) + .forEach((line) => newLines.push(line)) + } + }) + + return { lines: newLines, lastUsedLineId: lastUsedLineId } +} diff --git a/src/components/markdown-renderer/utils/use-post-meta-data-on-change.ts b/src/components/markdown-renderer/utils/use-post-meta-data-on-change.ts new file mode 100644 index 000000000..b7ad66f1d --- /dev/null +++ b/src/components/markdown-renderer/utils/use-post-meta-data-on-change.ts @@ -0,0 +1,29 @@ +import equal from 'fast-deep-equal' +import { useEffect, useRef } from 'react' +import { RawYAMLMetadata, YAMLMetaData } from '../../editor/yaml-metadata/yaml-metadata' + +export const usePostMetaDataOnChange = ( + rawMetaRef: RawYAMLMetadata|undefined, + firstHeadingRef: string|undefined, + onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void, + onFirstHeadingChange?: (firstHeading: string | undefined) => void +): void => { + const oldMetaRef = useRef() + const oldFirstHeadingRef = useRef() + + useEffect(() => { + if (onMetaDataChange && !equal(oldMetaRef.current, rawMetaRef)) { + if (rawMetaRef) { + const newMetaData = new YAMLMetaData(rawMetaRef) + onMetaDataChange(newMetaData) + } else { + onMetaDataChange(undefined) + } + oldMetaRef.current = rawMetaRef + } + if (onFirstHeadingChange && !equal(firstHeadingRef, oldFirstHeadingRef.current)) { + onFirstHeadingChange(firstHeadingRef || undefined) + oldFirstHeadingRef.current = firstHeadingRef + } + }) +} diff --git a/src/components/markdown-renderer/utils/use-post-toc-ast-on-change.ts b/src/components/markdown-renderer/utils/use-post-toc-ast-on-change.ts new file mode 100644 index 000000000..2fd494802 --- /dev/null +++ b/src/components/markdown-renderer/utils/use-post-toc-ast-on-change.ts @@ -0,0 +1,13 @@ +import equal from 'fast-deep-equal' +import { RefObject, useEffect, useRef } from 'react' +import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface' + +export const usePostTocAstOnChange = (tocAst: RefObject, onTocChange?: (ast: TocAst) => void): void => { + const lastTocAst = useRef() + useEffect(() => { + if (onTocChange && tocAst.current && !equal(tocAst, lastTocAst.current)) { + lastTocAst.current = tocAst.current + onTocChange(tocAst.current) + } + }, [onTocChange, tocAst]) +}