feat: update mermaid rendering flow (#5852)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
antran22
2023-09-26 04:03:25 +07:00
committed by GitHub
parent 25b961b3b8
commit 1639c657c8

View File

@@ -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 { Node } from "prosemirror-model";
import { import {
Plugin, Plugin,
@@ -8,13 +11,106 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { isCode } from "../lib/isCode"; import { isCode } from "../lib/isCode";
import { findBlockNodes } from "../queries/findChildren"; import { findBlockNodes, NodeWithPos } from "../queries/findChildren";
type MermaidState = { type MermaidState = {
decorationSet: DecorationSet; decorationSet: DecorationSet;
isDark: boolean; 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<RendererFunc>(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({ function getNewState({
doc, doc,
name, name,
@@ -23,74 +119,58 @@ function getNewState({
doc: Node; doc: Node;
name: string; name: string;
pluginState: MermaidState; pluginState: MermaidState;
}) { }): MermaidState {
const decorations: Decoration[] = []; const decorations: Decoration[] = [];
// Find all blocks that represent Mermaid diagrams // Find all blocks that represent Mermaid diagrams
const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter( const blocks = findBlockNodes(doc).filter(
(item) => (item) =>
item.node.type.name === name && item.node.attrs.language === "mermaidjs" 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) => { blocks.forEach((block) => {
const existingDecorations = pluginState.decorationSet.find( const existingDecorations = pluginState.decorationSet.find(
block.pos, 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 const bestDecoration = findBestOverlapDecoration(
// a new one if none exists yet. existingDecorations,
const diagramId = existingDecorations[0]?.spec["diagramId"] ?? uuidv4(); block
);
const renderer: MermaidRenderer =
bestDecoration?.spec?.renderer ?? new MermaidRenderer();
const diagramDecoration = Decoration.widget( const diagramDecoration = Decoration.widget(
block.pos + block.node.nodeSize, block.pos + block.node.nodeSize,
() => { () => {
const elementId = "mermaid-diagram-wrapper-" + diagramId; const element = renderer.element;
const element = void renderer.render(block);
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");
}
}
});
return element; return element;
}, },
{ {
diagramId, diagramId: renderer.diagramId,
renderer,
} }
); );
@@ -99,7 +179,8 @@ function getNewState({
block.pos + block.node.nodeSize, block.pos + block.node.nodeSize,
{}, {},
{ {
diagramId, diagramId: renderer.diagramId,
renderer,
} }
); );
@@ -110,6 +191,7 @@ function getNewState({
return { return {
decorationSet: DecorationSet.create(doc, decorations), decorationSet: DecorationSet.create(doc, decorations),
isDark: pluginState.isDark, isDark: pluginState.isDark,
initialized,
}; };
} }
@@ -127,6 +209,7 @@ export default function Mermaid({
const pluginState: MermaidState = { const pluginState: MermaidState = {
decorationSet: DecorationSet.create(doc, []), decorationSet: DecorationSet.create(doc, []),
isDark, isDark,
initialized: false,
}; };
return pluginState; return pluginState;
}, },