diff --git a/shared/editor/extensions/Mermaid.ts b/shared/editor/extensions/Mermaid.ts index a0c960113..5638cefd6 100644 --- a/shared/editor/extensions/Mermaid.ts +++ b/shared/editor/extensions/Mermaid.ts @@ -1,3 +1,6 @@ +import debounce from "lodash/debounce"; +import last from "lodash/last"; +import sortBy from "lodash/sortBy"; import { Node } from "prosemirror-model"; import { Plugin, @@ -8,13 +11,106 @@ import { import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 as uuidv4 } from "uuid"; import { isCode } from "../lib/isCode"; -import { findBlockNodes } from "../queries/findChildren"; +import { findBlockNodes, NodeWithPos } from "../queries/findChildren"; type MermaidState = { decorationSet: DecorationSet; isDark: boolean; + initialized: boolean; }; +type RendererFunc = (block: { node: Node; pos: number }) => void; + +class MermaidRenderer { + readonly diagramId: string; + readonly element: HTMLElement; + readonly elementId: string; + private currentTextContent = ""; + private _rendererFunc?: RendererFunc; + + constructor() { + this.diagramId = uuidv4(); + this.elementId = "mermaid-diagram-wrapper-" + this.diagramId; + this.element = + document.getElementById(this.elementId) || document.createElement("div"); + this.element.id = this.elementId; + this.element.classList.add("mermaid-diagram-wrapper"); + } + + async renderImmediately(block: { node: Node; pos: number }) { + const diagramId = this.diagramId; + const element = this.element; + const newTextContent = block.node.textContent; + if (newTextContent === this.currentTextContent) { + return; + } + try { + const { default: mermaid } = await import("mermaid"); + void mermaid.render( + "mermaid-diagram-" + diagramId, + newTextContent, + (svgCode, bindFunctions) => { + this.currentTextContent = newTextContent; + element.classList.remove("parse-error", "empty"); + element.innerHTML = svgCode; + bindFunctions?.(element); + }, + element + ); + } catch (error) { + const isEmpty = block.node.textContent.trim().length === 0; + + if (isEmpty) { + element.innerText = "Empty diagram"; + element.classList.add("empty"); + } else { + element.innerText = `Error rendering diagram\n\n${error}`; + element.classList.add("parse-error"); + } + } + } + + get render(): RendererFunc { + if (this._rendererFunc) { + return this._rendererFunc; + } + this._rendererFunc = debounce(this.renderImmediately, 500); + return this._rendererFunc; + } +} + +function overlap( + start1: number, + end1: number, + start2: number, + end2: number +): number { + return Math.max(0, Math.min(end1, end2) - Math.max(start1, start2)); +} +/* + This code find the decoration that overlap the most with a given node. + This will ensure we can find the best decoration that match the last change set + See: https://github.com/outline/outline/pull/5852/files#r1334929120 +*/ +function findBestOverlapDecoration( + decorations: Decoration[], + block: NodeWithPos +): Decoration | undefined { + if (decorations.length === 0) { + return undefined; + } + return last( + sortBy(decorations, (decoration) => + overlap( + decoration.from, + decoration.to, + block.pos, + block.pos + block.node.nodeSize + ) + ) + ); +} + function getNewState({ doc, name, @@ -23,74 +119,58 @@ function getNewState({ doc: Node; name: string; pluginState: MermaidState; -}) { +}): MermaidState { const decorations: Decoration[] = []; // Find all blocks that represent Mermaid diagrams - const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter( + const blocks = findBlockNodes(doc).filter( (item) => item.node.type.name === name && item.node.attrs.language === "mermaidjs" ); + let { initialized } = pluginState; + if (blocks.length > 0 && !initialized) { + void import("mermaid").then(({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: true, + // TODO: Make dynamic based on the width of the editor or remove in + // the future if Mermaid is able to handle this automatically. + gantt: { + useWidth: 700, + }, + theme: pluginState.isDark ? "dark" : "default", + fontFamily: "inherit", + }); + }); + + initialized = true; + } + blocks.forEach((block) => { const existingDecorations = pluginState.decorationSet.find( block.pos, - block.pos + block.node.nodeSize + block.pos + block.node.nodeSize, + (spec) => !!spec.diagramId ); - // Attempt to find the existing diagramId from the decoration, or assign - // a new one if none exists yet. - const diagramId = existingDecorations[0]?.spec["diagramId"] ?? uuidv4(); + const bestDecoration = findBestOverlapDecoration( + existingDecorations, + block + ); + + const renderer: MermaidRenderer = + bestDecoration?.spec?.renderer ?? new MermaidRenderer(); const diagramDecoration = Decoration.widget( block.pos + block.node.nodeSize, () => { - const elementId = "mermaid-diagram-wrapper-" + diagramId; - const element = - document.getElementById(elementId) || document.createElement("div"); - element.id = elementId; - element.classList.add("mermaid-diagram-wrapper"); - - void import("mermaid").then((module) => { - module.default.initialize({ - startOnLoad: true, - // TODO: Make dynamic based on the width of the editor or remove in - // the future if Mermaid is able to handle this automatically. - gantt: { - useWidth: 700, - }, - theme: pluginState.isDark ? "dark" : "default", - fontFamily: "inherit", - }); - - try { - module.default.render( - "mermaid-diagram-" + diagramId, - block.node.textContent, - (svgCode, bindFunctions) => { - element.classList.remove("parse-error", "empty"); - element.innerHTML = svgCode; - bindFunctions?.(element); - }, - element - ); - } catch (error) { - const isEmpty = block.node.textContent.trim().length === 0; - - if (isEmpty) { - element.innerText = "Empty diagram"; - element.classList.add("empty"); - } else { - element.innerText = "Error rendering diagram"; - element.classList.add("parse-error"); - } - } - }); - + const element = renderer.element; + void renderer.render(block); return element; }, { - diagramId, + diagramId: renderer.diagramId, + renderer, } ); @@ -99,7 +179,8 @@ function getNewState({ block.pos + block.node.nodeSize, {}, { - diagramId, + diagramId: renderer.diagramId, + renderer, } ); @@ -110,6 +191,7 @@ function getNewState({ return { decorationSet: DecorationSet.create(doc, decorations), isDark: pluginState.isDark, + initialized, }; } @@ -127,6 +209,7 @@ export default function Mermaid({ const pluginState: MermaidState = { decorationSet: DecorationSet.create(doc, []), isDark, + initialized: false, }; return pluginState; },