modularize renderer (#552)

This commit is contained in:
mrdrogdrog 2020-09-08 21:49:42 +02:00 committed by GitHub
parent ac00bc98c0
commit a86d4cbc58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 580 additions and 455 deletions

View file

@ -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 { Table } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' 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 { HighlightedCode } from '../../../markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code'
import './cheatsheet.scss' import './cheatsheet.scss'
@ -27,6 +30,13 @@ export const Cheatsheet: React.FC = () => {
':smile:', ':smile:',
`:::info\n${t('editor.help.cheatsheet.exampleAlert')}\n:::` `:::info\n${t('editor.help.cheatsheet.exampleAlert')}\n:::`
] ]
const markdownItPlugins = useCallback((md: MarkdownIt) => {
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
}, [])
return ( return (
<Table className="table-condensed table-cheatsheet"> <Table className="table-condensed table-cheatsheet">
<thead> <thead>
@ -40,14 +50,10 @@ export const Cheatsheet: React.FC = () => {
return ( return (
<tr key={key}> <tr key={key}>
<td> <td>
<MarkdownRenderer <BasicMarkdownRenderer
content={code} content={code}
wide={false} wide={false}
onTaskCheckedChange={(_) => null} onConfigureMarkdownIt={markdownItPlugins}
onTocChange={() => false}
onMetaDataChange={() => false}
onFirstHeadingChange={() => false}
onLineMarkerPositionChanged={() => false}
/> />
</td> </td>
<td className={'markdown-body'}> <td className={'markdown-body'}>

View file

@ -6,7 +6,7 @@ import { Cheatsheet } from './cheatsheet'
import { Links } from './links' import { Links } from './links'
import { Shortcut } from './shortcuts' import { Shortcut } from './shortcuts'
enum HelpTabStatus { export enum HelpTabStatus {
Cheatsheet='cheatsheet.title', Cheatsheet='cheatsheet.title',
Shortcuts='shortcuts.title', Shortcuts='shortcuts.title',
Links='links.title' Links='links.title'

View file

@ -4,7 +4,8 @@ import useResizeObserver from 'use-resize-observer'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface' import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../common/show-if/show-if' 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 { ScrollProps, ScrollState } from '../scroll/scroll-props'
import { findLineMarks } from '../scroll/utils' import { findLineMarks } from '../scroll/utils'
import { TableOfContents } from '../table-of-contents/table-of-contents' import { TableOfContents } from '../table-of-contents/table-of-contents'
@ -108,16 +109,18 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps & ScrollProps>
<div className={'bg-light flex-fill pb-5 flex-row d-flex w-100 h-100 overflow-y-scroll'} <div className={'bg-light flex-fill pb-5 flex-row d-flex w-100 h-100 overflow-y-scroll'}
ref={renderer} onScroll={userScroll} onMouseEnter={onMakeScrollSource}> ref={renderer} onScroll={userScroll} onMouseEnter={onMakeScrollSource}>
<div className={'col-md'}/> <div className={'col-md'}/>
<MarkdownRenderer <div className={'bg-light flex-fill'}>
className={'flex-fill mb-3'} <FullMarkdownRenderer
content={content} className={'flex-fill mb-3'}
onFirstHeadingChange={onFirstHeadingChange} content={content}
onLineMarkerPositionChanged={setLineMarks} onFirstHeadingChange={onFirstHeadingChange}
onMetaDataChange={onMetadataChange} onLineMarkerPositionChanged={setLineMarks}
onTaskCheckedChange={onTaskCheckedChange} onMetaDataChange={onMetadataChange}
onTocChange={(tocAst) => setTocAst(tocAst)} onTaskCheckedChange={onTaskCheckedChange}
wide={wide} onTocChange={(tocAst) => setTocAst(tocAst)}
/> wide={wide}
/>
</div>
<div className={'col-md'}> <div className={'col-md'}>
<ShowIf condition={realWidth >= 1280 && !!tocAst}> <ShowIf condition={realWidth >= 1280 && !!tocAst}>

View file

@ -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 } => { export const findLineMarks = (lineMarks: LineMarkerPosition[], lineNumber: number): { lastMarkBefore: LineMarkerPosition | undefined, firstMarkAfter: LineMarkerPosition | undefined } => {
let lastMarkBefore let lastMarkBefore
let firstMarkAfter let firstMarkAfter

View file

@ -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<HTMLDivElement>
onBeforeRendering?: () => void
}
export const BasicMarkdownRenderer: React.FC<BasicMarkdownRendererProps & AdditionalMarkdownRendererProps> = ({
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<LineKeys[]>()
const lastUsedLineId = useRef<number>(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 (
<div className={`${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`}>
<ShowIf condition={content.length > maxLength}>
<Alert variant='danger' dir={'auto'}>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }}/>
</Alert>
</ShowIf>
<div ref={documentReference} className={'markdown-body w-100 d-flex flex-column align-items-center'}>
{markdownReactDom}
</div>
</div>
)
}

View file

@ -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<FullMarkdownRendererProps & AdditionalMarkdownRendererProps> = ({
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<RawYAMLMetadata>()
const firstHeadingRef = useRef<string>()
const documentElement = useRef<HTMLDivElement>(null)
const currentLineMarkers = useRef<LineMarkers[]>()
usePostMetaDataOnChange(rawMetaRef.current, firstHeadingRef.current, onMetaDataChange, onFirstHeadingChange)
useCalculateLineMarkerPosition(documentElement, currentLineMarkers.current, onLineMarkerPositionChanged, documentElement.current?.offsetTop ?? 0)
const tocAst = useRef<TocAst>()
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: '<i class="fa fa-link"></i>'
})
md.use(mathJax({
beforeMath: '<app-katex>',
afterMath: '</app-katex>',
beforeInlineMath: '<app-katex inline>',
afterInlineMath: '</app-katex>',
beforeDisplayMath: '<app-katex>',
afterDisplayMath: '</app-katex>'
}))
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 (
<div className={'position-relative'}>
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
</Trans>
</Alert>
</ShowIf>
<BasicMarkdownRenderer className={className} wide={wide} content={content} componentReplacers={allReplacers}
onConfigureMarkdownIt={configureMarkdownIt} documentReference={documentElement}
onBeforeRendering={clearMetadata}/>
</div>
)
}

View file

@ -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] = `<i class="fa fa-${icon}"></i>`
return reduceObject
}, {} as { [key: string]: string })
export const combinedEmojiData = {
...markdownItTwitterEmojis,
...emojiSkinToneModifierMap,
...forkAwesomeIconMap
}

View file

@ -0,0 +1,26 @@
import MarkdownIt from 'markdown-it/lib'
export interface FirstHeaderExtractorOptions {
firstHeaderFound: (firstHeader: string|undefined) => void
}
export const firstHeaderExtractor: () => MarkdownIt.PluginWithOptions<FirstHeaderExtractorOptions> = () => {
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
})
}
}

