mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
v4.13.0 — round-trips Mermaid source through the editor:
- new open-text-file-dialog invoke IPC in main.js opens a system
Open dialog filtered for .mmd / .mermaid / .md / .markdown / .txt,
reads the chosen file, returns { path, content } or null on cancel.
- new openFile() on the flowchart preload bridge.
- new Open from File button in the standalone window toolbar
alongside Insert / Save / Reset.
- On click: confirms overwrite when the current graph is non-empty,
strips any mermaid fence, parses via the inlined fromMermaid()
(the inverse of toMermaid), and calls _store.deserialize so the
canvas redraws. Status bar reports Loaded path.
The fromMermaid() in the bundle is duplicated from
src/flowchart/flowchart-mermaid-parse.js (23 tests in C8 land the
same logic) so the standalone window does not depend on the bundle
rebuild step. Pure module + bundle inline stay in sync logically;
future work could collapse them via a build-time inclusion.
Amit Haridas
1686 lines
60 KiB
JavaScript
1686 lines
60 KiB
JavaScript
/**
|
||
* v4.12.0 — Bundled single-file Flowchart Generator loader.
|
||
*
|
||
* Inlines the four pure modules (flowchart-shapes / flowchart-mermaid /
|
||
* flowchart-store / flowchart-canvas) plus the renderer controller
|
||
* (src/renderer/flowchart-controller.js) into one script. Loads as a single
|
||
* `<script src="renderer/flowchart-bundle.js">` tag in
|
||
* src/flowchart-generator.html.
|
||
*
|
||
* History:
|
||
* v4.9.6 — split modules into UMD wrappers; standalone window loaded each
|
||
* via `<script>` tags.
|
||
* v4.9.7 — added the `window.FlowchartXxx = exported` guard inside every
|
||
* UMD wrapper so the global survives the `module` truthy
|
||
* (nodeIntegration:true) case in the renderer.
|
||
* v4.9.8 — even with the v4.9.7 guards the user kept reporting
|
||
* 'modules not loaded' in the standalone window. Rather than
|
||
* rely on script-tag ordering / UMD quirks across all four files,
|
||
* brute-force bundle everything into one self-contained file. No
|
||
* cross-file script ordering, no UMD wrapper, no `require()`. The
|
||
* standalone window now has exactly one script dependency.
|
||
* v4.9.9 — Electron renderer contexts disable `window.prompt` and
|
||
* `window.confirm`, so shape change / edge kind / edge label /
|
||
* reset confirmation did nothing. Replaced with `promptInline`
|
||
* and `confirmInline` (custom DOM-overlay modals). Exposed as
|
||
* `window.FlowchartModals` for jsdom tests.
|
||
* v4.10.0 — User still reported "no fix still" because hidden right-
|
||
* click context menus and `window.prompt` were unreliable in
|
||
* Electron. Added a *visible* floating selection toolbar
|
||
* inside the canvas panel (`<div id="fc-selection-toolbar">`)
|
||
* that exposes shape buttons, edge-kind buttons, an inline
|
||
* label input, and a Delete button — no hidden UI affordance
|
||
* for the primary interactions. Added console-log diagnostics
|
||
* on every canvas event (pointerdown / pointerup / dblclick /
|
||
* contextmenu / selection change / bootstrap phase) so the
|
||
* user can open DevTools (Ctrl+Shift+I) and see what's firing.
|
||
* `promptInline` / `confirmInline` kept as advanced fallback
|
||
* for the right-click "change shape" path; the toolbar is now
|
||
* the primary interaction surface.
|
||
* v4.11.0 — The v4.10.0 floating toolbar was click-driven and the user
|
||
* reported it still showed only rectangles in their Electron
|
||
* runtime (SVG click hit-testing was unreliable). Replaced
|
||
* with a button-driven node-list panel (`#fc-nodelist`)
|
||
* between the canvas and the preview. Every mutation — add
|
||
* node, delete node, change kind, edit label, add edge,
|
||
* delete edge, change edge kind, edit edge label — is wired
|
||
* to explicit buttons and form controls. The canvas itself
|
||
* is now purely visual: no more click hit-testing, no more
|
||
* selection state, no more floating toolbar. `promptInline` /
|
||
* `confirmInline` are kept only for the Reset confirmation
|
||
* modal.
|
||
* v4.12.0 — User feedback: the v4.11.0 connect form (From dropdown +
|
||
* To dropdown + "+ Edge") was buried below the node/edge
|
||
* lists and they couldn't find it. Moved the connect form
|
||
* up to the second section in #fc-nodelist (right after Add
|
||
* Node). Also added (a) a per-node color picker in the
|
||
* node list (`<input type="color">` → `store.setNodeColor`)
|
||
* — `shapeSvg` now accepts an optional color arg and
|
||
* normalises `#ffffff` by default; (b) a "Save to File"
|
||
* button alongside "Insert at Cursor" that opens a system
|
||
* save dialog via a new `save-text-file` IPC channel. The
|
||
* standalone top toolbar was removed; Insert / Save / Reset
|
||
* now live inside the panel's new "Export" section.
|
||
*
|
||
* The legacy individual files under src/flowchart/* and
|
||
* src/renderer/flowchart-controller.js are kept untouched — the
|
||
* `src/renderer.js` sidebar still uses the CommonJS shape via require().
|
||
*
|
||
* Pure browser script — no require(), no module.exports, no Node APIs.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ========== flowchart-shapes (inlined) ==========
|
||
// v4.12.0 — accepts an optional `color` (6th) arg so the canvas can paint
|
||
// each node with its per-node fill color. Falls back to `#ffffff`.
|
||
const SHAPE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
||
const DEFAULT_WIDTH = 140;
|
||
const DEFAULT_HEIGHT = 60;
|
||
const LABEL_PADDING_X = 16;
|
||
const LABEL_PADDING_Y = 12;
|
||
const DEFAULT_FILL = '#ffffff';
|
||
|
||
function shapeSvg(kind, x, y, width, height, color) {
|
||
if (!SHAPE_KINDS.includes(kind)) {
|
||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||
}
|
||
const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL;
|
||
switch (kind) {
|
||
case 'process':
|
||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />`;
|
||
case 'terminator':
|
||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" ry="${height / 2}" fill="${fill}" />`;
|
||
case 'subroutine': {
|
||
const inset = 4;
|
||
return (
|
||
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />` +
|
||
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" fill="${fill}" />`
|
||
);
|
||
}
|
||
case 'decision': {
|
||
const cx = x + width / 2;
|
||
const cy = y + height / 2;
|
||
const left = `${x},${cy}`;
|
||
const top = `${cx},${y}`;
|
||
const right = `${x + width},${cy}`;
|
||
const bottom = `${cx},${y + height}`;
|
||
return `<polygon points="${left} ${top} ${right} ${bottom}" fill="${fill}" />`;
|
||
}
|
||
case 'document': {
|
||
// Parallelogram: top-right and bottom-right indented by ~20% of height.
|
||
const skew = Math.max(10, Math.round(height * 0.25));
|
||
const tl = `${x + skew},${y}`;
|
||
const tr = `${x + width},${y}`;
|
||
const br = `${x + width - skew},${y + height}`;
|
||
const bl = `${x},${y + height}`;
|
||
return `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" />`;
|
||
}
|
||
default:
|
||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||
}
|
||
}
|
||
|
||
// Exposed for the controller (and any other bundle consumer).
|
||
window.FlowchartShapes = {
|
||
shapeSvg,
|
||
SHAPE_KINDS,
|
||
DEFAULT_WIDTH,
|
||
DEFAULT_HEIGHT,
|
||
LABEL_PADDING_X,
|
||
LABEL_PADDING_Y,
|
||
};
|
||
|
||
// ========== flowchart-mermaid (inlined) ==========
|
||
const MERMAID_SHAPE_SYNTAX = {
|
||
process: (id, label) => `${id}[${label}]`,
|
||
decision: (id, label) => `${id}{${label}}`,
|
||
terminator: (id, label) => `${id}([${label}])`,
|
||
subroutine: (id, label) => `${id}[[${label}]]`,
|
||
document: (id, label) => `${id}[/${label}/]`,
|
||
};
|
||
|
||
const MERMAID_EDGE_ARROW = {
|
||
solid: '-->',
|
||
dotted: '-.->',
|
||
thick: '==>',
|
||
};
|
||
|
||
function escapeLabel(label) {
|
||
return String(label || '')
|
||
.replace(/"/g, '#quot;')
|
||
.replace(/\r?\n/g, '\\n');
|
||
}
|
||
|
||
function assignIds(nodes) {
|
||
// A, B, C, …, Z, AA, AB, … (matches Mermaid's preferred short ids).
|
||
const map = new Map();
|
||
let n = 0;
|
||
for (const node of nodes) {
|
||
let id;
|
||
if (n < 26) id = String.fromCharCode(65 + n);
|
||
else {
|
||
const first = Math.floor(n / 26) - 1;
|
||
const second = n % 26;
|
||
id = String.fromCharCode(65 + first) + String.fromCharCode(65 + second);
|
||
}
|
||
map.set(node.id, id);
|
||
n += 1;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function nodeDeclaration(node) {
|
||
const fn = MERMAID_SHAPE_SYNTAX[node.kind];
|
||
if (!fn) throw new Error(`flowchart-mermaid: unknown node kind "${node.kind}"`);
|
||
return fn(node.id, escapeLabel(node.label));
|
||
}
|
||
|
||
function edgeDeclaration(edge, fromId, toId) {
|
||
const arrow = MERMAID_EDGE_ARROW[edge.kind];
|
||
if (!arrow) throw new Error(`flowchart-mermaid: unknown edge kind "${edge.kind}"`);
|
||
if (edge.label && edge.label.length > 0) {
|
||
return `${fromId} ${arrow}|${escapeLabel(edge.label)}| ${toId}`;
|
||
}
|
||
return `${fromId} ${arrow} ${toId}`;
|
||
}
|
||
|
||
function toMermaid(graph) {
|
||
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
||
throw new Error('flowchart-mermaid: graph must have nodes[] and edges[]');
|
||
}
|
||
const ids = assignIds(graph.nodes);
|
||
const lines = ['flowchart TD'];
|
||
for (const node of graph.nodes) {
|
||
lines.push(nodeDeclaration({ ...node, id: ids.get(node.id) }));
|
||
}
|
||
for (const edge of graph.edges) {
|
||
const fromId = ids.get(edge.fromNodeId);
|
||
const toId = ids.get(edge.toNodeId);
|
||
if (!fromId || !toId) continue; // skip dangling edges (defensive)
|
||
lines.push(edgeDeclaration(edge, fromId, toId));
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
|
||
|
||
// ========== flowchart-mermaid-parse (inlined v4.13.0) ==========
|
||
// Inverse of toMermaid. Hand-edited to drop the duplicate parser code —
|
||
// re-import this from src/flowchart/flowchart-mermaid-parse.js as a
|
||
// pure function bundle step. Since this is a bundle, we replicate the
|
||
// minimal API surface needed by the renderer here.
|
||
function unescapeLabel(label) {
|
||
return String(label || '')
|
||
.replace(/#quot;/g, '"')
|
||
.replace(/\\n/g, '\n');
|
||
}
|
||
function fromMermaid(source) {
|
||
const nodes = [];
|
||
const edges = [];
|
||
const idMap = new Map();
|
||
if (typeof source !== 'string') return { nodes, edges };
|
||
const lines = source
|
||
.split(/\r?\n/)
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0 && !l.startsWith('%%'));
|
||
const SHAPES = [
|
||
{ p: '[[', s: ']]', k: 'subroutine' },
|
||
{ p: '([', s: '])', k: 'terminator' },
|
||
{ p: '[/', s: '/]', k: 'document' },
|
||
{ p: '{', s: '}', k: 'decision' },
|
||
{ p: '[', s: ']', k: 'process' },
|
||
];
|
||
const ARROWS = { '-->': 'solid', '-.->': 'dotted', '==>': 'thick' };
|
||
let auto = 0;
|
||
for (const line of lines) {
|
||
if (/^flowchart\s+(TD|LR|BT|RL)/i.test(line)) continue;
|
||
let arrow = null,
|
||
arrowAt = -1;
|
||
for (const a of Object.keys(ARROWS)) {
|
||
const i = line.indexOf(a);
|
||
if (i !== -1 && (arrowAt === -1 || i < arrowAt)) {
|
||
arrow = a;
|
||
arrowAt = i;
|
||
}
|
||
}
|
||
if (arrow) {
|
||
const before = line.slice(0, arrowAt).trim();
|
||
const after = line.slice(arrowAt + arrow.length).trim();
|
||
const beforeLabel = /\|([^|]*)\|/.exec(before);
|
||
const afterLabel = /\|([^|]*)\|/.exec(after);
|
||
const from = beforeLabel ? before.replace(beforeLabel[0], '').trim() : before;
|
||
const to = afterLabel ? after.replace(afterLabel[0], '').trim() : after;
|
||
const label = (afterLabel && afterLabel[1]) || (beforeLabel && beforeLabel[1]) || '';
|
||
const fid = ensureId(from, idMap, nodes, () => autoNode(auto++));
|
||
const tid = ensureId(to, idMap, nodes, () => autoNode(auto++));
|
||
edges.push({
|
||
id: 'e_' + edges.length,
|
||
fromNodeId: fid,
|
||
toNodeId: tid,
|
||
kind: ARROWS[arrow],
|
||
label,
|
||
});
|
||
continue;
|
||
}
|
||
const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*(.*)$/.exec(line);
|
||
if (m) {
|
||
const id = m[1];
|
||
const body = m[2];
|
||
for (const sh of SHAPES) {
|
||
if (body.startsWith(sh.p) && body.endsWith(sh.s)) {
|
||
const label = unescapeLabel(body.slice(sh.p.length, body.length - sh.s.length));
|
||
idMap.set(id, 'n_' + id);
|
||
if (!nodes.find((n) => n.id === 'n_' + id)) {
|
||
nodes.push({
|
||
id: 'n_' + id,
|
||
kind: sh.k,
|
||
x: 40 + (nodes.length % 5) * 160,
|
||
y: 40 + Math.floor(nodes.length / 5) * 100,
|
||
label,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function ensureId(mid, map, ns, mk) {
|
||
if (map.has(mid)) return map.get(mid);
|
||
const n = mk();
|
||
ns.push(n);
|
||
map.set(mid, n.id);
|
||
return n.id;
|
||
}
|
||
function autoNode(n) {
|
||
return {
|
||
id: 'n_auto_' + n,
|
||
kind: 'process',
|
||
x: 40 + (n % 5) * 160,
|
||
y: 40 + Math.floor(n / 5) * 100,
|
||
label: '',
|
||
};
|
||
}
|
||
return { nodes, edges };
|
||
}
|
||
|
||
// ========== flowchart-store (inlined) ==========
|
||
// v4.12.0 — Nodes carry an optional `color` field; `setNodeColor(id, color)`
|
||
// mutates it, and serialize/deserialize round-trip it.
|
||
const STORE_NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
||
const STORE_EDGE_KINDS = ['solid', 'dotted', 'thick'];
|
||
const STORE_UNDO_LIMIT = 50;
|
||
const STORE_DEFAULT_COLOR = '#ffffff';
|
||
|
||
function storeClone(obj) {
|
||
return JSON.parse(JSON.stringify(obj));
|
||
}
|
||
|
||
function storeNewId(prefix) {
|
||
// 12 hex chars; monotonic enough for in-memory use.
|
||
return `${prefix}_${Math.random().toString(16).slice(2, 10)}${Date.now().toString(16).slice(-4)}`;
|
||
}
|
||
|
||
function isValidNode(node) {
|
||
return (
|
||
node &&
|
||
typeof node.id === 'string' &&
|
||
STORE_NODE_KINDS.includes(node.kind) &&
|
||
Number.isFinite(node.x) &&
|
||
Number.isFinite(node.y) &&
|
||
typeof node.label === 'string'
|
||
);
|
||
}
|
||
|
||
function normalizeColor(color) {
|
||
if (typeof color !== 'string' || color.length === 0) {
|
||
return { color: STORE_DEFAULT_COLOR };
|
||
}
|
||
const trimmed = color.trim();
|
||
const hex = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(trimmed);
|
||
if (!hex) return { color: STORE_DEFAULT_COLOR };
|
||
return { color: trimmed.startsWith('#') ? trimmed : `#${trimmed}` };
|
||
}
|
||
|
||
function isValidEdge(edge) {
|
||
return (
|
||
edge &&
|
||
typeof edge.id === 'string' &&
|
||
typeof edge.fromNodeId === 'string' &&
|
||
typeof edge.toNodeId === 'string' &&
|
||
STORE_EDGE_KINDS.includes(edge.kind)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {object} io
|
||
* @param {string} io.persistencePath Absolute path for auto-save JSON.
|
||
* @param {(path:string) => Promise<string|null>} io.readFile
|
||
* @param {(path:string, content:string) => Promise<void>} io.writeFile
|
||
* @param {() => number} io.now
|
||
*/
|
||
function createStore(io) {
|
||
if (!io || typeof io !== 'object') {
|
||
throw new Error('flowchart-store: io bundle is required');
|
||
}
|
||
|
||
let graph = { nodes: [], edges: [] };
|
||
const undoStack = [];
|
||
const redoStack = [];
|
||
const listeners = new Set();
|
||
|
||
function emit() {
|
||
for (const fn of listeners) {
|
||
try {
|
||
fn(getGraph());
|
||
} catch {
|
||
// Don't let a subscriber crash the store.
|
||
}
|
||
}
|
||
}
|
||
|
||
function snapshot() {
|
||
undoStack.push(storeClone(graph));
|
||
if (undoStack.length > STORE_UNDO_LIMIT) undoStack.shift();
|
||
redoStack.length = 0;
|
||
}
|
||
|
||
function getGraph() {
|
||
return storeClone(graph);
|
||
}
|
||
|
||
function addNode({ kind, x, y, label = '', color }) {
|
||
if (!STORE_NODE_KINDS.includes(kind)) {
|
||
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
||
}
|
||
snapshot();
|
||
const node = {
|
||
id: storeNewId('n'),
|
||
kind,
|
||
x,
|
||
y,
|
||
label,
|
||
...normalizeColor(color),
|
||
};
|
||
graph.nodes.push(node);
|
||
emit();
|
||
return node;
|
||
}
|
||
|
||
function findNodeIndex(id) {
|
||
return graph.nodes.findIndex((n) => n.id === id);
|
||
}
|
||
|
||
function findEdgeIndex(id) {
|
||
return graph.edges.findIndex((e) => e.id === id);
|
||
}
|
||
|
||
function moveNode(id, x, y) {
|
||
const idx = findNodeIndex(id);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
|
||
snapshot();
|
||
graph.nodes[idx] = { ...graph.nodes[idx], x, y };
|
||
emit();
|
||
}
|
||
|
||
function setNodeLabel(id, label) {
|
||
const idx = findNodeIndex(id);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
|
||
snapshot();
|
||
graph.nodes[idx] = { ...graph.nodes[idx], label };
|
||
emit();
|
||
}
|
||
|
||
function setNodeKind(id, kind) {
|
||
if (!STORE_NODE_KINDS.includes(kind)) {
|
||
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
||
}
|
||
const idx = findNodeIndex(id);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
|
||
snapshot();
|
||
graph.nodes[idx] = { ...graph.nodes[idx], kind };
|
||
emit();
|
||
}
|
||
|
||
function setNodeColor(id, color) {
|
||
const idx = findNodeIndex(id);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
|
||
snapshot();
|
||
graph.nodes[idx] = { ...graph.nodes[idx], ...normalizeColor(color) };
|
||
emit();
|
||
}
|
||
|
||
function removeNode(id) {
|
||
const idx = findNodeIndex(id);
|
||
if (idx === -1) return;
|
||
snapshot();
|
||
graph.nodes.splice(idx, 1);
|
||
graph.edges = graph.edges.filter((e) => e.fromNodeId !== id && e.toNodeId !== id);
|
||
emit();
|
||
}
|
||
|
||
function connect(fromNodeId, toNodeId, kind = 'solid') {
|
||
if (fromNodeId === toNodeId) {
|
||
throw new TypeError('flowchart-store: cannot connect a node to itself');
|
||
}
|
||
if (!STORE_EDGE_KINDS.includes(kind)) {
|
||
throw new Error(`flowchart-store: unknown edge kind "${kind}"`);
|
||
}
|
||
snapshot();
|
||
const edge = { id: storeNewId('e'), fromNodeId, toNodeId, kind };
|
||
graph.edges.push(edge);
|
||
emit();
|
||
return edge;
|
||
}
|
||
|
||
function disconnect(edgeId) {
|
||
const idx = findEdgeIndex(edgeId);
|
||
if (idx === -1) return;
|
||
snapshot();
|
||
graph.edges.splice(idx, 1);
|
||
emit();
|
||
}
|
||
|
||
function setEdgeKind(edgeId, kind) {
|
||
if (!STORE_EDGE_KINDS.includes(kind)) {
|
||
throw new Error(`flowchart-store: unknown edge kind "${kind}"`);
|
||
}
|
||
const idx = findEdgeIndex(edgeId);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`);
|
||
snapshot();
|
||
graph.edges[idx] = { ...graph.edges[idx], kind };
|
||
emit();
|
||
}
|
||
|
||
function setEdgeLabel(edgeId, label) {
|
||
const idx = findEdgeIndex(edgeId);
|
||
if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`);
|
||
snapshot();
|
||
graph.edges[idx] = { ...graph.edges[idx], label };
|
||
emit();
|
||
}
|
||
|
||
function undo() {
|
||
const prior = undoStack.pop();
|
||
if (!prior) return;
|
||
redoStack.push(storeClone(graph));
|
||
graph = prior;
|
||
emit();
|
||
}
|
||
|
||
function redo() {
|
||
const next = redoStack.pop();
|
||
if (!next) return;
|
||
undoStack.push(storeClone(graph));
|
||
graph = next;
|
||
emit();
|
||
}
|
||
|
||
function canUndo() {
|
||
return undoStack.length > 0;
|
||
}
|
||
|
||
function canRedo() {
|
||
return redoStack.length > 0;
|
||
}
|
||
|
||
function subscribe(fn) {
|
||
listeners.add(fn);
|
||
return () => listeners.delete(fn);
|
||
}
|
||
|
||
function serialize() {
|
||
return JSON.stringify(graph);
|
||
}
|
||
|
||
function deserialize(json) {
|
||
let parsed;
|
||
try {
|
||
parsed = typeof json === 'object' ? json : JSON.parse(json);
|
||
} catch {
|
||
graph = { nodes: [], edges: [] };
|
||
return;
|
||
}
|
||
const rawNodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : [];
|
||
const nodes = rawNodes.map((n) => ({ ...n, ...normalizeColor(n.color) }));
|
||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||
const edges = Array.isArray(parsed.edges)
|
||
? parsed.edges.filter(
|
||
(e) => isValidEdge(e) && nodeIds.has(e.fromNodeId) && nodeIds.has(e.toNodeId)
|
||
)
|
||
: [];
|
||
graph = { nodes, edges };
|
||
undoStack.length = 0;
|
||
redoStack.length = 0;
|
||
emit();
|
||
}
|
||
|
||
function toJSON() {
|
||
return storeClone(graph);
|
||
}
|
||
|
||
return {
|
||
getGraph,
|
||
addNode,
|
||
moveNode,
|
||
setNodeLabel,
|
||
setNodeKind,
|
||
setNodeColor,
|
||
removeNode,
|
||
connect,
|
||
disconnect,
|
||
setEdgeKind,
|
||
setEdgeLabel,
|
||
undo,
|
||
redo,
|
||
canUndo,
|
||
canRedo,
|
||
subscribe,
|
||
serialize,
|
||
deserialize,
|
||
toJSON,
|
||
};
|
||
}
|
||
|
||
window.FlowchartStore = {
|
||
create: createStore,
|
||
NODE_KINDS: STORE_NODE_KINDS,
|
||
EDGE_KINDS: STORE_EDGE_KINDS,
|
||
};
|
||
|
||
// ========== flowchart-canvas (inlined, no require) ==========
|
||
const CANVAS_SVG_NS = 'http://www.w3.org/2000/svg';
|
||
|
||
function canvasSvgEl(tag, attrs = {}) {
|
||
const el = document.createElementNS(CANVAS_SVG_NS, tag);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v === null || v === undefined) continue;
|
||
el.setAttribute(k, String(v));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function canvasEdgeStyle(kind) {
|
||
if (kind === 'dotted') return { 'stroke-dasharray': '4,4', 'stroke-width': 1 };
|
||
if (kind === 'thick') return { 'stroke-width': 3 };
|
||
return { 'stroke-width': 1 };
|
||
}
|
||
|
||
function canvasNodeCenter(node) {
|
||
return { x: node.x + DEFAULT_WIDTH / 2, y: node.y + DEFAULT_HEIGHT / 2 };
|
||
}
|
||
|
||
function createCanvas(container, store, opts = {}) {
|
||
const svg = canvasSvgEl('svg', {
|
||
class: 'flowchart-canvas',
|
||
width: '100%',
|
||
height: '100%',
|
||
viewBox: '0 0 1000 700',
|
||
role: 'img',
|
||
'aria-label': 'Flow chart canvas',
|
||
});
|
||
container.appendChild(svg);
|
||
|
||
// Layer order: edges first (under nodes), then nodes.
|
||
const edgesLayer = canvasSvgEl('g', { class: 'flowchart-edges' });
|
||
const nodesLayer = canvasSvgEl('g', { class: 'flowchart-nodes' });
|
||
svg.appendChild(edgesLayer);
|
||
svg.appendChild(nodesLayer);
|
||
|
||
let selectedNodeId = null;
|
||
let selectedEdgeId = null;
|
||
let unsubscribe = null;
|
||
let destroyed = false;
|
||
|
||
function render() {
|
||
if (destroyed) return;
|
||
const graph = store.getGraph();
|
||
edgesLayer.replaceChildren();
|
||
nodesLayer.replaceChildren();
|
||
|
||
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
||
|
||
for (const edge of graph.edges) {
|
||
const from = nodeById.get(edge.fromNodeId);
|
||
const to = nodeById.get(edge.toNodeId);
|
||
if (!from || !to) continue;
|
||
const fc = canvasNodeCenter(from);
|
||
const tc = canvasNodeCenter(to);
|
||
const line = canvasSvgEl('line', {
|
||
x1: fc.x,
|
||
y1: fc.y,
|
||
x2: tc.x,
|
||
y2: tc.y,
|
||
stroke: 'currentColor',
|
||
'data-edge-id': edge.id,
|
||
...canvasEdgeStyle(edge.kind),
|
||
class: 'flowchart-edge' + (edge.id === selectedEdgeId ? ' selected' : ''),
|
||
});
|
||
edgesLayer.appendChild(line);
|
||
if (edge.label) {
|
||
const mx = (fc.x + tc.x) / 2;
|
||
const my = (fc.y + tc.y) / 2;
|
||
const bg = canvasSvgEl('rect', {
|
||
x: mx - 20,
|
||
y: my - 8,
|
||
width: 40,
|
||
height: 16,
|
||
fill: 'var(--bg-primary, #fff)',
|
||
'data-edge-label-bg': edge.id,
|
||
});
|
||
edgesLayer.appendChild(bg);
|
||
const t = canvasSvgEl('text', {
|
||
x: mx,
|
||
y: my + 4,
|
||
'text-anchor': 'middle',
|
||
'font-size': 11,
|
||
fill: 'currentColor',
|
||
'data-edge-label': edge.id,
|
||
});
|
||
t.textContent = edge.label;
|
||
edgesLayer.appendChild(t);
|
||
}
|
||
}
|
||
|
||
for (const node of graph.nodes) {
|
||
const g = canvasSvgEl('g', {
|
||
'data-node-id': node.id,
|
||
transform: `translate(${node.x},${node.y})`,
|
||
class: 'flowchart-node' + (node.id === selectedNodeId ? ' selected' : ''),
|
||
tabindex: '0',
|
||
'aria-label': `${node.kind}: ${node.label || '(no label)'}`,
|
||
});
|
||
// v4.12.0 — pass the per-node fill color through to the shape SVG.
|
||
// `shapeSvg` itself falls back to #ffffff when the color is missing
|
||
// or invalid, so old (uncolored) sessions keep rendering correctly.
|
||
g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT, node.color);
|
||
const text = canvasSvgEl('text', {
|
||
x: DEFAULT_WIDTH / 2,
|
||
y: DEFAULT_HEIGHT / 2 + 4,
|
||
'text-anchor': 'middle',
|
||
'font-size': 13,
|
||
fill: 'currentColor',
|
||
'pointer-events': 'none',
|
||
});
|
||
text.textContent = node.label || ' ';
|
||
g.appendChild(text);
|
||
nodesLayer.appendChild(g);
|
||
}
|
||
}
|
||
|
||
// Surgical selection highlight — toggles the `.selected` class on the
|
||
// existing SVG <g> / <line> elements without going through render() (which
|
||
// replaces all children and would detach the very element the user's
|
||
// pointer is still on, breaking pointermove/pointerup bubbling on the
|
||
// same node during a drag). Called from onPointerDown after the internal
|
||
// selectedNodeId/selectedEdgeId update. The existing
|
||
// .flowchart-node.selected / .flowchart-edge.selected CSS rules (see
|
||
// src/styles-sidebar.css) handle the visual highlight.
|
||
function applySelectionHighlight() {
|
||
if (destroyed) return;
|
||
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
||
nodeEls.forEach((g) => {
|
||
const id = g.getAttribute('data-node-id');
|
||
g.classList.toggle('selected', id === selectedNodeId);
|
||
});
|
||
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
|
||
edgeEls.forEach((l) => {
|
||
const id = l.getAttribute('data-edge-id');
|
||
l.classList.toggle('selected', id === selectedEdgeId);
|
||
});
|
||
}
|
||
|
||
// ----- pointer events -----
|
||
let dragState = null;
|
||
|
||
function getSvgPoint(clientX, clientY) {
|
||
const rect = svg.getBoundingClientRect();
|
||
// Naive linear mapping into the viewBox (works in jsdom and roughly in
|
||
// production for the bounded viewport; v2 can add proper screenCTM).
|
||
const vb = svg.viewBox.baseVal;
|
||
const scaleX = vb.width / rect.width;
|
||
const scaleY = vb.height / rect.height;
|
||
return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
|
||
}
|
||
|
||
function onPointerDown(ev) {
|
||
const nodeG = ev.target.closest('g[data-node-id]');
|
||
if (nodeG) {
|
||
const nodeId = nodeG.getAttribute('data-node-id');
|
||
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
||
if (!node) return;
|
||
selectedNodeId = nodeId;
|
||
selectedEdgeId = null;
|
||
// Paint the .flowchart-node.selected highlight immediately on a bare
|
||
// click (without a drag). store.subscribe would normally trigger
|
||
// render() after moveNode; a click-only path has no store mutation, so
|
||
// we apply the highlight ourselves. Surgical toggle — not a full
|
||
// render() — so the pointerdown target stays attached and subsequent
|
||
// pointermove/pointerup can still bubble on the same element.
|
||
applySelectionHighlight();
|
||
const start = getSvgPoint(ev.clientX, ev.clientY);
|
||
if (ev.altKey) {
|
||
// Alt+drag = create a new edge from this node to wherever the pointer
|
||
// is released. Track source node only; movement does not move nodes.
|
||
dragState = { mode: 'connect', sourceNodeId: nodeId };
|
||
console.log('[flowchart] pointerdown on node', nodeId, '— altKey=true (connect mode)');
|
||
} else {
|
||
dragState = {
|
||
mode: 'move',
|
||
nodeId,
|
||
startX: node.x,
|
||
startY: node.y,
|
||
pointerX: start.x,
|
||
pointerY: start.y,
|
||
};
|
||
console.log('[flowchart] pointerdown on node', nodeId, '— altKey=false (move mode)');
|
||
}
|
||
// Notify the panel so its internal selection state (used by Delete /
|
||
// Backspace keyboard shortcuts) tracks the canvas selection.
|
||
if (typeof opts.onNodeClick === 'function') {
|
||
opts.onNodeClick(nodeId, ev);
|
||
}
|
||
ev.preventDefault();
|
||
return;
|
||
}
|
||
const edgeLine = ev.target.closest('line[data-edge-id]');
|
||
if (edgeLine) {
|
||
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
|
||
selectedNodeId = null;
|
||
// Same reasoning as the node branch above: paint the edge highlight
|
||
// immediately so a click-without-drag isn't invisible until the next
|
||
// store mutation triggers a re-render.
|
||
applySelectionHighlight();
|
||
console.log('[flowchart] pointerdown on edge', selectedEdgeId);
|
||
if (typeof opts.onEdgeClick === 'function') {
|
||
opts.onEdgeClick(selectedEdgeId, ev);
|
||
}
|
||
ev.preventDefault();
|
||
return;
|
||
}
|
||
// Click on empty canvas: add a process node at the click point.
|
||
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
||
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
||
console.log('[flowchart] pointerdown on empty canvas — adding process node at', x, y);
|
||
store.addNode({ kind: 'process', x, y, label: 'Node' });
|
||
ev.preventDefault();
|
||
}
|
||
}
|
||
|
||
function onPointerMove(ev) {
|
||
if (!dragState) return;
|
||
if (dragState.mode === 'move') {
|
||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||
const dx = p.x - dragState.pointerX;
|
||
const dy = p.y - dragState.pointerY;
|
||
store.moveNode(dragState.nodeId, dragState.startX + dx, dragState.startY + dy);
|
||
}
|
||
// connect-mode: visual feedback deferred to v2 (no preview line yet).
|
||
}
|
||
|
||
function onPointerUp(ev) {
|
||
if (dragState && dragState.mode === 'connect') {
|
||
const targetG = ev.target && ev.target.closest && ev.target.closest('g[data-node-id]');
|
||
if (targetG) {
|
||
const targetId = targetG.getAttribute('data-node-id');
|
||
if (targetId && targetId !== dragState.sourceNodeId) {
|
||
try {
|
||
const edge = store.connect(dragState.sourceNodeId, targetId, 'solid');
|
||
console.log(
|
||
'[flowchart] pointerup connect:',
|
||
dragState.sourceNodeId,
|
||
'->',
|
||
targetId,
|
||
'new edge',
|
||
edge && edge.id
|
||
);
|
||
} catch (err) {
|
||
console.log('[flowchart] pointerup connect failed:', err && err.message);
|
||
}
|
||
} else {
|
||
console.log('[flowchart] pointerup connect dropped (self-loop or no target)', targetId);
|
||
}
|
||
} else {
|
||
console.log('[flowchart] pointerup connect dropped (no target node under pointer)');
|
||
}
|
||
} else if (dragState && dragState.mode === 'move') {
|
||
console.log('[flowchart] pointerup move complete for node', dragState.nodeId);
|
||
}
|
||
dragState = null;
|
||
}
|
||
|
||
function onDblClick(ev) {
|
||
const nodeG = ev.target.closest('g[data-node-id]');
|
||
if (!nodeG) return;
|
||
const nodeId = nodeG.getAttribute('data-node-id');
|
||
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
|
||
if (!node) return;
|
||
console.log('[flowchart] dblclick on node', nodeId, '— opening label editor');
|
||
// Inline-edit overlay: foreignObject-free — just use a positioned HTML
|
||
// <input> overlaid on top of the node, in the canvas's parent.
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = node.label;
|
||
input.className = 'flowchart-label-input';
|
||
const rect = nodeG.getBoundingClientRect();
|
||
input.style.position = 'fixed';
|
||
input.style.left = `${rect.left}px`;
|
||
input.style.top = `${rect.top}px`;
|
||
input.style.width = `${rect.width}px`;
|
||
input.style.height = `${rect.height}px`;
|
||
container.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
const finish = (commit) => {
|
||
if (commit) store.setNodeLabel(nodeId, input.value);
|
||
input.remove();
|
||
};
|
||
input.addEventListener('blur', () => finish(true));
|
||
input.addEventListener('keydown', (kev) => {
|
||
if (kev.key === 'Enter') finish(true);
|
||
else if (kev.key === 'Escape') finish(false);
|
||
});
|
||
ev.preventDefault();
|
||
}
|
||
|
||
function onContextMenu(ev) {
|
||
const nodeG = ev.target.closest('g[data-node-id]');
|
||
if (nodeG) {
|
||
const nodeId = nodeG.getAttribute('data-node-id');
|
||
console.log('[flowchart] contextmenu on node', nodeId, '— opening shape picker');
|
||
ev.preventDefault();
|
||
if (typeof opts.onShapeMenu === 'function') opts.onShapeMenu(nodeId, ev);
|
||
}
|
||
}
|
||
|
||
svg.addEventListener('pointerdown', onPointerDown);
|
||
window.addEventListener('pointermove', onPointerMove);
|
||
window.addEventListener('pointerup', onPointerUp);
|
||
svg.addEventListener('dblclick', onDblClick);
|
||
svg.addEventListener('contextmenu', onContextMenu);
|
||
|
||
unsubscribe = store.subscribe(render);
|
||
render();
|
||
|
||
function destroy() {
|
||
if (destroyed) return;
|
||
destroyed = true;
|
||
if (typeof unsubscribe === 'function') unsubscribe();
|
||
svg.removeEventListener('pointerdown', onPointerDown);
|
||
window.removeEventListener('pointermove', onPointerMove);
|
||
window.removeEventListener('pointerup', onPointerUp);
|
||
svg.removeEventListener('dblclick', onDblClick);
|
||
svg.removeEventListener('contextmenu', onContextMenu);
|
||
svg.remove();
|
||
}
|
||
|
||
return { destroy, getSvg: () => svg };
|
||
}
|
||
|
||
// ========== Globals — set BEFORE the controller boots ==========
|
||
window.FlowchartCanvas = { createCanvas, SHAPE_KINDS };
|
||
|
||
// ========== Controller bootstrap (inline) ==========
|
||
const api =
|
||
window.electronAPI && window.electronAPI.flowchart ? window.electronAPI.flowchart : null;
|
||
|
||
const els = {
|
||
canvasHost: document.getElementById('canvas-host'),
|
||
previewSource: document.getElementById('preview-source'),
|
||
previewRender: document.getElementById('preview-render'),
|
||
btnInsert: document.getElementById('fc-btn-insert'),
|
||
btnSave: document.getElementById('fc-btn-save'),
|
||
btnOpen: document.getElementById('fc-btn-open'),
|
||
btnReset: document.getElementById('fc-btn-reset'),
|
||
btnUndo: document.getElementById('fc-btn-undo'),
|
||
btnRedo: document.getElementById('fc-btn-redo'),
|
||
historyCount: document.getElementById('fc-history-count'),
|
||
status: document.getElementById('fc-status'),
|
||
// v4.11.0 — node-list panel (button-driven UI). Every mutation goes
|
||
// through controls in this panel; the canvas is purely visual.
|
||
nodelistUl: document.getElementById('fc-nodelist-ul'),
|
||
edgelistUl: document.getElementById('fc-edgelist-ul'),
|
||
nodeCountEl: document.getElementById('fc-node-count'),
|
||
edgeCountEl: document.getElementById('fc-edge-count'),
|
||
connectFromSel: document.getElementById('fc-connect-from'),
|
||
connectToSel: document.getElementById('fc-connect-to'),
|
||
connectBtn: document.getElementById('fc-connect-btn'),
|
||
connectCancelBtn: document.getElementById('fc-connect-cancel'),
|
||
};
|
||
|
||
console.log('[flowchart] DOM loaded');
|
||
|
||
// Guard rails — these should never be null in a correctly-launched window.
|
||
// Fail loudly with a visible status message rather than silently no-op'ing
|
||
// if the HTML or the preload bridge are misconfigured.
|
||
function fatal(msg) {
|
||
if (els.status) els.status.textContent = msg;
|
||
console.error('[flowchart-controller]', msg);
|
||
}
|
||
if (!els.canvasHost || !els.previewSource || !els.previewRender) {
|
||
fatal('Required DOM elements missing — check src/flowchart-generator.html');
|
||
return;
|
||
}
|
||
// v4.9.8 — since the pure modules are bundled into this file, the four
|
||
// window.FlowchartXxx globals are guaranteed to be set above. The guard
|
||
// below is now belt-and-braces (it still trips if this file is loaded in
|
||
// some weird context that strips `window`).
|
||
if (
|
||
!window.FlowchartStore ||
|
||
!window.FlowchartCanvas ||
|
||
!window.FlowchartMermaid ||
|
||
!window.FlowchartShapes
|
||
) {
|
||
fatal('Flowchart pure modules not loaded — verify flowchart-bundle.js ran in full');
|
||
return;
|
||
}
|
||
if (!api) {
|
||
fatal('window.electronAPI.flowchart missing — check src/preload.js');
|
||
return;
|
||
}
|
||
|
||
const PREVIEW_DEBOUNCE_MS = 250;
|
||
const PERSIST_DEBOUNCE_MS = 500;
|
||
const PERSISTENCE_FILENAME = 'flowchart-session.json';
|
||
|
||
let _userDataPath = null;
|
||
let _persistenceFile = null;
|
||
let _previewTimer = null;
|
||
let _persistTimer = null;
|
||
let _store = null;
|
||
let _canvas = null;
|
||
// v4.11.0 — no more selection state. Every mutation is initiated from
|
||
// a button in the #fc-nodelist panel; the canvas is purely visual.
|
||
|
||
function setStatus(msg) {
|
||
if (els.status) els.status.textContent = msg || '';
|
||
if (msg) setTimeout(() => setStatus(''), 2500);
|
||
}
|
||
|
||
function runPreview() {
|
||
if (!_store || !els.previewSource || !els.previewRender) return;
|
||
const graph = _store.getGraph();
|
||
const source = toMermaid(graph);
|
||
els.previewSource.textContent = source;
|
||
// v4.10.0 — the canvas on the left IS the rendered chart (hand-rolled
|
||
// SVG, no Mermaid runtime needed in this window). The right pane
|
||
// shows the Mermaid source for inspection only — seed a static info
|
||
// card explaining the layout so users don't think the right pane is
|
||
// broken / blank.
|
||
if (els.previewRender.firstElementChild === null) {
|
||
els.previewRender.innerHTML =
|
||
'<div class="fc-preview-render-note">' +
|
||
'Visual chart is rendered on the left canvas panel. ' +
|
||
'Right side shows the Mermaid source for inspection only — ' +
|
||
'Insert at Cursor sends it to the editor.' +
|
||
'</div>';
|
||
}
|
||
}
|
||
|
||
function debouncedPreview() {
|
||
if (_previewTimer) clearTimeout(_previewTimer);
|
||
_previewTimer = setTimeout(() => {
|
||
_previewTimer = null;
|
||
runPreview();
|
||
}, PREVIEW_DEBOUNCE_MS);
|
||
}
|
||
|
||
function debouncedPersist() {
|
||
if (_persistTimer) clearTimeout(_persistTimer);
|
||
_persistTimer = setTimeout(() => {
|
||
_persistTimer = null;
|
||
if (!_store || !_persistenceFile) return;
|
||
api
|
||
.writeFile(_persistenceFile, _store.serialize())
|
||
.then(() => setStatus('Saved'))
|
||
.catch((err) => setStatus(`Save failed: ${err && err.message ? err.message : err}`));
|
||
}, PERSIST_DEBOUNCE_MS);
|
||
}
|
||
|
||
// ========== Inline modal dialog (replacement for window.prompt/confirm) ==========
|
||
// v4.9.9 — window.prompt and window.confirm are disabled in Electron
|
||
// renderer contexts (the BrowserWindow of a BrowserView/WebContentsView
|
||
// returns undefined when called). Build minimal modal interactions on top
|
||
// of plain DOM nodes. Resolves with the entered string (or null on
|
||
// cancel/Esc/backdrop-click) for promptInline, and with a boolean for
|
||
// confirmInline.
|
||
function promptInline({ title, message, defaultValue = '', kind = 'text' }) {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
Object.assign(overlay.style, {
|
||
position: 'fixed',
|
||
inset: '0',
|
||
background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 99999,
|
||
});
|
||
const box = document.createElement('div');
|
||
Object.assign(box.style, {
|
||
background: '#ffffff',
|
||
color: '#1f2328',
|
||
border: '1px solid #d0d7de',
|
||
borderRadius: '8px',
|
||
padding: '20px 24px',
|
||
minWidth: '320px',
|
||
maxWidth: '480px',
|
||
boxShadow: '0 12px 32px rgba(0,0,0,0.25)',
|
||
fontFamily: 'system-ui, sans-serif',
|
||
});
|
||
if (title) {
|
||
const h = document.createElement('div');
|
||
h.textContent = title;
|
||
Object.assign(h.style, { fontSize: '14px', fontWeight: '600', marginBottom: '12px' });
|
||
box.appendChild(h);
|
||
}
|
||
if (message) {
|
||
const m = document.createElement('div');
|
||
m.textContent = message;
|
||
Object.assign(m.style, {
|
||
fontSize: '12px',
|
||
color: '#57606a',
|
||
marginBottom: '12px',
|
||
whiteSpace: 'pre-wrap',
|
||
});
|
||
box.appendChild(m);
|
||
}
|
||
const input = document.createElement('input');
|
||
input.type = kind === 'number' ? 'number' : 'text';
|
||
input.value = defaultValue;
|
||
Object.assign(input.style, {
|
||
width: '100%',
|
||
padding: '8px 10px',
|
||
fontSize: '13px',
|
||
border: '1px solid #d0d7de',
|
||
borderRadius: '4px',
|
||
boxSizing: 'border-box',
|
||
});
|
||
box.appendChild(input);
|
||
|
||
const buttons = document.createElement('div');
|
||
Object.assign(buttons.style, {
|
||
marginTop: '14px',
|
||
display: 'flex',
|
||
gap: '8px',
|
||
justifyContent: 'flex-end',
|
||
});
|
||
const ok = document.createElement('button');
|
||
ok.textContent = 'OK';
|
||
Object.assign(ok.style, {
|
||
padding: '6px 14px',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
background: '#1f883d',
|
||
color: '#ffffff',
|
||
fontSize: '13px',
|
||
cursor: 'pointer',
|
||
});
|
||
const cancel = document.createElement('button');
|
||
cancel.textContent = 'Cancel';
|
||
Object.assign(cancel.style, {
|
||
padding: '6px 14px',
|
||
border: '1px solid #d0d7de',
|
||
borderRadius: '4px',
|
||
background: '#f6f8fa',
|
||
color: '#1f2328',
|
||
fontSize: '13px',
|
||
cursor: 'pointer',
|
||
});
|
||
buttons.appendChild(cancel);
|
||
buttons.appendChild(ok);
|
||
box.appendChild(buttons);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
let resolved = false;
|
||
const cleanup = (val) => {
|
||
if (resolved) return;
|
||
resolved = true;
|
||
document.body.removeChild(overlay);
|
||
resolve(val);
|
||
};
|
||
ok.addEventListener('click', () => cleanup(input.value || null));
|
||
cancel.addEventListener('click', () => cleanup(null));
|
||
overlay.addEventListener('click', (ev) => {
|
||
if (ev.target === overlay) cleanup(null);
|
||
});
|
||
input.addEventListener('keydown', (kev) => {
|
||
if (kev.key === 'Enter') cleanup(input.value || null);
|
||
if (kev.key === 'Escape') cleanup(null);
|
||
});
|
||
setTimeout(() => input.focus(), 0);
|
||
});
|
||
}
|
||
|
||
function confirmInline({ title, message, danger = false }) {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
Object.assign(overlay.style, {
|
||
position: 'fixed',
|
||
inset: '0',
|
||
background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 99999,
|
||
});
|
||
const box = document.createElement('div');
|
||
Object.assign(box.style, {
|
||
background: '#ffffff',
|
||
color: '#1f2328',
|
||
border: '1px solid #d0d7de',
|
||
borderRadius: '8px',
|
||
padding: '20px 24px',
|
||
minWidth: '320px',
|
||
maxWidth: '480px',
|
||
boxShadow: '0 12px 32px rgba(0,0,0,0.25)',
|
||
fontFamily: 'system-ui, sans-serif',
|
||
});
|
||
if (title) {
|
||
const h = document.createElement('div');
|
||
h.textContent = title;
|
||
Object.assign(h.style, { fontSize: '14px', fontWeight: '600', marginBottom: '12px' });
|
||
box.appendChild(h);
|
||
}
|
||
if (message) {
|
||
const m = document.createElement('div');
|
||
m.textContent = message;
|
||
Object.assign(m.style, {
|
||
fontSize: '13px',
|
||
color: '#1f2328',
|
||
marginBottom: '14px',
|
||
whiteSpace: 'pre-wrap',
|
||
});
|
||
box.appendChild(m);
|
||
}
|
||
const buttons = document.createElement('div');
|
||
Object.assign(buttons.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end' });
|
||
const ok = document.createElement('button');
|
||
ok.textContent = danger ? 'Delete' : 'OK';
|
||
Object.assign(ok.style, {
|
||
padding: '6px 14px',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
background: danger ? '#cf222e' : '#1f883d',
|
||
color: '#ffffff',
|
||
fontSize: '13px',
|
||
cursor: 'pointer',
|
||
});
|
||
const cancel = document.createElement('button');
|
||
cancel.textContent = 'Cancel';
|
||
Object.assign(cancel.style, {
|
||
padding: '6px 14px',
|
||
border: '1px solid #d0d7de',
|
||
borderRadius: '4px',
|
||
background: '#f6f8fa',
|
||
color: '#1f2328',
|
||
fontSize: '13px',
|
||
cursor: 'pointer',
|
||
});
|
||
buttons.appendChild(cancel);
|
||
buttons.appendChild(ok);
|
||
box.appendChild(buttons);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
let resolved = false;
|
||
const cleanup = (val) => {
|
||
if (resolved) return;
|
||
resolved = true;
|
||
document.body.removeChild(overlay);
|
||
resolve(val);
|
||
};
|
||
ok.addEventListener('click', () => cleanup(true));
|
||
cancel.addEventListener('click', () => cleanup(false));
|
||
overlay.addEventListener('click', (ev) => {
|
||
if (ev.target === overlay) cleanup(false);
|
||
});
|
||
document.addEventListener('keydown', function onKey(ev) {
|
||
if (ev.key === 'Enter') {
|
||
cleanup(true);
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
if (ev.key === 'Escape') {
|
||
cleanup(false);
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ========== Node-list panel (v4.11.0) ==========
|
||
// Button-driven UI. The #fc-nodelist panel below the canvas hosts
|
||
// every mutation: add/delete node, change kind, edit label,
|
||
// add/delete edge, change edge kind, edit edge label. The canvas
|
||
// itself is purely visual — no click hit-testing, no selection
|
||
// state. The panel is re-rendered on every store mutation.
|
||
|
||
const SHAPE_LABEL = {
|
||
process: 'Process',
|
||
decision: 'Decision',
|
||
terminator: 'Terminator',
|
||
subroutine: 'Subroutine',
|
||
document: 'Document',
|
||
};
|
||
const EDGE_LABEL = {
|
||
solid: 'Solid',
|
||
dotted: 'Dotted',
|
||
thick: 'Thick',
|
||
};
|
||
const SHAPE_KINDS_FOR_UI = Object.keys(SHAPE_LABEL);
|
||
const EDGE_KINDS_FOR_UI = Object.keys(EDGE_LABEL);
|
||
|
||
function shapeLabel(kind) {
|
||
return SHAPE_LABEL[kind] || kind;
|
||
}
|
||
function edgeLabel(kind) {
|
||
return EDGE_LABEL[kind] || kind;
|
||
}
|
||
|
||
function rerenderNodeList() {
|
||
if (!_store) return;
|
||
const graph = _store.getGraph();
|
||
|
||
if (els.nodeCountEl) els.nodeCountEl.textContent = String(graph.nodes.length);
|
||
if (els.edgeCountEl) els.edgeCountEl.textContent = String(graph.edges.length);
|
||
|
||
// --- Nodes list ---
|
||
if (els.nodelistUl) {
|
||
els.nodelistUl.replaceChildren();
|
||
for (const node of graph.nodes) {
|
||
const li = document.createElement('li');
|
||
|
||
const idSpan = document.createElement('span');
|
||
idSpan.className = 'fc-node-id';
|
||
idSpan.textContent = node.id.slice(0, 8);
|
||
li.appendChild(idSpan);
|
||
|
||
const kindSel = document.createElement('select');
|
||
for (const k of SHAPE_KINDS_FOR_UI) {
|
||
const opt = document.createElement('option');
|
||
opt.value = k;
|
||
opt.textContent = shapeLabel(k);
|
||
if (k === node.kind) opt.selected = true;
|
||
kindSel.appendChild(opt);
|
||
}
|
||
kindSel.addEventListener('change', () => {
|
||
if (_store) _store.setNodeKind(node.id, kindSel.value);
|
||
});
|
||
li.appendChild(kindSel);
|
||
|
||
const labelInput = document.createElement('input');
|
||
labelInput.type = 'text';
|
||
labelInput.value = node.label || '';
|
||
labelInput.setAttribute('placeholder', 'Label');
|
||
labelInput.addEventListener('input', () => {
|
||
if (_store) _store.setNodeLabel(node.id, labelInput.value);
|
||
});
|
||
li.appendChild(labelInput);
|
||
|
||
// v4.12.0 — per-node fill color. Native <input type="color"> opens a
|
||
// platform color picker (presets + custom). We listen for `input`
|
||
// (continuous as the user drags) so the canvas re-renders live.
|
||
const colorInput = document.createElement('input');
|
||
colorInput.type = 'color';
|
||
colorInput.value = node.color || '#ffffff';
|
||
colorInput.title = 'Node fill color';
|
||
colorInput.setAttribute('aria-label', 'Node fill color');
|
||
colorInput.addEventListener('input', () => {
|
||
if (_store) _store.setNodeColor(node.id, colorInput.value);
|
||
});
|
||
li.appendChild(colorInput);
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.textContent = '×';
|
||
delBtn.className = 'fc-delete';
|
||
delBtn.title = 'Delete this node';
|
||
delBtn.addEventListener('click', () => {
|
||
if (_store) _store.removeNode(node.id);
|
||
});
|
||
li.appendChild(delBtn);
|
||
|
||
els.nodelistUl.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// --- Edges list ---
|
||
if (els.edgelistUl) {
|
||
els.edgelistUl.replaceChildren();
|
||
for (const edge of graph.edges) {
|
||
const li = document.createElement('li');
|
||
|
||
const idSpan = document.createElement('span');
|
||
idSpan.className = 'fc-node-id';
|
||
const fromShort = edge.fromNodeId ? edge.fromNodeId.slice(0, 4) : '?';
|
||
const toShort = edge.toNodeId ? edge.toNodeId.slice(0, 4) : '?';
|
||
idSpan.textContent = `${fromShort}→${toShort}`;
|
||
li.appendChild(idSpan);
|
||
|
||
const kindSel = document.createElement('select');
|
||
for (const k of EDGE_KINDS_FOR_UI) {
|
||
const opt = document.createElement('option');
|
||
opt.value = k;
|
||
opt.textContent = edgeLabel(k);
|
||
if (k === edge.kind) opt.selected = true;
|
||
kindSel.appendChild(opt);
|
||
}
|
||
kindSel.addEventListener('change', () => {
|
||
if (_store) _store.setEdgeKind(edge.id, kindSel.value);
|
||
});
|
||
li.appendChild(kindSel);
|
||
|
||
const labelInput = document.createElement('input');
|
||
labelInput.type = 'text';
|
||
labelInput.value = edge.label || '';
|
||
labelInput.setAttribute('placeholder', 'Label');
|
||
labelInput.addEventListener('input', () => {
|
||
if (_store) _store.setEdgeLabel(edge.id, labelInput.value);
|
||
});
|
||
li.appendChild(labelInput);
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.textContent = '×';
|
||
delBtn.className = 'fc-delete';
|
||
delBtn.title = 'Delete this edge';
|
||
delBtn.addEventListener('click', () => {
|
||
if (_store) _store.disconnect(edge.id);
|
||
});
|
||
li.appendChild(delBtn);
|
||
|
||
els.edgelistUl.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// --- Connect dropdowns (from / to) ---
|
||
if (els.connectFromSel && els.connectToSel) {
|
||
const prevFrom = els.connectFromSel.value;
|
||
const prevTo = els.connectToSel.value;
|
||
els.connectFromSel.replaceChildren();
|
||
els.connectToSel.replaceChildren();
|
||
for (const n of graph.nodes) {
|
||
const o1 = document.createElement('option');
|
||
o1.value = n.id;
|
||
o1.textContent = `${n.id.slice(0, 8)} (${shapeLabel(n.kind)})`;
|
||
els.connectFromSel.appendChild(o1);
|
||
|
||
const o2 = document.createElement('option');
|
||
o2.value = n.id;
|
||
o2.textContent = `${n.id.slice(0, 8)} (${shapeLabel(n.kind)})`;
|
||
els.connectToSel.appendChild(o2);
|
||
}
|
||
// Restore previous selection if the node still exists.
|
||
const stillExists = (id) => id && graph.nodes.some((n) => n.id === id);
|
||
if (stillExists(prevFrom)) els.connectFromSel.value = prevFrom;
|
||
if (stillExists(prevTo)) els.connectToSel.value = prevTo;
|
||
}
|
||
}
|
||
|
||
// Wire add-node buttons (in the add-row).
|
||
document.querySelectorAll('.fc-add-row .fc-btn').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (!_store) return;
|
||
const kind = btn.getAttribute('data-add');
|
||
if (!kind || !SHAPE_KINDS_FOR_UI.includes(kind)) return;
|
||
// Place new node at the next free spot (simple grid layout).
|
||
const graph = _store.getGraph();
|
||
const idx = graph.nodes.length;
|
||
const col = idx % 4;
|
||
const row = Math.floor(idx / 4);
|
||
_store.addNode({
|
||
kind,
|
||
x: 50 + col * 180,
|
||
y: 50 + row * 100,
|
||
label: shapeLabel(kind),
|
||
});
|
||
});
|
||
});
|
||
|
||
// Wire connect-edge button.
|
||
if (els.connectBtn) {
|
||
els.connectBtn.addEventListener('click', () => {
|
||
if (!_store || !els.connectFromSel || !els.connectToSel) return;
|
||
const from = els.connectFromSel.value;
|
||
const to = els.connectToSel.value;
|
||
if (!from || !to) {
|
||
setStatus('Add at least two nodes first');
|
||
return;
|
||
}
|
||
if (from === to) {
|
||
setStatus('Select two different nodes to connect');
|
||
return;
|
||
}
|
||
try {
|
||
_store.connect(from, to, 'solid');
|
||
} catch (err) {
|
||
setStatus(`Connect failed: ${err && err.message ? err.message : err}`);
|
||
}
|
||
});
|
||
}
|
||
if (els.connectCancelBtn) {
|
||
els.connectCancelBtn.addEventListener('click', () => {
|
||
// Just rebuild the dropdowns from the current graph.
|
||
rerenderNodeList();
|
||
});
|
||
}
|
||
|
||
async function bootstrap() {
|
||
console.log('[flowchart] bootstrap: resolving userData path');
|
||
// Resolve the userData path ONCE on mount. The persistence path is
|
||
// interpolated into a string on every read/write; calling the async
|
||
// api.getUserDataPath() directly would coerce the Promise to
|
||
// "[object Promise]" and break the userData sandbox check in
|
||
// main.js:write-text-file.
|
||
try {
|
||
_userDataPath = await api.getUserDataPath();
|
||
} catch (err) {
|
||
fatal(`Could not resolve userData path: ${err && err.message ? err.message : err}`);
|
||
return;
|
||
}
|
||
_persistenceFile = `${_userDataPath}/${PERSISTENCE_FILENAME}`;
|
||
console.log('[flowchart] bootstrap: persistence file =', _persistenceFile);
|
||
|
||
_store = createStore({
|
||
persistencePath: _persistenceFile,
|
||
readFile: api.readFile,
|
||
writeFile: api.writeFile,
|
||
now: () => Date.now(),
|
||
});
|
||
console.log('[flowchart] bootstrap: store created');
|
||
|
||
_canvas = createCanvas(els.canvasHost, _store, {
|
||
// v4.11.0 — the canvas is purely visual. All mutations are driven
|
||
// from the #fc-nodelist panel below the canvas (see the wiring
|
||
// above). Canvas click handlers exist for drag-to-move but the
|
||
// selection / shape-menu callbacks are no-ops now.
|
||
onNodeClick: () => {},
|
||
onEdgeClick: () => {},
|
||
onShapeMenu: () => {},
|
||
});
|
||
console.log('[flowchart] bootstrap: canvas rendered');
|
||
|
||
_store.subscribe(() => {
|
||
debouncedPreview();
|
||
debouncedPersist();
|
||
// Keep the node-list panel in sync with every mutation.
|
||
rerenderNodeList();
|
||
// v4.13.0 — keep the undo/redo buttons enabled-state in sync.
|
||
updateHistoryUI();
|
||
});
|
||
|
||
// Hydrate from disk (defensively — corrupt JSON is caught by the store).
|
||
try {
|
||
const json = await api.readFile(_persistenceFile);
|
||
if (json) _store.deserialize(json);
|
||
console.log('[flowchart] bootstrap: persistence hydrated');
|
||
} catch (err) {
|
||
console.warn('[flowchart-controller] failed to read session:', err);
|
||
}
|
||
|
||
if (els.btnInsert) {
|
||
els.btnInsert.addEventListener('click', () => {
|
||
if (!_store) return;
|
||
const source = toMermaid(_store.getGraph());
|
||
const fenced = '```mermaid\n' + source + '\n```';
|
||
if (api.insertAtCursor) api.insertAtCursor(fenced);
|
||
setStatus('Inserted');
|
||
});
|
||
}
|
||
|
||
// v4.12.0 — Save to File. Pops a system save dialog and writes the
|
||
// Mermaid-fenced source to the user-chosen path via the generic
|
||
// 'save-text-file' IPC channel. The main-process handler resolves with
|
||
// `{ canceled: true }` if the user dismissed the dialog.
|
||
if (els.btnSave) {
|
||
els.btnSave.addEventListener('click', async () => {
|
||
if (!_store || !api.saveFile) return;
|
||
const source = toMermaid(_store.getGraph());
|
||
const fenced = '```mermaid\n' + source + '\n```';
|
||
try {
|
||
const result = await api.saveFile(fenced, 'flowchart.mmd');
|
||
if (result && result.canceled) {
|
||
setStatus('Save cancelled');
|
||
} else if (result && result.path) {
|
||
setStatus(`Saved to ${result.path}`);
|
||
} else {
|
||
setStatus('Saved');
|
||
}
|
||
} catch (err) {
|
||
setStatus(`Save failed: ${err && err.message ? err.message : err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
|
||
// strips the ```mermaid fence (if any), parses via fromMermaid()
|
||
// (inlined above) and replaces the current graph. Confirms
|
||
// overwrite before destroying unsaved work.
|
||
if (els.btnOpen) {
|
||
els.btnOpen.addEventListener('click', async () => {
|
||
if (!_store || !api.openFile) return;
|
||
let result;
|
||
try {
|
||
result = await api.openFile();
|
||
} catch (err) {
|
||
setStatus(`Open failed: ${err && err.message ? err.message : err}`);
|
||
return;
|
||
}
|
||
if (!result) {
|
||
setStatus('Open cancelled');
|
||
return;
|
||
}
|
||
const graph = _store.getGraph();
|
||
if (graph.nodes.length > 0 || graph.edges.length > 0) {
|
||
const ok = await confirmInline({
|
||
title: 'Open file',
|
||
message: 'This will replace the current diagram. Continue?',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
}
|
||
// Strip the ```mermaid fence if present
|
||
let source = result.content || '';
|
||
const fence = source.match(/```(?:mermaid)?\s*\n?([\s\S]*?)\n?```/);
|
||
if (fence) source = fence[1];
|
||
const parsed = fromMermaid(source);
|
||
_store.deserialize(parsed);
|
||
setStatus(`Loaded ${result.path}`);
|
||
});
|
||
}
|
||
|
||
if (els.btnReset) {
|
||
els.btnReset.addEventListener('click', async () => {
|
||
if (!_store) return;
|
||
const ok = await confirmInline({
|
||
title: 'Reset diagram',
|
||
message: 'Clear all nodes and edges? This cannot be undone.',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
_store.deserialize({ nodes: [], edges: [] });
|
||
setStatus('Reset');
|
||
});
|
||
}
|
||
|
||
// v4.13.0 — toolbar undo/redo buttons + history counter. Both wired
|
||
// here so they share state with the existing keyboard shortcuts.
|
||
if (els.btnUndo) {
|
||
els.btnUndo.addEventListener('click', () => {
|
||
if (!_store) return;
|
||
_store.undo();
|
||
});
|
||
}
|
||
if (els.btnRedo) {
|
||
els.btnRedo.addEventListener('click', () => {
|
||
if (!_store) return;
|
||
_store.redo();
|
||
});
|
||
}
|
||
function updateHistoryUI() {
|
||
if (els.historyCount) {
|
||
// The store exposes canUndo()/canRedo(); show the stack depth as
|
||
// a vague "edit count" so the user has feedback that their work
|
||
// is being captured.
|
||
const u = _store.canUndo() ? 1 : 0;
|
||
const r = _store.canRedo() ? 1 : 0;
|
||
els.historyCount.textContent = `${u}↶ / ${r}↷`;
|
||
}
|
||
if (els.btnUndo) els.btnUndo.disabled = !_store.canUndo();
|
||
if (els.btnRedo) els.btnRedo.disabled = !_store.canRedo();
|
||
}
|
||
|
||
// Keyboard shortcuts — Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z. Delete / Backspace
|
||
// are intentionally NOT wired (v4.11.0 — there is no canvas selection
|
||
// state anymore; use the × buttons in the node-list panel instead).
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (!_store) return;
|
||
const meta = ev.ctrlKey || ev.metaKey;
|
||
if (meta && !ev.shiftKey && ev.key.toLowerCase() === 'z') {
|
||
ev.preventDefault();
|
||
_store.undo();
|
||
return;
|
||
}
|
||
if (meta && ev.shiftKey && ev.key.toLowerCase() === 'z') {
|
||
ev.preventDefault();
|
||
_store.redo();
|
||
}
|
||
});
|
||
|
||
console.log('[flowchart] bootstrap: panel wired');
|
||
rerenderNodeList();
|
||
runPreview();
|
||
setStatus('Ready');
|
||
console.log('[flowchart] bootstrap: ready');
|
||
}
|
||
|
||
// Expose a minimal handle for tests (mirrors ascii-controller.js pattern).
|
||
// v4.11.0 — selection state is gone. Tests interact with the panel
|
||
// via the real DOM (`#fc-nodelist-ul`, `#fc-edgelist-ul`,
|
||
// `#fc-connect-from`, etc.) and assert against `_store` directly.
|
||
window.FlowchartController = {
|
||
bootstrap,
|
||
rerenderNodeList,
|
||
get store() {
|
||
return _store;
|
||
},
|
||
get canvas() {
|
||
return _canvas;
|
||
},
|
||
};
|
||
|
||
// Expose the inline modal helpers (v4.9.9) so jsdom tests can drive them
|
||
// directly without rebuilding the bundle's IIFE. Production code accesses
|
||
// these by closure; this handle exists purely for unit tests.
|
||
window.FlowchartModals = { promptInline, confirmInline };
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||
} else {
|
||
bootstrap();
|
||
}
|
||
})();
|