feat: mermaid-animator with HTTP API, WebSocket server, and Docker deployment
Animated GIF generator for Mermaid diagrams with three animation modes (progressive, template, sequence). Includes POST /api/render REST endpoint with CORS for cross-origin canvas integration, plus WebSocket server for real-time preview. Dockerized with Playwright Chromium for server-side rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4c8ea44300
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
*.gif
|
||||
.git
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.gif
|
||||
*.png
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
FROM node:22-bookworm-slim
|
||||
|
||||
# Install Playwright Chromium dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libdbus-1-3 \
|
||||
libxkbcommon0 \
|
||||
libatspi2.0-0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libx11-xcb1 \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --production=false
|
||||
|
||||
# Install Playwright Chromium browser
|
||||
RUN npx playwright install chromium
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3999
|
||||
|
||||
CMD ["node", "dist/cli.js", "preview", "--port", "3999"]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
mermaid-animator:
|
||||
build: .
|
||||
container_name: mermaid-animator
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mermaid-animator.rule=Host(`mermaid.jeffemmett.com`)"
|
||||
- "traefik.http.routers.mermaid-animator.entrypoints=websecure"
|
||||
- "traefik.http.routers.mermaid-animator.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mermaid-animator.loadbalancer.server.port=3999"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "mermaid-animator",
|
||||
"version": "0.1.0",
|
||||
"description": "Animated GIF generator for Mermaid diagrams",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mermaid-animator": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/cli.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"mermaid",
|
||||
"animation",
|
||||
"gif",
|
||||
"diagram"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^13.1.0",
|
||||
"gifenc": "^1.0.3",
|
||||
"mermaid-isomorphic": "^3.1.0",
|
||||
"playwright": "^1.59.1",
|
||||
"sharp": "^0.33.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { Animator, AnimationMode } from '../types.js';
|
||||
import { ProgressiveAnimator } from './progressive.js';
|
||||
import { TemplateAnimator } from './template.js';
|
||||
import { SequenceAnimator } from './sequence.js';
|
||||
|
||||
export function getAnimator(mode: AnimationMode): Animator {
|
||||
switch (mode) {
|
||||
case 'progressive':
|
||||
return new ProgressiveAnimator();
|
||||
case 'template':
|
||||
return new TemplateAnimator();
|
||||
case 'sequence':
|
||||
return new SequenceAnimator();
|
||||
default:
|
||||
throw new Error(`Unknown animation mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { Animator, AnimationResult, ProgressiveResult } from '../types.js';
|
||||
import { parseDiagram, generateProgressiveFrames } from '../parser/mermaid-ast.js';
|
||||
|
||||
export class ProgressiveAnimator implements Animator {
|
||||
/**
|
||||
* Fallback: generates separate mermaid code per frame (causes layout shifts).
|
||||
* Used by template/sequence modes via the generic pipeline.
|
||||
*/
|
||||
parse(input: string, delay: number): AnimationResult {
|
||||
const parsed = parseDiagram(input);
|
||||
const frameCodes = generateProgressiveFrames(parsed);
|
||||
return {
|
||||
frames: frameCodes.map((code) => ({ mermaidCode: code, delay })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable-layout progressive mode: returns the full diagram code
|
||||
* plus per-frame visibility info (which nodes should be shown).
|
||||
*/
|
||||
parseStable(input: string, delay: number): ProgressiveResult {
|
||||
const parsed = parseDiagram(input);
|
||||
const { header, directives, elements } = parsed;
|
||||
|
||||
// Build the full diagram code (all elements + directives)
|
||||
const directiveBlock = directives.length > 0 ? '\n' + directives.join('\n') : '';
|
||||
const allLines = elements.map((e) => e.line);
|
||||
const fullCode = header + '\n' + allLines.join('\n') + directiveBlock;
|
||||
|
||||
// Build cumulative visibility — one node at a time.
|
||||
// Edges appear automatically when both endpoints become visible.
|
||||
const frameVisibility = [];
|
||||
const cumulativeNodes = new Set<string>();
|
||||
|
||||
for (const element of elements) {
|
||||
for (const name of element.nodeNames) {
|
||||
if (!cumulativeNodes.has(name)) {
|
||||
cumulativeNodes.add(name);
|
||||
frameVisibility.push({
|
||||
visibleNodes: new Set(cumulativeNodes),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no elements, single frame with everything visible
|
||||
if (frameVisibility.length === 0) {
|
||||
frameVisibility.push({ visibleNodes: new Set<string>() });
|
||||
}
|
||||
|
||||
return { fullCode, frameVisibility, delay };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { Animator, AnimationResult } from '../types.js';
|
||||
|
||||
/**
|
||||
* Sequence mode: multiple diagrams separated by ---
|
||||
* Each diagram becomes one frame.
|
||||
*/
|
||||
export class SequenceAnimator implements Animator {
|
||||
parse(input: string, delay: number): AnimationResult {
|
||||
const diagrams = input
|
||||
.split(/^---$/m)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (diagrams.length === 0) {
|
||||
throw new Error('No diagrams found in sequence input');
|
||||
}
|
||||
|
||||
return {
|
||||
frames: diagrams.map((code) => ({
|
||||
mermaidCode: code,
|
||||
delay,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { Animator, AnimationResult } from '../types.js';
|
||||
|
||||
/**
|
||||
* Template substitution mode.
|
||||
* Syntax: {[frameIndex value |frameIndex value |...]}
|
||||
* Each frame index gets its corresponding value substituted.
|
||||
*/
|
||||
export class TemplateAnimator implements Animator {
|
||||
parse(input: string, delay: number): AnimationResult {
|
||||
// Find all template markers and determine frame count
|
||||
const markerRegex = /\{\[([^\]]+)\]\}/g;
|
||||
const markers: { full: string; options: Map<number, string> }[] = [];
|
||||
let maxFrame = 0;
|
||||
|
||||
let match;
|
||||
while ((match = markerRegex.exec(input)) !== null) {
|
||||
const content = match[1];
|
||||
const options = new Map<number, string>();
|
||||
|
||||
// Parse "frameIndex value" pairs separated by |
|
||||
const parts = content.split('|').map((s) => s.trim());
|
||||
for (const part of parts) {
|
||||
const spaceIdx = part.indexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
const idx = parseInt(part.substring(0, spaceIdx), 10);
|
||||
const value = part.substring(spaceIdx + 1).trim();
|
||||
if (!isNaN(idx)) {
|
||||
options.set(idx, value);
|
||||
maxFrame = Math.max(maxFrame, idx);
|
||||
}
|
||||
}
|
||||
|
||||
markers.push({ full: match[0], options });
|
||||
}
|
||||
|
||||
if (markers.length === 0) {
|
||||
return { frames: [{ mermaidCode: input, delay }] };
|
||||
}
|
||||
|
||||
// Generate one frame per index
|
||||
const frames = [];
|
||||
for (let frameIdx = 0; frameIdx <= maxFrame; frameIdx++) {
|
||||
let code = input;
|
||||
for (const marker of markers) {
|
||||
// Use the value for this frame index, or the closest lower one
|
||||
let value = '';
|
||||
for (let j = frameIdx; j >= 0; j--) {
|
||||
if (marker.options.has(j)) {
|
||||
value = marker.options.get(j)!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
code = code.replace(marker.full, value);
|
||||
}
|
||||
frames.push({ mermaidCode: code, delay });
|
||||
}
|
||||
|
||||
return { frames };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { getAnimator } from './animators/index.js';
|
||||
import { ProgressiveAnimator } from './animators/progressive.js';
|
||||
import { renderFrames, renderStableProgressiveFrames } from './renderer/index.js';
|
||||
import { encodeGif } from './encoder/gif.js';
|
||||
import type { AnimationMode, RenderOptions } from './types.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('mermaid-animator')
|
||||
.description('Animated GIF generator for Mermaid diagrams')
|
||||
.version('0.1.0');
|
||||
|
||||
program
|
||||
.command('render')
|
||||
.description('Render a Mermaid diagram to an animated GIF')
|
||||
.argument('<input>', 'Path to .mmd file')
|
||||
.option('-o, --output <path>', 'Output GIF path', 'output.gif')
|
||||
.option('-m, --mode <mode>', 'Animation mode: progressive, template, sequence', 'progressive')
|
||||
.option('-d, --delay <ms>', 'Delay between frames in ms', '800')
|
||||
.option('-w, --width <px>', 'Diagram width', '800')
|
||||
.option('-t, --theme <theme>', 'Mermaid theme: default, dark, forest, neutral', 'default')
|
||||
.option('--bg <color>', 'Background color', 'white')
|
||||
.action(async (input: string, opts: Record<string, string>) => {
|
||||
try {
|
||||
const mmdContent = readFileSync(input, 'utf-8');
|
||||
const mode = opts.mode as AnimationMode;
|
||||
const delay = parseInt(opts.delay, 10);
|
||||
|
||||
console.log(`Mode: ${mode}, Delay: ${delay}ms`);
|
||||
|
||||
const renderOpts: RenderOptions = {
|
||||
width: parseInt(opts.width, 10),
|
||||
theme: opts.theme as RenderOptions['theme'],
|
||||
backgroundColor: opts.bg,
|
||||
};
|
||||
|
||||
let pngBuffers: Buffer[];
|
||||
|
||||
if (mode === 'progressive') {
|
||||
// Stable-layout progressive: render full diagram once, hide elements per frame
|
||||
const animator = new ProgressiveAnimator();
|
||||
const progressive = animator.parseStable(mmdContent, delay);
|
||||
console.log(`Generated ${progressive.frameVisibility.length} frames (stable layout)`);
|
||||
|
||||
console.log('Rendering frames...');
|
||||
pngBuffers = await renderStableProgressiveFrames(
|
||||
progressive.fullCode,
|
||||
progressive.frameVisibility,
|
||||
renderOpts
|
||||
);
|
||||
} else {
|
||||
// Template/sequence: render each frame independently
|
||||
const animator = getAnimator(mode);
|
||||
const result = animator.parse(mmdContent, delay);
|
||||
console.log(`Generated ${result.frames.length} frames`);
|
||||
|
||||
console.log('Rendering frames...');
|
||||
pngBuffers = await renderFrames(
|
||||
result.frames.map((f) => f.mermaidCode),
|
||||
renderOpts
|
||||
);
|
||||
}
|
||||
|
||||
// Encode to GIF
|
||||
console.log('Encoding GIF...');
|
||||
const gif = await encodeGif(pngBuffers, { delay });
|
||||
|
||||
writeFileSync(opts.output, gif);
|
||||
console.log(`Written: ${opts.output} (${gif.length} bytes)`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Error:', err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('preview')
|
||||
.description('Launch web preview with live editor')
|
||||
.argument('[input]', 'Optional .mmd file to load')
|
||||
.option('-p, --port <port>', 'Server port', '3999')
|
||||
.action(async (input: string | undefined, opts: Record<string, string>) => {
|
||||
const { startServer } = await import('./server/index.js');
|
||||
const initialCode = input ? readFileSync(input, 'utf-8') : '';
|
||||
await startServer(parseInt(opts.port, 10), initialCode);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import sharp from 'sharp';
|
||||
import gifenc from 'gifenc';
|
||||
const { GIFEncoder, quantize, applyPalette } = gifenc;
|
||||
|
||||
interface EncodeOptions {
|
||||
delay: number; // ms per frame
|
||||
repeat?: number; // 0 = loop forever, -1 = no loop
|
||||
}
|
||||
|
||||
export async function encodeGif(
|
||||
pngBuffers: Buffer[],
|
||||
options: EncodeOptions
|
||||
): Promise<Uint8Array> {
|
||||
if (pngBuffers.length === 0) {
|
||||
throw new Error('No frames to encode');
|
||||
}
|
||||
|
||||
// Decode all PNGs and get their dimensions
|
||||
const frames = await Promise.all(
|
||||
pngBuffers.map(async (buf) => {
|
||||
const img = sharp(buf);
|
||||
const meta = await img.metadata();
|
||||
return { img, width: meta.width!, height: meta.height! };
|
||||
})
|
||||
);
|
||||
|
||||
// Normalize all frames to the same dimensions (max width/height, white padded)
|
||||
const maxWidth = Math.max(...frames.map((f) => f.width));
|
||||
const maxHeight = Math.max(...frames.map((f) => f.height));
|
||||
|
||||
const rgbaFrames = await Promise.all(
|
||||
frames.map(async ({ img, width, height }) => {
|
||||
let pipeline = img;
|
||||
if (width !== maxWidth || height !== maxHeight) {
|
||||
// Extend with white background to match max dimensions
|
||||
pipeline = pipeline.extend({
|
||||
top: 0,
|
||||
bottom: maxHeight - height,
|
||||
left: 0,
|
||||
right: maxWidth - width,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
});
|
||||
}
|
||||
return pipeline.ensureAlpha().raw().toBuffer();
|
||||
})
|
||||
);
|
||||
|
||||
// Encode to GIF
|
||||
const gif = GIFEncoder();
|
||||
|
||||
for (const rgba of rgbaFrames) {
|
||||
// Convert RGBA buffer to Uint8Array for quantization
|
||||
const data = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
|
||||
const palette = quantize(data, 256);
|
||||
const index = applyPalette(data, palette);
|
||||
|
||||
gif.writeFrame(index, maxWidth, maxHeight, {
|
||||
palette,
|
||||
delay: options.delay,
|
||||
repeat: options.repeat ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
gif.finish();
|
||||
return gif.bytesView();
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
declare module 'gifenc' {
|
||||
interface GIFEncoderInstance {
|
||||
writeFrame(
|
||||
index: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: {
|
||||
palette?: number[][];
|
||||
delay?: number;
|
||||
repeat?: number;
|
||||
transparent?: boolean;
|
||||
transparentIndex?: number;
|
||||
dispose?: number;
|
||||
first?: boolean;
|
||||
colorDepth?: number;
|
||||
}
|
||||
): void;
|
||||
finish(): void;
|
||||
bytes(): Uint8Array;
|
||||
bytesView(): Uint8Array;
|
||||
reset(): void;
|
||||
readonly buffer: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface Gifenc {
|
||||
GIFEncoder(): GIFEncoderInstance;
|
||||
quantize(
|
||||
data: Uint8Array,
|
||||
maxColors: number,
|
||||
options?: {
|
||||
format?: 'rgb565' | 'rgb444' | 'rgba4444';
|
||||
oneBitAlpha?: boolean | number;
|
||||
clearAlpha?: boolean;
|
||||
clearAlphaColor?: number;
|
||||
clearAlphaThreshold?: number;
|
||||
}
|
||||
): number[][];
|
||||
applyPalette(
|
||||
data: Uint8Array,
|
||||
palette: number[][],
|
||||
format?: 'rgb565' | 'rgb444' | 'rgba4444'
|
||||
): Uint8Array;
|
||||
nearestColorIndex(palette: number[][], color: number[]): number;
|
||||
prequantize(
|
||||
data: Uint8Array,
|
||||
options?: { roundRGB?: number; roundAlpha?: number; oneBitAlpha?: boolean | number }
|
||||
): void;
|
||||
snapColorsToPalette(
|
||||
palette: number[][],
|
||||
knownColors: number[][],
|
||||
threshold?: number
|
||||
): void;
|
||||
}
|
||||
|
||||
const gifenc: Gifenc;
|
||||
export default gifenc;
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Line-level tokenizer for Mermaid flowchart/graph diagrams.
|
||||
* Parses the diagram into a header, directives (classDef/style/linkStyle),
|
||||
* and elements (nodes, edges, subgraphs) for progressive reveal.
|
||||
*/
|
||||
|
||||
export interface ParsedDiagram {
|
||||
header: string; // e.g. "graph TD" or "flowchart LR"
|
||||
directives: string[]; // classDef, style, linkStyle — always included in every frame
|
||||
elements: ElementInfo[]; // nodes/edges/subgraphs to reveal progressively
|
||||
}
|
||||
|
||||
export interface ElementInfo {
|
||||
line: string; // original source line(s)
|
||||
nodeNames: string[]; // node IDs introduced by this element
|
||||
}
|
||||
|
||||
const DIRECTIVE_PATTERNS = [
|
||||
/^\s*classDef\s/,
|
||||
/^\s*class\s/,
|
||||
/^\s*style\s/,
|
||||
/^\s*linkStyle\s/,
|
||||
/^\s*%%/,
|
||||
/^\s*click\s/,
|
||||
];
|
||||
|
||||
const HEADER_PATTERN = /^\s*(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|gitGraph|journey|mindmap|timeline|sankey|xychart|block)/;
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
'graph', 'flowchart', 'TD', 'TB', 'BT', 'RL', 'LR',
|
||||
'subgraph', 'end', 'classDef', 'class', 'style', 'linkStyle',
|
||||
'click', 'direction', 'true', 'false',
|
||||
]);
|
||||
|
||||
function isDirective(line: string): boolean {
|
||||
return DIRECTIVE_PATTERNS.some((p) => p.test(line));
|
||||
}
|
||||
|
||||
function isBlank(line: string): boolean {
|
||||
return line.trim() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node names from a mermaid source line.
|
||||
* Strips shape content ([text], {text}, etc.) and edge labels (|text|),
|
||||
* then finds word tokens that match known SVG node names.
|
||||
*/
|
||||
export function extractNodeNames(line: string): string[] {
|
||||
let cleaned = line;
|
||||
// Remove quoted strings
|
||||
cleaned = cleaned.replace(/"[^"]*"/g, ' ');
|
||||
// Remove arrow operators FIRST (before shape removal, to avoid > in --> matching asymmetric shapes)
|
||||
cleaned = cleaned.replace(/[-.=~]{2,}>?/g, ' ');
|
||||
cleaned = cleaned.replace(/<[-.=~]{2,}/g, ' ');
|
||||
// Remove edge labels |text|
|
||||
cleaned = cleaned.replace(/\|[^|]*\|/g, ' ');
|
||||
// Remove shape content (order matters: compound shapes first)
|
||||
cleaned = cleaned.replace(/\(\[.*?\]\)/g, ' ');
|
||||
cleaned = cleaned.replace(/\[\[.*?\]\]/g, ' ');
|
||||
cleaned = cleaned.replace(/\(\(.*?\)\)/g, ' ');
|
||||
cleaned = cleaned.replace(/\[.*?\]/g, ' ');
|
||||
cleaned = cleaned.replace(/\(.*?\)/g, ' ');
|
||||
cleaned = cleaned.replace(/\{.*?\}/g, ' ');
|
||||
// Asymmetric shape: ID>text] — only match when preceded by a word char
|
||||
cleaned = cleaned.replace(/(?<=\w)>.*?\]/g, ' ');
|
||||
|
||||
// Extract word tokens
|
||||
const tokens = cleaned.match(/\b([A-Za-z_][\w]*)\b/g) || [];
|
||||
return [...new Set(tokens.filter((t) => !KEYWORDS.has(t)))];
|
||||
}
|
||||
|
||||
export function parseDiagram(code: string): ParsedDiagram {
|
||||
const lines = code.split('\n');
|
||||
let header = '';
|
||||
const directives: string[] = [];
|
||||
const elements: ElementInfo[] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
// Find header line
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (isBlank(line)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (HEADER_PATTERN.test(line)) {
|
||||
header = line;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (line.trim().startsWith('%%{')) {
|
||||
directives.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
header = line;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse remaining lines into directives and elements
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (isBlank(line)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDirective(line)) {
|
||||
directives.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle subgraph blocks as single elements
|
||||
if (/^\s*subgraph\s/.test(line)) {
|
||||
const subgraphLines: string[] = [line];
|
||||
i++;
|
||||
let depth = 1;
|
||||
while (i < lines.length && depth > 0) {
|
||||
const subLine = lines[i];
|
||||
subgraphLines.push(subLine);
|
||||
if (/^\s*subgraph\s/.test(subLine)) depth++;
|
||||
if (/^\s*end\s*$/.test(subLine.trim())) depth--;
|
||||
i++;
|
||||
}
|
||||
const fullLine = subgraphLines.join('\n');
|
||||
const nodeNames = subgraphLines.flatMap(extractNodeNames);
|
||||
elements.push({ line: fullLine, nodeNames: [...new Set(nodeNames)] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular line: node, edge, or other content
|
||||
elements.push({ line, nodeNames: extractNodeNames(line) });
|
||||
i++;
|
||||
}
|
||||
|
||||
return { header, directives, elements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate progressive frame codes from a parsed diagram.
|
||||
* Each frame adds one more element while always including directives.
|
||||
*/
|
||||
export function generateProgressiveFrames(parsed: ParsedDiagram): string[] {
|
||||
const { header, directives, elements } = parsed;
|
||||
const frames: string[] = [];
|
||||
const directiveBlock = directives.length > 0 ? '\n' + directives.join('\n') : '';
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const visibleElements = elements.slice(0, i + 1).map((e) => e.line);
|
||||
const frame = header + '\n' + visibleElements.join('\n') + directiveBlock;
|
||||
frames.push(frame);
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
frames.push(header + directiveBlock);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import { createMermaidRenderer, type MermaidRenderer } from 'mermaid-isomorphic';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import type { RenderOptions, FrameVisibility } from '../types.js';
|
||||
|
||||
let renderer: MermaidRenderer | null = null;
|
||||
let screenshotBrowser: Browser | null = null;
|
||||
|
||||
function getRenderer(): MermaidRenderer {
|
||||
if (!renderer) {
|
||||
renderer = createMermaidRenderer({
|
||||
launchOptions: {
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
},
|
||||
});
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
async function getScreenshotBrowser(): Promise<Browser> {
|
||||
if (!screenshotBrowser) {
|
||||
screenshotBrowser = await chromium.launch({
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
}
|
||||
return screenshotBrowser;
|
||||
}
|
||||
|
||||
export async function renderDiagram(
|
||||
code: string,
|
||||
opts: RenderOptions
|
||||
): Promise<Buffer> {
|
||||
const render = getRenderer();
|
||||
const results = await render([code], {
|
||||
screenshot: true,
|
||||
mermaidConfig: {
|
||||
theme: opts.theme,
|
||||
},
|
||||
});
|
||||
|
||||
const result = results[0];
|
||||
if (result.status === 'rejected') {
|
||||
throw new Error(`Mermaid render failed: ${result.reason}`);
|
||||
}
|
||||
if (!result.value.screenshot) {
|
||||
throw new Error('No screenshot produced');
|
||||
}
|
||||
return Buffer.from(result.value.screenshot);
|
||||
}
|
||||
|
||||
export async function renderFrames(
|
||||
codes: string[],
|
||||
opts: RenderOptions
|
||||
): Promise<Buffer[]> {
|
||||
const render = getRenderer();
|
||||
const results = await render(codes, {
|
||||
screenshot: true,
|
||||
mermaidConfig: {
|
||||
theme: opts.theme,
|
||||
},
|
||||
});
|
||||
|
||||
return results.map((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
throw new Error(`Frame ${i} render failed: ${result.reason}`);
|
||||
}
|
||||
if (!result.value.screenshot) {
|
||||
throw new Error(`Frame ${i}: no screenshot produced`);
|
||||
}
|
||||
return Buffer.from(result.value.screenshot);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Stable-layout progressive rendering ---
|
||||
|
||||
interface SvgElementIds {
|
||||
nodeIds: Map<string, string>; // nodeName → full SVG element ID
|
||||
edgeIds: Map<string, string[]>; // "source_target" → full SVG element IDs
|
||||
edgeLabelDataIds: Map<string, string[]>; // "source_target" → data-id values
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node and edge IDs from mermaid SVG output.
|
||||
*/
|
||||
function extractSvgIds(svg: string): SvgElementIds {
|
||||
const nodeIds = new Map<string, string>();
|
||||
const edgeIds = new Map<string, string[]>();
|
||||
const edgeLabelDataIds = new Map<string, string[]>();
|
||||
|
||||
// Node IDs: id="mermaid-0-flowchart-A-0"
|
||||
const nodeRegex = /id="(mermaid-\d+-flowchart-(\w+)-\d+)"/g;
|
||||
let match;
|
||||
while ((match = nodeRegex.exec(svg)) !== null) {
|
||||
nodeIds.set(match[2], match[1]);
|
||||
}
|
||||
|
||||
// Edge path IDs: id="mermaid-0-L_A_B_0"
|
||||
const edgeRegex = /id="(mermaid-\d+-L_(\w+)_(\w+)_\d+)"/g;
|
||||
while ((match = edgeRegex.exec(svg)) !== null) {
|
||||
const key = `${match[2]}_${match[3]}`;
|
||||
const ids = edgeIds.get(key) || [];
|
||||
ids.push(match[1]);
|
||||
edgeIds.set(key, ids);
|
||||
}
|
||||
|
||||
// Edge label data-ids: data-id="L_A_B_0"
|
||||
const labelRegex = /data-id="(L_(\w+)_(\w+)_\d+)"/g;
|
||||
while ((match = labelRegex.exec(svg)) !== null) {
|
||||
const key = `${match[2]}_${match[3]}`;
|
||||
const ids = edgeLabelDataIds.get(key) || [];
|
||||
ids.push(match[1]);
|
||||
edgeLabelDataIds.set(key, ids);
|
||||
}
|
||||
|
||||
return { nodeIds, edgeIds, edgeLabelDataIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSS that hides SVG elements not visible in the current frame.
|
||||
*/
|
||||
function buildHidingCss(
|
||||
svgIds: SvgElementIds,
|
||||
visibleNodes: Set<string>
|
||||
): string {
|
||||
const selectors: string[] = [];
|
||||
|
||||
// Hide nodes not yet visible
|
||||
for (const [nodeName, svgId] of svgIds.nodeIds) {
|
||||
if (!visibleNodes.has(nodeName)) {
|
||||
selectors.push(`#${cssEscapeId(svgId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide edges where either endpoint is not visible
|
||||
for (const [key, ids] of svgIds.edgeIds) {
|
||||
const [source, target] = key.split('_');
|
||||
if (!visibleNodes.has(source) || !visibleNodes.has(target)) {
|
||||
for (const id of ids) {
|
||||
selectors.push(`#${cssEscapeId(id)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide edge labels where either endpoint is not visible
|
||||
for (const [key, dataIds] of svgIds.edgeLabelDataIds) {
|
||||
const [source, target] = key.split('_');
|
||||
if (!visibleNodes.has(source) || !visibleNodes.has(target)) {
|
||||
for (const dataId of dataIds) {
|
||||
selectors.push(`[data-id="${dataId}"]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectors.length === 0) return '';
|
||||
return selectors.join(', ') + ' { visibility: hidden !important; }';
|
||||
}
|
||||
|
||||
function cssEscapeId(id: string): string {
|
||||
// CSS.escape equivalent for SVG IDs (alphanumeric + hyphens, safe without escaping)
|
||||
return id.replace(/([^\w-])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render progressive frames with stable layout.
|
||||
* 1. Renders the full diagram to SVG (final layout)
|
||||
* 2. For each frame, injects CSS to hide future elements
|
||||
* 3. Screenshots each modified SVG via Playwright
|
||||
*/
|
||||
export async function renderStableProgressiveFrames(
|
||||
fullCode: string,
|
||||
frameVisibility: FrameVisibility[],
|
||||
opts: RenderOptions
|
||||
): Promise<Buffer[]> {
|
||||
// 1. Render full diagram to get SVG with final layout
|
||||
const render = getRenderer();
|
||||
const results = await render([fullCode], {
|
||||
screenshot: false,
|
||||
mermaidConfig: { theme: opts.theme },
|
||||
});
|
||||
|
||||
const result = results[0];
|
||||
if (result.status === 'rejected') {
|
||||
throw new Error(`Mermaid render failed: ${result.reason}`);
|
||||
}
|
||||
|
||||
const svg = result.value.svg;
|
||||
const svgWidth = result.value.width;
|
||||
const svgHeight = result.value.height;
|
||||
|
||||
// 2. Extract element IDs from SVG
|
||||
const svgIds = extractSvgIds(svg);
|
||||
|
||||
// 3. Prepare a fixed-size SVG (replace width="100%" with explicit dimensions)
|
||||
const fixedSvg = svg.replace(
|
||||
/width="100%"/,
|
||||
`width="${svgWidth}" height="${svgHeight}"`
|
||||
);
|
||||
|
||||
// 4. For each frame, inject hiding CSS and screenshot
|
||||
const browser = await getScreenshotBrowser();
|
||||
const page = await browser.newPage();
|
||||
await page.setViewportSize({
|
||||
width: Math.ceil(svgWidth) + 20,
|
||||
height: Math.ceil(svgHeight) + 20,
|
||||
});
|
||||
|
||||
const pngBuffers: Buffer[] = [];
|
||||
|
||||
for (const frame of frameVisibility) {
|
||||
const hidingCss = buildHidingCss(svgIds, frame.visibleNodes);
|
||||
const modifiedSvg = hidingCss
|
||||
? fixedSvg.replace('</style>', hidingCss + '</style>')
|
||||
: fixedSvg;
|
||||
|
||||
const html = `<!DOCTYPE html><html><head><style>body{margin:0;padding:0;background:white;}</style></head><body>${modifiedSvg}</body></html>`;
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
|
||||
const screenshot = await page.screenshot({
|
||||
clip: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(svgWidth),
|
||||
height: Math.ceil(svgHeight),
|
||||
},
|
||||
type: 'png',
|
||||
});
|
||||
|
||||
pngBuffers.push(Buffer.from(screenshot));
|
||||
}
|
||||
|
||||
await page.close();
|
||||
return pngBuffers;
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getAnimator } from '../animators/index.js';
|
||||
import { ProgressiveAnimator } from '../animators/progressive.js';
|
||||
import { renderFrames, renderStableProgressiveFrames } from '../renderer/index.js';
|
||||
import { encodeGif } from '../encoder/gif.js';
|
||||
import type { AnimationMode, RenderOptions } from '../types.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
|
||||
interface RenderRequest {
|
||||
code: string;
|
||||
mode?: AnimationMode;
|
||||
delay?: number;
|
||||
theme?: RenderOptions['theme'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared render function: mermaid code → GIF base64
|
||||
* Used by both the WebSocket handler and the HTTP API.
|
||||
*/
|
||||
async function renderMermaidToGif(
|
||||
code: string,
|
||||
mode: AnimationMode = 'progressive',
|
||||
delay: number = 500,
|
||||
theme: RenderOptions['theme'] = 'default',
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<{ gif: string; frames: number; width: number; height: number }> {
|
||||
const renderOpts: RenderOptions = {
|
||||
width: 800,
|
||||
theme,
|
||||
backgroundColor: 'white',
|
||||
};
|
||||
|
||||
let pngBuffers: Buffer[];
|
||||
|
||||
if (mode === 'progressive') {
|
||||
const animator = new ProgressiveAnimator();
|
||||
const progressive = animator.parseStable(code, delay);
|
||||
onStatus?.(`Rendering ${progressive.frameVisibility.length} frames (stable layout)...`);
|
||||
pngBuffers = await renderStableProgressiveFrames(
|
||||
progressive.fullCode,
|
||||
progressive.frameVisibility,
|
||||
renderOpts
|
||||
);
|
||||
} else {
|
||||
const animator = getAnimator(mode);
|
||||
const result = animator.parse(code, delay);
|
||||
onStatus?.(`Rendering ${result.frames.length} frames...`);
|
||||
pngBuffers = await renderFrames(
|
||||
result.frames.map((f) => f.mermaidCode),
|
||||
renderOpts
|
||||
);
|
||||
}
|
||||
|
||||
onStatus?.('Encoding GIF...');
|
||||
const gif = await encodeGif(pngBuffers, { delay });
|
||||
const base64 = Buffer.from(gif).toString('base64');
|
||||
|
||||
return {
|
||||
gif: base64,
|
||||
frames: pngBuffers.length,
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full request body as a string.
|
||||
*/
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /api/render — HTTP API for GIF rendering.
|
||||
*/
|
||||
async function handleApiRender(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
// CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, CORS_HEADERS);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const { code, mode, delay, theme } = JSON.parse(body) as RenderRequest;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
res.writeHead(400, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing required field: code' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await renderMermaidToGif(code, mode, delay, theme);
|
||||
|
||||
res.writeHead(200, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('API render error:', message);
|
||||
res.writeHead(500, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(port: number, initialCode: string): Promise<void> {
|
||||
const staticDir = join(__dirname, 'static');
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url === '/' ? '/index.html' : req.url!;
|
||||
|
||||
// API routes
|
||||
if (url === '/api/initial-code') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: initialCode }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '/api/render') {
|
||||
handleApiRender(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// CORS preflight for any /api/* path
|
||||
if (url.startsWith('/api/') && req.method === 'OPTIONS') {
|
||||
res.writeHead(204, CORS_HEADERS);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Static file serving
|
||||
try {
|
||||
const filePath = join(staticDir, url);
|
||||
const content = readFileSync(filePath);
|
||||
const ext = extname(filePath);
|
||||
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
||||
res.end(content);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Client connected');
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'render') {
|
||||
const { code, mode = 'progressive', delay = 500, theme = 'default' } = msg;
|
||||
|
||||
ws.send(JSON.stringify({ type: 'status', message: 'Parsing...' }));
|
||||
|
||||
const result = await renderMermaidToGif(
|
||||
code,
|
||||
mode as AnimationMode,
|
||||
delay,
|
||||
theme as RenderOptions['theme'],
|
||||
(message) => ws.send(JSON.stringify({ type: 'status', message }))
|
||||
);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'gif', data: result.gif }));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ws.send(JSON.stringify({ type: 'error', message }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Preview server running at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Monaco Editor setup
|
||||
const DEFAULT_CODE = `graph TD
|
||||
A[Start] --> B[Process]
|
||||
B --> C{Decision}
|
||||
C -->|Yes| D[Result]
|
||||
C -->|No| E[Other]`;
|
||||
|
||||
let editor;
|
||||
|
||||
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
editor = monaco.editor.create(document.getElementById('editor-container'), {
|
||||
value: DEFAULT_CODE,
|
||||
language: 'markdown',
|
||||
theme: 'vs-dark',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
// Load initial code from server if provided
|
||||
fetch('/api/initial-code')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.code) {
|
||||
editor.setValue(data.code);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
function getEditorValue() {
|
||||
return editor ? editor.getValue() : DEFAULT_CODE;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mermaid Animator — Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; height: 100vh; display: flex; flex-direction: column; background: #1e1e1e; color: #ccc; }
|
||||
header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; }
|
||||
header h1 { font-size: 14px; font-weight: 600; color: #fff; }
|
||||
.controls { display: flex; gap: 8px; align-items: center; margin-left: auto; }
|
||||
.controls label { font-size: 12px; }
|
||||
.controls select, .controls input { background: #3c3c3c; color: #ccc; border: 1px solid #555; border-radius: 3px; padding: 3px 6px; font-size: 12px; }
|
||||
.controls input[type="number"] { width: 60px; }
|
||||
button { background: #0078d4; color: #fff; border: none; border-radius: 3px; padding: 6px 16px; cursor: pointer; font-size: 13px; }
|
||||
button:hover { background: #106ebe; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.main { display: flex; flex: 1; overflow: hidden; }
|
||||
.editor-pane { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #333; }
|
||||
.preview-pane { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; background: #f5f5f5; overflow: auto; }
|
||||
#editor-container { flex: 1; }
|
||||
#status { font-size: 12px; padding: 4px 8px; background: #252526; color: #888; }
|
||||
#preview-img { max-width: 100%; max-height: 100%; image-rendering: auto; }
|
||||
.placeholder { color: #999; font-size: 14px; }
|
||||
#download-link { display: none; margin-top: 8px; color: #0078d4; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Mermaid Animator</h1>
|
||||
<div class="controls">
|
||||
<label>Mode:
|
||||
<select id="mode">
|
||||
<option value="progressive">Progressive</option>
|
||||
<option value="template">Template</option>
|
||||
<option value="sequence">Sequence</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Delay:
|
||||
<input type="number" id="delay" value="500" min="100" step="100"> ms
|
||||
</label>
|
||||
<label>Theme:
|
||||
<select id="theme">
|
||||
<option value="default">Default</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="render-btn">Render GIF</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main">
|
||||
<div class="editor-pane">
|
||||
<div id="editor-container"></div>
|
||||
<div id="status">Ready</div>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<img id="preview-img" style="display:none" />
|
||||
<p class="placeholder" id="placeholder">Click "Render GIF" to generate animation</p>
|
||||
<a id="download-link" download="mermaid-animation.gif">Download GIF</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
|
||||
<script src="editor.js"></script>
|
||||
<script src="preview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// WebSocket preview client
|
||||
const ws = new WebSocket(`ws://${location.host}`);
|
||||
const statusEl = document.getElementById('status');
|
||||
const renderBtn = document.getElementById('render-btn');
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const downloadLink = document.getElementById('download-link');
|
||||
|
||||
ws.onopen = () => {
|
||||
statusEl.textContent = 'Connected';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
statusEl.textContent = 'Disconnected — refresh to reconnect';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'status':
|
||||
statusEl.textContent = msg.message;
|
||||
break;
|
||||
|
||||
case 'gif':
|
||||
const dataUrl = `data:image/gif;base64,${msg.data}`;
|
||||
previewImg.src = dataUrl;
|
||||
previewImg.style.display = 'block';
|
||||
placeholder.style.display = 'none';
|
||||
downloadLink.href = dataUrl;
|
||||
downloadLink.style.display = 'inline';
|
||||
statusEl.textContent = 'Done';
|
||||
renderBtn.disabled = false;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
statusEl.textContent = `Error: ${msg.message}`;
|
||||
renderBtn.disabled = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
renderBtn.addEventListener('click', () => {
|
||||
const code = getEditorValue();
|
||||
const mode = document.getElementById('mode').value;
|
||||
const delay = parseInt(document.getElementById('delay').value, 10);
|
||||
const theme = document.getElementById('theme').value;
|
||||
|
||||
renderBtn.disabled = true;
|
||||
statusEl.textContent = 'Sending...';
|
||||
|
||||
ws.send(JSON.stringify({ type: 'render', code, mode, delay, theme }));
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
export interface RenderOptions {
|
||||
width: number;
|
||||
height?: number;
|
||||
theme: 'default' | 'dark' | 'forest' | 'neutral';
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface AnimationFrame {
|
||||
mermaidCode: string;
|
||||
delay: number; // ms
|
||||
}
|
||||
|
||||
export interface AnimationResult {
|
||||
frames: AnimationFrame[];
|
||||
}
|
||||
|
||||
export type AnimationMode = 'progressive' | 'template' | 'sequence';
|
||||
|
||||
export interface Animator {
|
||||
parse(input: string, delay: number): AnimationResult;
|
||||
}
|
||||
|
||||
/** Progressive mode: stable-layout frames with visibility info */
|
||||
export interface ProgressiveResult {
|
||||
fullCode: string;
|
||||
frameVisibility: FrameVisibility[];
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export interface FrameVisibility {
|
||||
visibleNodes: Set<string>;
|
||||
}
|
||||
|
||||
export interface CliOptions {
|
||||
output: string;
|
||||
mode: AnimationMode;
|
||||
delay: number;
|
||||
width: number;
|
||||
height?: number;
|
||||
theme: 'default' | 'dark' | 'forest' | 'neutral';
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
graph TD
|
||||
A[Start]
|
||||
---
|
||||
graph TD
|
||||
A[Start] --> B[Process]
|
||||
---
|
||||
graph TD
|
||||
A[Start] --> B[Process]
|
||||
B --> C{Decision}
|
||||
---
|
||||
graph TD
|
||||
A[Start] --> B[Process]
|
||||
B --> C{Decision}
|
||||
C -->|Yes| D[Result]
|
||||
C -->|No| E[Other]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
graph TD
|
||||
A[Start] --> B[Process]
|
||||
B --> C{Decision}
|
||||
C -->|Yes| D[Result]
|
||||
C -->|No| E[Other]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
graph TD
|
||||
A[{[0 Start |1 Begin |2 Initialize]}] --> B[{[0 Step 1 |1 Step 2 |2 Step 3]}]
|
||||
B --> C[{[0 Processing |1 Computing |2 Finishing]}]
|
||||
C --> D[Done]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue