Files
outline/shared/editor/extensions/Mermaid.ts
T
Tom Moor 28aebc9fbf feat: Upload remote-hosted images on paste (#8301)
* First pass

* fix

* tidy, tidy

* Determine dimensions

* docs

* test getFileNameFromUrl

* PR feedback

* tsc
2025-01-30 17:24:07 -08:00

397 lines
10 KiB
TypeScript

import debounce from "lodash/debounce";
import last from "lodash/last";
import sortBy from "lodash/sortBy";
import type MermaidUnsafe from "mermaid";
import { Node } from "prosemirror-model";
import {
Plugin,
PluginKey,
TextSelection,
Transaction,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 as uuidv4 } from "uuid";
import { isCode } from "../lib/isCode";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
import { NodeWithPos } from "../types";
type MermaidState = {
decorationSet: DecorationSet;
isDark: boolean;
};
class Cache {
static get(key: string) {
return this.data.get(key);
}
static set(key: string, value: string) {
this.data.set(key, value);
if (this.data.size > this.maxSize) {
this.data.delete(this.data.keys().next().value);
}
}
private static maxSize = 20;
private static data: Map<string, string> = new Map();
}
let mermaid: typeof MermaidUnsafe;
type RendererFunc = (
block: { node: Node; pos: number },
isDark: boolean
) => void;
class MermaidRenderer {
readonly diagramId: string;
readonly element: HTMLElement;
readonly elementId: string;
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");
}
renderImmediately = async (
block: { node: Node; pos: number },
isDark: boolean
) => {
const element = this.element;
const text = block.node.textContent;
const cacheKey = `${isDark ? "dark" : "light"}-${text}`;
const cache = Cache.get(cacheKey);
if (cache) {
element.classList.remove("parse-error", "empty");
element.innerHTML = cache;
return;
}
// Create a temporary element that will render the diagram off-screen. This is necessary
// as Mermaid will error if the element is not visible, such as if the heading is collapsed
const renderElement = document.createElement("div");
renderElement.style.position = "absolute";
renderElement.style.left = "-9999px";
renderElement.style.top = "-9999px";
document.body.appendChild(renderElement);
try {
mermaid ??= (await import("mermaid")).default;
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 },
pie: { useWidth: 700 },
fontFamily: "inherit",
theme: isDark ? "dark" : "default",
darkMode: isDark,
});
const { svg, bindFunctions } = await mermaid.render(
`mermaid-diagram-${this.diagramId}`,
text,
// If the element is not visible we use an off-screen element to render the diagram
element.offsetParent === null ? renderElement : element
);
this.currentTextContent = text;
// Cache the rendered SVG so we won't need to calculate it again in the same session
if (text) {
Cache.set(cacheKey, svg);
}
element.classList.remove("parse-error", "empty");
element.innerHTML = svg;
// Allow the user to interact with the diagram
bindFunctions?.(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;
element.classList.add("parse-error");
}
} finally {
renderElement.remove();
}
};
get render(): RendererFunc {
if (this._rendererFunc) {
return this._rendererFunc;
}
this._rendererFunc = debounce<RendererFunc>(this.renderImmediately, 250);
return this.renderImmediately;
}
private currentTextContent = "";
private _rendererFunc?: 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,
pluginState,
}: {
doc: Node;
name: string;
pluginState: MermaidState;
}): MermaidState {
const decorations: Decoration[] = [];
// Find all blocks that represent Mermaid diagrams
const blocks = findBlockNodes(doc).filter(
(item) =>
item.node.type.name === name && item.node.attrs.language === "mermaidjs"
);
blocks.forEach((block) => {
const existingDecorations = pluginState.decorationSet.find(
block.pos,
block.pos + block.node.nodeSize,
(spec) => !!spec.diagramId
);
const bestDecoration = findBestOverlapDecoration(
existingDecorations,
block
);
const renderer: MermaidRenderer =
bestDecoration?.spec?.renderer ?? new MermaidRenderer();
const diagramDecoration = Decoration.widget(
block.pos + block.node.nodeSize,
() => {
void renderer.render(block, pluginState.isDark);
return renderer.element;
},
{
diagramId: renderer.diagramId,
renderer,
side: -10,
}
);
const diagramIdDecoration = Decoration.node(
block.pos,
block.pos + block.node.nodeSize,
{},
{
diagramId: renderer.diagramId,
renderer,
}
);
decorations.push(diagramDecoration);
decorations.push(diagramIdDecoration);
});
return {
decorationSet: DecorationSet.create(doc, decorations),
isDark: pluginState.isDark,
};
}
export default function Mermaid({
name,
isDark,
}: {
name: string;
isDark: boolean;
}) {
return new Plugin({
key: new PluginKey("mermaid"),
state: {
init: (_, { doc }) => {
const pluginState: MermaidState = {
decorationSet: DecorationSet.create(doc, []),
isDark,
};
return getNewState({
doc,
name,
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 themeMeta = transaction.getMeta("theme");
const mermaidMeta = transaction.getMeta("mermaid");
const themeToggled = themeMeta?.isDark !== undefined;
if (themeToggled) {
pluginState.isDark = themeMeta.isDark;
}
if (
mermaidMeta ||
themeToggled ||
codeBlockChanged ||
isRemoteTransaction(transaction)
) {
return getNewState({
doc: transaction.doc,
name,
pluginState,
});
}
return {
decorationSet: pluginState.decorationSet.map(
transaction.mapping,
transaction.doc
),
isDark: pluginState.isDark,
};
},
},
view: (view) => {
view.dispatch(view.state.tr.setMeta("mermaid", { loaded: true }));
return {};
},
props: {
decorations(state) {
return this.getState(state)?.decorationSet;
},
handleDOMEvents: {
mousedown(view, event) {
const target = event.target as HTMLElement;
const diagram = target?.closest(".mermaid-diagram-wrapper");
const codeBlock = diagram?.previousElementSibling;
if (!codeBlock) {
return false;
}
const pos = view.posAtDOM(codeBlock, 0);
if (!pos) {
return false;
}
// select node
if (diagram && event.detail === 1) {
view.dispatch(
view.state.tr
.setSelection(TextSelection.near(view.state.doc.resolve(pos)))
.scrollIntoView()
);
return true;
}
return false;
},
keydown: (view, event) => {
switch (event.key) {
case "ArrowDown": {
const { selection } = view.state;
const $pos = view.state.doc.resolve(
Math.min(selection.from + 1, view.state.doc.nodeSize)
);
const nextBlock = $pos.nodeAfter;
if (
nextBlock &&
isCode(nextBlock) &&
nextBlock.attrs.language === "mermaidjs"
) {
view.dispatch(
view.state.tr
.setSelection(
TextSelection.near(
view.state.doc.resolve(selection.to + 1)
)
)
.scrollIntoView()
);
event.preventDefault();
return true;
}
return false;
}
case "ArrowUp": {
const { selection } = view.state;
const $pos = view.state.doc.resolve(
Math.max(0, selection.from - 1)
);
const prevBlock = $pos.nodeBefore;
if (
prevBlock &&
isCode(prevBlock) &&
prevBlock.attrs.language === "mermaidjs"
) {
view.dispatch(
view.state.tr
.setSelection(
TextSelection.near(
view.state.doc.resolve(selection.from - 2)
)
)
.scrollIntoView()
);
event.preventDefault();
return true;
}
return false;
}
}
return false;
},
},
},
});
}