diff --git a/package-lock.json b/package-lock.json index 6d5dde65fc..58ca4d27e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33825,6 +33825,40 @@ "errno": "~0.1.7" } }, + "worker-loader": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz", + "integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, "worker-rpc": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz", diff --git a/package.json b/package.json index ff99534998..da02030328 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,8 @@ "uuid-parse": "^1.1.0", "webpack": "^4.44.2", "webpack-cli": "^3.3.9", - "webpack-dev-server": "^3.11.0" + "webpack-dev-server": "^3.11.0", + "worker-loader": "^3.0.8" }, "dependencies": { "@dnd-kit/core": "^5.0.1", diff --git a/src/cloud/components/MarkdownView/index.tsx b/src/cloud/components/MarkdownView/index.tsx index 02da801c1f..67c890cb59 100644 --- a/src/cloud/components/MarkdownView/index.tsx +++ b/src/cloud/components/MarkdownView/index.tsx @@ -22,8 +22,6 @@ import { FlowchartWarningBlock, } from '../../lib/charts' import MarkdownCheckbox from './MarkdownCheckbox' -import { mergeDeepRight } from 'ramda' -import gh from 'hast-util-sanitize/lib/github.json' import { shortcodeRehypeHandler } from '../../lib/shortcode' import Shortcode from './Shortcode' import LinkableHeader from './LinkableHeader' @@ -51,6 +49,8 @@ import LoaderDocEditor from '../../../design/components/atoms/loaders/LoaderDocE import { lngKeys } from '../../lib/i18n/types' import { DialogIconTypes, useDialog } from '../../../design/lib/stores/dialog' import { useI18n } from '../../lib/hooks/useI18n' +import { schema } from './schema' +import MarkdownRenderWorker from 'worker-loader!./markdownRender.worker' const remarkAdmonitionOptions = { tag: ':::', @@ -58,36 +58,7 @@ const remarkAdmonitionOptions = { infima: false, } -export const schema = mergeDeepRight(gh, { - attributes: { - '*': [ - ...gh.attributes['*'], - 'className', - 'align', - 'data-line', - 'data-offset', - 'data-inline-comment', - ], - input: [...gh.attributes['input'], 'checked'], - pre: ['dataRaw'], - shortcode: ['entityId', 'identifier'], - iframe: ['src'], - path: ['d'], - svg: ['viewBox'], - }, - tagNames: [ - ...gh.tagNames, - 'svg', - 'path', - 'mermaid', - 'flowchart', - 'chart', - 'chart(yaml)', - 'shortcode', - 'iframe', - 'gutter', - ], -}) +export { schema } type MarkdownViewState = | { type: 'loading' } @@ -128,6 +99,97 @@ interface MarkdownViewProps { showLinkOpenWarning?: boolean } +interface MarkdownWorkerResponse { + type: 'rendered' | 'error' + id: number + tree?: any + error?: string +} + +interface RenderConfig { + mainThreadProcessor: any + reactProcessor: any + getEmbed?: MarkdownViewProps['getEmbed'] +} + +const docEmbedPattern = + /\[\[\s*(?:boostnote|boosthub)\.doc\b[^\]]*\bid=(?:"([^"]+)"|'([^']+)'|([^\s\]]+))/g + +async function resolveEmbedDocs( + content: string, + getEmbed: MarkdownViewProps['getEmbed'] +) { + if (getEmbed == null || !content.includes('.doc')) { + return {} + } + + const ids = new Set() + let match = docEmbedPattern.exec(content) + while (match != null) { + const id = match[1] || match[2] || match[3] + if (id != null) { + ids.add(id) + } + match = docEmbedPattern.exec(content) + } + docEmbedPattern.lastIndex = 0 + + const embeds: { [id: string]: EmbedDoc | undefined } = {} + await Promise.all( + Array.from(ids).map(async (id) => { + embeds[id] = await getEmbed(id) + }) + ) + return embeds +} + +function createMarkdownWorker() { + if (typeof Worker === 'undefined') { + return null + } + + try { + return new MarkdownRenderWorker() + } catch { + return null + } +} + +function renderMarkdownInWorker( + worker: Worker, + id: number, + content: string, + embeds: { [id: string]: EmbedDoc | undefined } +) { + return new Promise((resolve, reject) => { + const onMessage = ({ data }: MessageEvent) => { + const response = data as MarkdownWorkerResponse + if (response.id !== id) { + return + } + + cleanup() + if (response.type === 'error') { + reject(new Error(response.error || 'Markdown worker failed')) + } else { + resolve(response.tree) + } + } + const onError = (event: ErrorEvent) => { + cleanup() + reject(event.error || new Error(event.message)) + } + function cleanup() { + worker.removeEventListener('message', onMessage) + worker.removeEventListener('error', onError) + } + + worker.addEventListener('message', onMessage) + worker.addEventListener('error', onError) + worker.postMessage({ type: 'render', id, content, embeds }) + }) +} + const MarkdownView = ({ content, updateContent, @@ -187,7 +249,7 @@ const MarkdownView = ({ [messageBox, showLinkOpenWarning, translate] ) - const markdownProcessor = useMemo(() => { + const renderConfig = useMemo(() => { const linkableHeader = (as: string) => (props: any) => { return props.id !== 'user-content-' ? ( @@ -336,7 +398,7 @@ const MarkdownView = ({ rehypeReactConfig.components.h6 = linkableHeader('h6') } - const parser = unified() + const mainThreadProcessor = unified() .use(remarkParse) .use(remarkShortcodes) .use(remarkDocEmbed, { @@ -364,7 +426,16 @@ const MarkdownView = ({ .use(rehypePosition) .use(rehypeReact, rehypeReactConfig) - return parser + const reactProcessor = unified() + .use(rehypeCodeMirror, { + ignoreMissing: true, + theme: codeBlockTheme, + }) + .use(rehypeMermaid) + .use(rehypePosition) + .use(rehypeReact, rehypeReactConfig) + + return { mainThreadProcessor, reactProcessor, getEmbed } }, [ shortcodeHandler, codeFence, @@ -378,42 +449,57 @@ const MarkdownView = ({ showLinkOpenWarning, ]) - const processorRef = useRef(markdownProcessor) + const renderConfigRef = useRef(renderConfig) + const renderWorkerRef = useRef(null) + const renderRequestIdRef = useRef(0) const renderContentRef = useRef( throttle( async (content: string) => { + const requestId = ++renderRequestIdRef.current try { checkboxIndexRef.current = 0 - const result = (await processorRef.current.process(content)) as any + const { reactProcessor, getEmbed } = renderConfigRef.current + if (renderWorkerRef.current == null) { + renderWorkerRef.current = createMarkdownWorker() + } + if (renderWorkerRef.current == null) { + throw new Error('Markdown worker is unavailable') + } + + const embeds = await resolveEmbedDocs(content, getEmbed) + const tree = await renderMarkdownInWorker( + renderWorkerRef.current, + requestId, + content, + embeds + ) + if (requestId !== renderRequestIdRef.current) { + return + } + + const renderedTree = await reactProcessor.run(tree) + const result = reactProcessor.stringify(renderedTree) setState({ type: 'loaded', - content: result.result, - }) - document.querySelectorAll('.collapse-trigger').forEach((trigger) => { - trigger.addEventListener('click', triggerCollapse) + content: result as React.ReactNode, }) - document - .querySelectorAll('.doc-embed-header a') - .forEach((docEmbedLink) => { - docEmbedLink.addEventListener('click', (event) => { - if ( - ((event as MouseEvent).ctrlKey || - (event as MouseEvent).metaKey) && - !usingElectron - ) { - return - } - - event.preventDefault() - push((event.target as HTMLAnchorElement).href) - }) + runAfterRenderCallbacks(push, onRenderRef) + } catch { + try { + const { mainThreadProcessor } = renderConfigRef.current + const result = (await mainThreadProcessor.process(content)) as any + if (requestId !== renderRequestIdRef.current) { + return + } + setState({ + type: 'loaded', + content: result.result, }) - if (onRenderRef.current != null) { - onRenderRef.current() + runAfterRenderCallbacks(push, onRenderRef) + } catch (fallbackErr) { + setState({ type: 'error', err: fallbackErr as any }) } - } catch (err) { - setState({ type: 'error', err: err as any }) } }, 100, @@ -422,10 +508,16 @@ const MarkdownView = ({ ) useEffect(() => { - processorRef.current = markdownProcessor + renderConfigRef.current = renderConfig modeLoadCallbackRef.current = () => renderContentRef.current(content) renderContentRef.current(content) - }, [content, markdownProcessor]) + }, [content, renderConfig]) + + useEffectOnce(() => { + return () => { + renderWorkerRef.current?.terminate() + } + }) useEffectOnce(() => { const callback = () => modeLoadCallbackRef.current?.call(null) @@ -540,6 +632,31 @@ function triggerCollapse(event: Event) { } } +function runAfterRenderCallbacks( + push: (path: string) => void, + onRenderRef: React.MutableRefObject<(() => any) | undefined> +) { + document.querySelectorAll('.collapse-trigger').forEach((trigger) => { + trigger.addEventListener('click', triggerCollapse) + }) + document.querySelectorAll('.doc-embed-header a').forEach((docEmbedLink) => { + docEmbedLink.addEventListener('click', (event) => { + if ( + ((event as MouseEvent).ctrlKey || (event as MouseEvent).metaKey) && + !usingElectron + ) { + return + } + + event.preventDefault() + push((event.currentTarget as HTMLAnchorElement).href) + }) + }) + if (onRenderRef.current != null) { + onRenderRef.current() + } +} + function getSelectionContext( selection: Selection ): SelectionState['context'] | null { diff --git a/src/cloud/components/MarkdownView/markdownRender.worker.ts b/src/cloud/components/MarkdownView/markdownRender.worker.ts new file mode 100644 index 0000000000..e87d995deb --- /dev/null +++ b/src/cloud/components/MarkdownView/markdownRender.worker.ts @@ -0,0 +1,122 @@ +import unified from 'unified' +import remarkParse from 'remark-parse' +import remarkShortcodes from 'remark-shortcodes' +import remarkMath from 'remark-math' +import remarkRehype from 'remark-rehype' +import rehypeKatex from 'rehype-katex' +import remarkAdmonitions from 'remark-admonitions' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import rehypeSlug from 'rehype-slug' +import visit from 'unist-util-visit' +import { Node } from 'unist' +import { shortcodeRehypeHandler } from '../../lib/shortcode' +import remarkDocEmbed, { EmbedDoc } from '../../lib/docEmbedPlugin' +import { remarkPlantUML } from '../../lib/charts/plantuml' +import { schema } from './schema' + +const remarkAdmonitionOptions = { + tag: ':::', + icons: 'emoji', + infima: false, +} + +const chartLanguages = [ + 'flowchart', + 'mermaid', + 'sequence', + 'chart', + 'chart(yaml)', +] + +interface RenderMarkdownRequest { + type: 'render' + id: number + content: string + embeds: { [id: string]: EmbedDoc | undefined } +} + +interface RenderMarkdownSuccess { + type: 'rendered' + id: number + tree: Node +} + +interface RenderMarkdownFailure { + type: 'error' + id: number + error: string +} + +type RenderMarkdownResponse = RenderMarkdownSuccess | RenderMarkdownFailure + +const ctx: Worker = self as any + +ctx.addEventListener('message', async ({ data }: MessageEvent) => { + const request = data as RenderMarkdownRequest + if (request.type !== 'render') { + return + } + + try { + const processor = createMarkdownProcessor(request.embeds) + const tree = processor.parse(request.content) + const processedTree = await processor.run(tree) + postResponse({ type: 'rendered', id: request.id, tree: processedTree }) + } catch (err) { + postResponse({ + type: 'error', + id: request.id, + error: err instanceof Error ? err.message : `${err}`, + }) + } +}) + +function createMarkdownProcessor(embeds: RenderMarkdownRequest['embeds']) { + return unified() + .use(remarkParse) + .use(remarkShortcodes) + .use(remarkDocEmbed, { + getEmbed: (id: string) => embeds[id], + }) + .use(remarkAdmonitions, remarkAdmonitionOptions) + .use(remarkMath) + .use(remarkPlantUML, { server: 'http://www.plantuml.com/plantuml' }) + .use(remarkCharts) + .use(remarkRehype, { + allowDangerousHtml: true, + handlers: { + shortcode: shortcodeRehypeHandler, + }, + }) + .use(rehypeRaw) + .use(rehypeSlug) + .use(rehypeSanitize, schema) + .use(rehypeKatex) +} + +function remarkCharts() { + return (tree: Node) => { + visit(tree, 'code', (node: any) => { + if ( + typeof node.lang !== 'string' || + !chartLanguages.includes(node.lang) + ) { + return + } + + node.type = node.lang + node.data = { + hName: node.lang, + hChildren: [{ type: 'text', value: node.value }], + hProperties: { + className: [node.lang], + }, + } + }) + } +} + +function postResponse(response: RenderMarkdownResponse) { + ctx.postMessage(response) +} diff --git a/src/cloud/components/MarkdownView/schema.ts b/src/cloud/components/MarkdownView/schema.ts new file mode 100644 index 0000000000..4a3d564b26 --- /dev/null +++ b/src/cloud/components/MarkdownView/schema.ts @@ -0,0 +1,33 @@ +import { mergeDeepRight } from 'ramda' +import gh from 'hast-util-sanitize/lib/github.json' + +export const schema = mergeDeepRight(gh, { + attributes: { + '*': [ + ...gh.attributes['*'], + 'className', + 'align', + 'data-line', + 'data-offset', + 'data-inline-comment', + ], + input: [...gh.attributes['input'], 'checked'], + pre: ['dataRaw'], + shortcode: ['entityId', 'identifier'], + iframe: ['src'], + path: ['d'], + svg: ['viewBox'], + }, + tagNames: [ + ...gh.tagNames, + 'svg', + 'path', + 'mermaid', + 'flowchart', + 'chart', + 'chart(yaml)', + 'shortcode', + 'iframe', + 'gutter', + ], +}) diff --git a/typings/worker-loader.d.ts b/typings/worker-loader.d.ts new file mode 100644 index 0000000000..50267f5054 --- /dev/null +++ b/typings/worker-loader.d.ts @@ -0,0 +1,7 @@ +declare module 'worker-loader!*' { + class WebpackWorker extends Worker { + constructor() + } + + export default WebpackWorker +}