feat: update mermaid rendering flow (#5852)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user