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 { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer' import { CodimdLinemarkerReplacer } from './replace-components/codimd-linemarker/codimd-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 { 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 { 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 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 CodimdLinemarkerReplacer(), new PossibleWiderReplacer(), new GistReplacer(), new YoutubeReplacer(), new VimeoReplacer(), new AsciinemaReplacer(), new PdfReplacer(), new ImageReplacer(), new CsvReplacer(), new FlowchartReplacer(), 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 html: string = markdownIt.render(content) const contentLines = content.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]) return (
{markdownReactDom}
) }