217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
import { Node } from "prosemirror-model";
|
|
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
|
import { findBlockNodes } from "prosemirror-utils";
|
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
type MermaidState = {
|
|
decorationSet: DecorationSet;
|
|
diagramVisibility: Record<number, boolean>;
|
|
isDark: boolean;
|
|
};
|
|
|
|
function getNewState({
|
|
doc,
|
|
name,
|
|
pluginState,
|
|
}: {
|
|
doc: Node;
|
|
name: string;
|
|
pluginState: MermaidState;
|
|
}) {
|
|
const decorations: Decoration[] = [];
|
|
|
|
// Find all blocks that represent Mermaid diagrams
|
|
const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter(
|
|
(item) =>
|
|
item.node.type.name === name && item.node.attrs.language === "mermaidjs"
|
|
);
|
|
|
|
blocks.forEach((block) => {
|
|
const diagramDecorationPos = block.pos + block.node.nodeSize;
|
|
const existingDecorations = pluginState.decorationSet.find(
|
|
block.pos,
|
|
diagramDecorationPos
|
|
);
|
|
|
|
// Attempt to find the existing diagramId from the decoration, or assign
|
|
// a new one if none exists yet.
|
|
let diagramId = existingDecorations[0]?.spec["diagramId"];
|
|
if (diagramId === undefined) {
|
|
diagramId = uuidv4();
|
|
}
|
|
|
|
// Make the diagram visible by default if it contains source code
|
|
if (pluginState.diagramVisibility[diagramId] === undefined) {
|
|
pluginState.diagramVisibility[diagramId] = !!block.node.textContent;
|
|
}
|
|
|
|
const diagramDecoration = Decoration.widget(
|
|
block.pos + block.node.nodeSize,
|
|
() => {
|
|
const diagramWrapper = document.createElement("div");
|
|
diagramWrapper.classList.add("mermaid-diagram-wrapper");
|
|
|
|
if (pluginState.diagramVisibility[diagramId] === false) {
|
|
diagramWrapper.classList.add("diagram-hidden");
|
|
return diagramWrapper;
|
|
}
|
|
|
|
import("mermaid").then((module) => {
|
|
module.default.initialize({
|
|
startOnLoad: true,
|
|
flowchart: {
|
|
htmlLabels: false,
|
|
},
|
|
// TODO: Make dynamic based on the width of the editor or remove in
|
|
// the future if Mermaid is able to handle this automatically.
|
|
gantt: {
|
|
// @ts-expect-error types do not include this property.
|
|
useWidth: 700,
|
|
},
|
|
theme: pluginState.isDark ? "dark" : "default",
|
|
fontFamily: "inherit",
|
|
});
|
|
try {
|
|
module.default.render(
|
|
"mermaid-diagram-" + diagramId,
|
|
block.node.textContent,
|
|
(svgCode) => {
|
|
diagramWrapper.innerHTML = svgCode;
|
|
}
|
|
);
|
|
} catch (error) {
|
|
console.log(error);
|
|
const errorNode = document.getElementById(
|
|
"d" + "mermaid-diagram-" + diagramId
|
|
);
|
|
if (errorNode) {
|
|
diagramWrapper.appendChild(errorNode);
|
|
}
|
|
}
|
|
});
|
|
|
|
return diagramWrapper;
|
|
},
|
|
{
|
|
diagramId,
|
|
}
|
|
);
|
|
|
|
const attributes = { "data-diagram-id": "" + diagramId };
|
|
if (pluginState.diagramVisibility[diagramId] !== false) {
|
|
attributes["class"] = "code-hidden";
|
|
}
|
|
|
|
const diagramIdDecoration = Decoration.node(
|
|
block.pos,
|
|
block.pos + block.node.nodeSize,
|
|
attributes,
|
|
{
|
|
diagramId,
|
|
}
|
|
);
|
|
|
|
decorations.push(diagramDecoration);
|
|
decorations.push(diagramIdDecoration);
|
|
});
|
|
|
|
return {
|
|
decorationSet: DecorationSet.create(doc, decorations),
|
|
diagramVisibility: pluginState.diagramVisibility,
|
|
isDark: pluginState.isDark,
|
|
};
|
|
}
|
|
|
|
export default function Mermaid({
|
|
name,
|
|
isDark,
|
|
}: {
|
|
name: string;
|
|
isDark: boolean;
|
|
}) {
|
|
let diagramShown = false;
|
|
|
|
return new Plugin({
|
|
key: new PluginKey("mermaid"),
|
|
state: {
|
|
init: (_: Plugin, { doc }) => {
|
|
const pluginState: MermaidState = {
|
|
decorationSet: DecorationSet.create(doc, []),
|
|
diagramVisibility: {},
|
|
isDark,
|
|
};
|
|
return pluginState;
|
|
},
|
|
apply: (
|
|
transaction: Transaction,
|
|
pluginState: MermaidState,
|
|
oldState,
|
|
state
|
|
) => {
|
|
const nodeName = state.selection.$head.parent.type.name;
|
|
const previousNodeName = oldState.selection.$head.parent.type.name;
|
|
const codeBlockChanged =
|
|
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
|
const ySyncEdit = !!transaction.getMeta("y-sync$");
|
|
const mermaidMeta = transaction.getMeta("mermaid");
|
|
const themeMeta = transaction.getMeta("theme");
|
|
const diagramToggled = mermaidMeta?.toggleDiagram !== undefined;
|
|
const themeToggled = themeMeta?.isDark !== undefined;
|
|
|
|
if (themeToggled) {
|
|
pluginState.isDark = themeMeta.isDark;
|
|
}
|
|
|
|
if (diagramToggled) {
|
|
pluginState.diagramVisibility[
|
|
mermaidMeta.toggleDiagram
|
|
] = !pluginState.diagramVisibility[mermaidMeta.toggleDiagram];
|
|
}
|
|
|
|
if (
|
|
!diagramShown ||
|
|
themeToggled ||
|
|
codeBlockChanged ||
|
|
diagramToggled ||
|
|
ySyncEdit
|
|
) {
|
|
diagramShown = true;
|
|
return getNewState({
|
|
doc: transaction.doc,
|
|
name,
|
|
pluginState,
|
|
});
|
|
}
|
|
|
|
return {
|
|
decorationSet: pluginState.decorationSet.map(
|
|
transaction.mapping,
|
|
transaction.doc
|
|
),
|
|
diagramVisibility: pluginState.diagramVisibility,
|
|
isDark: pluginState.isDark,
|
|
};
|
|
},
|
|
},
|
|
view: (view) => {
|
|
if (!diagramShown) {
|
|
// we don't draw diagrams on code blocks on the first render as part of mounting
|
|
// as it's expensive (relative to the rest of the document). Instead let
|
|
// it render without a diagram and then trigger a defered render of Mermaid
|
|
// by updating the plugins metadata
|
|
setTimeout(() => {
|
|
view.dispatch(view.state.tr.setMeta("mermaid", { loaded: true }));
|
|
}, 10);
|
|
}
|
|
|
|
return {};
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
return this.getState(state).decorationSet;
|
|
},
|
|
},
|
|
});
|
|
}
|