View file

@ -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] = `<i class="fa fa-${icon}"></i>`
return reduceObject
}, {} as { [key: string]: string })
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
className,
content,
onFirstHeadingChange,
onLineMarkerPositionChanged,
onMetaDataChange,
onTaskCheckedChange,
onTocChange,
wide
}) => {
const [tocAst, setTocAst] = useState<TocAst>()
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
const lastTocAst = useRef<TocAst>()
const [yamlError, setYamlError] = useState(false)
const rawMetaRef = useRef<RawYAMLMetadata>()
const oldMetaRef = useRef<RawYAMLMetadata>()
const firstHeadingRef = useRef<string>()
const oldFirstHeadingRef = useRef<string>()
const documentElement = useRef<HTMLDivElement>(null)
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
const currentLineMarkers = useRef<LineMarkers[]>()
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: '<i class="fa fa-link"></i>'
})
md.use(mathJax({
beforeMath: '<app-katex>',
afterMath: '</app-katex>',
beforeInlineMath: '<app-katex inline>',
afterInlineMath: '</app-katex>',
beforeDisplayMath: '<app-katex>',
afterDisplayMath: '</app-katex>'
}))
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<LineKeys[]>()
const lastUsedLineId = useRef<number>(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 (
<div className={'bg-light flex-fill'}>
<div className={`${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`} >
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
</Trans>
</Alert>
</ShowIf>
<ShowIf condition={content.length > maxLength}>
<Alert variant='danger' dir={'auto'}>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }}/>
</Alert>
</ShowIf>
<div ref={documentElement} className={'markdown-body w-100 d-flex flex-column align-items-center'}>
{markdownReactDom}
</div>
</div>
</div>
)
}

View file

@ -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,
}

View file

@ -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<HTMLDivElement>, lineMarkers?: LineMarkers[], onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void, offset?: number) : void => {
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
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()
})
}

View file

@ -1,51 +1,17 @@
import { diffArrays } from 'diff'
import { DomElement } from 'domhandler' import { DomElement } from 'domhandler'
import React, { Fragment, ReactElement } from 'react' import React, { Fragment, ReactElement } from 'react'
import { convertNodeToElement, Transform } from 'react-html-parser' import { convertNodeToElement, Transform } from 'react-html-parser'
import { import {
ComponentReplacer, ComponentReplacer,
SubNodeTransform SubNodeTransform
} from './replace-components/ComponentReplacer' } from '../replace-components/ComponentReplacer'
import { LineKeys } from '../types'
export interface TextDifferenceResult { export interface TextDifferenceResult {
lines: LineKeys[], lines: LineKeys[],
lastUsedLineId: number lastUsedLineId: number
} }
export interface LineKeys {
line: string,
id: number
}
export const calculateNewLineNumberMapping = (newMarkdownLines: string[], oldLineKeys: LineKeys[], lastUsedLineId: number): TextDifferenceResult => {
const lineDifferences = diffArrays<string, LineKeys>(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 => { export const calculateKeyFromLineMarker = (node: DomElement, lineKeys?: LineKeys[]): number|undefined => {
if (!node.attribs || lineKeys === undefined) { if (!node.attribs || lineKeys === undefined) {
return return

View file

@ -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<string, LineKeys>(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 }
}

View file

@ -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<RawYAMLMetadata>()
const oldFirstHeadingRef = useRef<string>()
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
}
})
}

View file

@ -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<TocAst|undefined>, onTocChange?: (ast: TocAst) => void): void => {
const lastTocAst = useRef<TocAst>()
useEffect(() => {
if (onTocChange && tocAst.current && !equal(tocAst, lastTocAst.current)) {
lastTocAst.current = tocAst.current
onTocChange(tocAst.current)
}
}, [onTocChange, tocAst])
}