mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-22 17:56:30 -05:00
modularize renderer (#552)
This commit is contained in:
parent
ac00bc98c0
commit
a86d4cbc58
15 changed files with 580 additions and 455 deletions
|
@ -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'}>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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
|
||||||
|
|
106
src/components/markdown-renderer/basic-markdown-renderer.tsx
Normal file
106
src/components/markdown-renderer/basic-markdown-renderer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
227
src/components/markdown-renderer/full-markdown-renderer.tsx
Normal file
227
src/components/markdown-renderer/full-markdown-renderer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
15
src/components/markdown-renderer/types.d.ts
vendored
Normal file
15
src/components/markdown-renderer/types.d.ts
vendored
Normal 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,
|
||||||
|
}
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -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])
|
||||||
|
}
|
Loading…
Reference in a new issue