5257dfbaab
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
7.0 KiB
TypeScript
207 lines
7.0 KiB
TypeScript
/** Shared constants and helpers for mermaid diagram rendering. */
|
|
|
|
export const DEFAULT_SCALE = 0.75;
|
|
export const SCALE_STEP = 0.1;
|
|
export const MIN_SCALE = 0.1;
|
|
export const MAX_SCALE = 1.5;
|
|
|
|
export const MERMAID_ERROR_EVENT = "mermaid:render-error";
|
|
|
|
export function cleanupMermaidOrphans(id: string): void {
|
|
document.getElementById(id)?.remove();
|
|
document.getElementById(`d${id}`)?.remove();
|
|
}
|
|
|
|
type MermaidFitScaleInput = {
|
|
viewportWidth: number;
|
|
svgWidth: number;
|
|
};
|
|
|
|
function clampScale(scale: number): number {
|
|
return Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
|
|
}
|
|
|
|
export function calculateMermaidFitScale({
|
|
viewportWidth,
|
|
svgWidth,
|
|
}: MermaidFitScaleInput): number {
|
|
if (viewportWidth <= 0 || svgWidth <= 0) return DEFAULT_SCALE;
|
|
const fitScale = Math.min(DEFAULT_SCALE, viewportWidth / svgWidth);
|
|
return clampScale(fitScale);
|
|
}
|
|
|
|
export function getElementContentWidth(element: HTMLElement): number {
|
|
const style = window.getComputedStyle(element);
|
|
const padding =
|
|
Number.parseFloat(style.paddingLeft || "0") + Number.parseFloat(style.paddingRight || "0");
|
|
return Math.max(0, element.clientWidth - padding);
|
|
}
|
|
|
|
export function emitMermaidRenderError(message: string, taskId?: string | null): void {
|
|
document.dispatchEvent(new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message, taskId } }));
|
|
}
|
|
|
|
export function reportMermaidRenderFailure(
|
|
error: Error,
|
|
originalCode: string,
|
|
normalizedCode: string,
|
|
): void {
|
|
// i18n-exempt: console diagnostics for the developer, never shown in the UI.
|
|
const normalizedSection =
|
|
normalizedCode === originalCode ? "" : `\n\nNormalized diagram:\n${normalizedCode}`;
|
|
|
|
console.error(
|
|
`[mermaid] Failed to render diagram\nError: ${error.message}\n\nOriginal diagram:\n${originalCode}${normalizedSection}`,
|
|
);
|
|
}
|
|
|
|
/** Read intrinsic width/height from an SVG element's viewBox or attributes. */
|
|
export function getSvgDimensions(container: HTMLElement): { w: number; h: number } | null {
|
|
const svg = container.querySelector("svg");
|
|
if (!svg) return null;
|
|
const vb = svg.getAttribute("viewBox");
|
|
if (vb) {
|
|
const parts = vb.split(/[\s,]+/).map(Number);
|
|
if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
|
|
return { w: parts[2], h: parts[3] };
|
|
}
|
|
}
|
|
const w = parseFloat(svg.getAttribute("width") ?? "");
|
|
const h = parseFloat(svg.getAttribute("height") ?? "");
|
|
if (w > 0 && h > 0) return { w, h };
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Characters that require quoting in mermaid node/edge labels.
|
|
* Includes: $, #, &, /, and other special chars that cause lexical errors.
|
|
*/
|
|
const SPECIAL_CHARS_RE = /[$#&/\\<>{}]/;
|
|
|
|
/**
|
|
* Bracket-pass trigger: parens are mermaid grammar metachars (stadium-shape
|
|
* opener) and break parsing when they appear inside `[...]` labels, so they
|
|
* force quoting in bracket labels even though they're legal in standalone
|
|
* `(...)` stadium nodes.
|
|
*/
|
|
const BRACKET_TRIGGER_RE = /[$#&/\\<>{}()]/;
|
|
|
|
const SEQUENCE_DIAGRAM_RE = /^\s*sequenceDiagram\b/;
|
|
const SEQUENCE_MESSAGE_RE = /^(\s*.+?(?:<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->).+?:)(.*)$/;
|
|
const ENTITY_ESCAPE_RE = /^(?:#\d+|&[A-Za-z][A-Za-z0-9]+);/;
|
|
const SEQUENCE_STATEMENT_RE =
|
|
/^\s*(?:[A-Za-z_][\w-]*\s*(?:<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->)|(?:participant|actor)\s+[A-Za-z_][\w-]*(?:\s+as\s+[^;\r\n]+)?(?=\s*(?:;|$))|(?:activate|deactivate|destroy)\s+[A-Za-z_][\w-]*(?=\s*(?:;|$))|create\s+(?:participant|actor)\s+[A-Za-z_][\w-]*(?:\s+as\s+[^;\r\n]+)?(?=\s*(?:;|$))|autonumber(?:\s+\d+(?:\s+\d+)?)?(?=\s*(?:;|$))|end(?=\s*(?:;|$)))/;
|
|
|
|
function escapeMessageSemicolons(message: string): string {
|
|
let result = "";
|
|
let index = 0;
|
|
|
|
while (index < message.length) {
|
|
const entity = message.slice(index).match(ENTITY_ESCAPE_RE);
|
|
if (entity) {
|
|
result += entity[0];
|
|
index += entity[0].length;
|
|
continue;
|
|
}
|
|
|
|
if (message[index] === ";") {
|
|
result += SEQUENCE_STATEMENT_RE.test(message.slice(index + 1)) ? ";" : "#59;";
|
|
} else {
|
|
result += message[index];
|
|
}
|
|
index++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function escapeSequenceMessageSemicolons(code: string): string {
|
|
let isSequenceDiagram = false;
|
|
|
|
return code
|
|
.split(/(\r?\n)/)
|
|
.map((part) => {
|
|
if (SEQUENCE_DIAGRAM_RE.test(part)) isSequenceDiagram = true;
|
|
if (!isSequenceDiagram) return part;
|
|
return part.replace(SEQUENCE_MESSAGE_RE, (_match, prefix, message) => {
|
|
return `${prefix}${escapeMessageSemicolons(message)}`;
|
|
});
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
/**
|
|
* Index ranges of every top-level `"..."` span (start = opening `"`, end = closing `"`).
|
|
* Scan stops at newline so an unterminated `"` on one line cannot pair with a
|
|
* `"` on a later line and silently suppress sanitization of unrelated content.
|
|
*/
|
|
function findQuotedRanges(s: string): Array<[number, number]> {
|
|
const out: Array<[number, number]> = [];
|
|
let i = 0;
|
|
while (i < s.length) {
|
|
if (s[i] === '"' && s[i - 1] !== "\\") {
|
|
const start = i;
|
|
i++;
|
|
while (i < s.length && s[i] !== "\n" && !(s[i] === '"' && s[i - 1] !== "\\")) i++;
|
|
if (i < s.length && s[i] === '"') out.push([start, i]);
|
|
}
|
|
i++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const inAnyRange = (offset: number, ranges: Array<[number, number]>): boolean =>
|
|
ranges.some(([a, b]) => offset >= a && offset <= b);
|
|
|
|
/**
|
|
* Preprocesses mermaid code to quote text containing special characters.
|
|
* Mermaid requires quotes around text with special chars like $, /, etc.
|
|
*
|
|
* Handles:
|
|
* - Node labels: A[text] -> A["text"] if text contains special chars or parens
|
|
* - Edge labels: -->|text| -> -->|"text"| if text contains special chars
|
|
* - Stadium nodes: (text) -> ("text") if text contains special chars
|
|
*
|
|
* Skips any region already inside a user-supplied `"..."` label so we never
|
|
* inject nested quotes into pre-quoted content.
|
|
*/
|
|
export function sanitizeMermaidCode(code: string): string {
|
|
code = escapeSequenceMessageSemicolons(code);
|
|
|
|
// Quote node labels: [text] or [[text]] etc. Match brackets that aren't already quoted.
|
|
let quoted = findQuotedRanges(code);
|
|
let result = code.replace(/(\[+)([^\]\n"]+?)(\]+)/g, (match, open, text, close, offset) => {
|
|
if (inAnyRange(offset, quoted)) return match;
|
|
if (BRACKET_TRIGGER_RE.test(text)) {
|
|
return `${open}"${text}"${close}`;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
// Quote edge labels: |text|
|
|
quoted = findQuotedRanges(result);
|
|
result = result.replace(/\|([^|\n"]+?)\|/g, (match, text, offset) => {
|
|
if (inAnyRange(offset, quoted)) return match;
|
|
if (SPECIAL_CHARS_RE.test(text)) {
|
|
return `|"${text}"|`;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
// Quote parentheses labels: (text) for stadium/circle nodes
|
|
quoted = findQuotedRanges(result);
|
|
result = result.replace(/(\(+)([^)\n"]+?)(\)+)/g, (match, open, text, close, offset) => {
|
|
if (inAnyRange(offset, quoted)) return match;
|
|
// Skip if it looks like a subgraph or keyword
|
|
if (/^(subgraph|end|graph|flowchart|sequenceDiagram)\b/.test(text.trim())) {
|
|
return match;
|
|
}
|
|
if (SPECIAL_CHARS_RE.test(text)) {
|
|
return `${open}"${text}"${close}`;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
return result;
|
|
}
|