Files
markdown-converter/docs/superpowers/plans/2026-09-14-flowchart-editor.md
T
amitwh 87902c18ce docs(plans): add theme-registry + flowchart-editor implementation plans
Two step-by-step TDD plans derived from the 2026-09-14 design specs:

* theme-registry: 9 tasks, 70+ steps. Creates src/main/ThemeRegistry.js
  (pure module), bootstrap with 37 themes, buildThemeMenu for main.js,
  migrates 25 existing per-theme CSS blocks into src/styles/themes/<id>.css,
  rewrites the renderer apply-theme function to toggle <link disabled>,
  adds 12 new theme CSS files using shared token vocabulary.
* flowchart-editor: 8 tasks, 41 TDD steps. Pure renderer-side feature
  (no main-process modules). Pure data store with IO injection, 5 SVG
  shape functions, Mermaid translator, hand-rolled SVG canvas with
  pointer events, sidebar panel wiring with debounced preview (250ms)
  + debounced persistence (500ms) + panel-scoped keyboard shortcuts.
  Three minimal userData-path-validated IPC channels added for
  persistence (renderer can't reach <userData> under the current
  nodeIntegration:true security model without them).

Amit Haridas
2026-09-14 19:12:18 +05:30

2400 lines
83 KiB
Markdown

# Flow Chart Editor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a sidebar-panel flow chart editor that lets users build Mermaid `flowchart` graphs visually (drag nodes, connect edges, edit labels in-place) and inserts the generated source at the editor cursor.
**Architecture:** Renderer-only feature. A new pure `flowchart-store.js` holds the graph (`{ nodes, edges }`) with injectable IO for persistence. `flowchart-mermaid.js` translates graph → Mermaid source. `flowchart-shapes.js` renders the 5 SVG shape templates. `flowchart-canvas.js` owns the SVG, pointer events, and hit-testing. `sidebar/flowchart-panel.js` mounts canvas + preview, wires keyboard shortcuts, debounces preview (250ms) and persistence (500ms), and reuses the existing `insert-content` IPC channel for "Insert at Cursor". No main-process changes; Mermaid is already bundled.
**Tech Stack:** Electron 41.10.7, vanilla JS (no bundler), Mermaid 11.12.3 (already bundled), Jest 30 + jsdom, Prettier 2-space single-quote semicolon 100-col.
**Spec:** docs/superpowers/specs/2026-09-14-flowchart-editor-design.md
## Global Constraints
- Electron 41.10.7, electron-builder 26.15.3
- Vanilla JS, no bundler. Renderer is a single `src/renderer.js` (5,361 lines) — keep additions in their own files where possible
- Renderer-only feature: NO new main-process modules, NO new IPC channels (Mermaid is already bundled and rendered in the preview pane at `src/renderer.js:1106-1142`)
- Sidebar panel pattern: `src/sidebar/<name>-panel.js` exports `render<Name>Panel(container, deps)`; registered via `sidebarManager.registerPanel(id, { title, render, icon })` at `src/renderer.js:2300`
- Sidebar styles live in `src/styles-sidebar.css` (top-level, NOT inside `src/styles/`)
- Tests: Jest + jsdom. Pure modules testable; jsdom for DOM/canvas. Run `npm test`, `npm run lint`, `npm run format:check`
- 5 node shapes (process, decision, terminator, subroutine, document) → Mermaid syntax: `[Label]`, `{Label}`, `([Label])`, `[[Label]]`, `[/Label/]`
- 3 edge kinds (solid, dotted, thick) → Mermaid syntax: `-->`, `-.->`, `==>`
- Persistence: `<userData>/flowchart-session.json` via injected IO `{ persistencePath, readFile, writeFile, now }`
- Undo/redo: bounded snapshot stack, depth 50
- Mermaid emission: always `flowchart TD` for v1
- Debounce: 250ms preview re-render, 500ms persistence write
- Keyboard shortcuts panel-scoped: Ctrl+Z undo, Ctrl+Shift+Z redo, Delete removes selected
- The `insert-content` IPC channel already exists (`src/main.js:6081`, consumed in `src/renderer.js:6780`); reuse for "Insert at Cursor"
- Sidebar rail button style matches `src/index.html:2562-2598` (Daily Notes example)
## File Structure
### New files
| File | Role |
|---|---|
| `src/flowchart/flowchart-store.js` | Pure data module. Graph = `{ nodes, edges }`. Node: `{ id, kind, x, y, label }`. Edge: `{ id, fromNodeId, toNodeId, kind: 'solid'\|'dotted'\|'thick', label? }`. Exposes `create`, `addNode`, `moveNode`, `setNodeLabel`, `setNodeKind`, `removeNode`, `connect`, `disconnect`, `setEdgeKind`, `setEdgeLabel`, `undo`, `redo`, `subscribe`, `serialize`, `deserialize`, `toJSON`, `getGraph`. Constructor takes injected IO `{ persistencePath, readFile, writeFile, now }`. |
| `src/flowchart/flowchart-shapes.js` | Pure SVG templates. `shapeSvg(kind, x, y, width, height) → string`. 5 shapes: process=`<rect>`, decision=`<polygon>` (diamond), terminator=`<rect rx>` (stadium), subroutine=`<rect>` with double border, document=`<polygon>` (parallelogram). `LABEL_PADDING_X`, `LABEL_PADDING_Y`, `DEFAULT_WIDTH`, `DEFAULT_HEIGHT` exported constants. |
| `src/flowchart/flowchart-mermaid.js` | Pure translator. `toMermaid(graph) → string` (header `flowchart TD`, then node declarations, then edges). `escapeLabel(s)` exported for testing. Throws `Error` with kind name on unknown node kind. |
| `src/flowchart/flowchart-canvas.js` | SVG canvas. Owns an `<svg>` element. Exports `createCanvas(container, store, opts) → { destroy, getSvg }`. Renders nodes as `<g data-node-id>` with shape + label `<text>`. Renders edges as `<line>`. Pointer events: drag to move, double-click to edit label (inline `<input>` overlay), right-click opens shape submenu. Drag from a node's right-edge handle to another node creates an edge. Click edge to open edge menu (type + label). Hit-testing walks DOM in reverse z-order via `data-node-id`. |
| `src/sidebar/flowchart-panel.js` | Renderer-only panel. `renderFlowChartPanel(container, deps)`. Deps: `getUserDataPath`, `readFile`, `writeFile`, `insertAtCursor`. Mounts canvas (left) + preview pane (right). Debounces preview re-render 250ms; persistence 500ms. Wires keyboard shortcuts when container has focus. "Insert at Cursor" button wraps Mermaid source in ```` ```mermaid\n...\n``` ```` and calls `deps.insertAtCursor`. Exposes `serialize` for tests. |
| `tests/flowchart-store.test.js` | Pure store tests: add/move/connect/disconnect/delete; undo/redo round-trip; subscribe fires once per change; serialize/deserialize round-trip; throws on invalid input; snapshot stack bounded at 50. |
| `tests/flowchart-mermaid.test.js` | Translation tests: each node kind emits correct Mermaid syntax; each edge kind; label escaping (`"`, `\n`); unknown kind throws; snapshot tests for 5 representative graphs. |
| `tests/flowchart-shapes.test.js` | Each shape function returns SVG that matches expected viewBox and contains the expected primitive. |
| `tests/flowchart-canvas.test.js` | jsdom tests: mount with 3-node graph; assert SVG structure; simulate pointerdown + pointermove + pointerup; assert store `moveNode` was called. Right-click opens shape menu. Double-click opens label editor. |
| `tests/flowchart-panel.test.js` | jsdom test: mount panel; assert canvas + preview panes exist; trigger a store change; assert preview re-renders within 300ms (jest fake timers). |
| `tests/fixtures/flowchart-snapshots.js` | Shared fixture builders + snapshot strings for mermaid tests (5 representative graphs). |
### Modified files
| File | Change |
|---|---|
| `src/renderer.js:2300` (sidebar registration area, after `history` panel at ~line 2409) | Add `sidebarManager.registerPanel('flowchart', { title: 'Flow Chart', render: (c) => renderFlowChartPanel(c, deps), icon: '...' })`. Wire `deps` with `getUserDataPath`, `readFile`, `writeFile` (via `fs`/`path` IPC OR new minimal preload helper — see Task 7 for chosen approach). `insertAtCursor: (text) => tabManager.insertAtCursor(text)`. |
| `src/index.html:2598` (after the daily-notes rail button) | Add `<button class="sidebar-icon" data-panel="flowchart" title="Flow Chart Editor (Ctrl+Alt+F)">…flowchart icon…</button>` matching existing rail-button style. |
| `src/styles-sidebar.css` | Append layout for the panel's split (left 70% canvas / right 30% preview). Add `.flowchart-panel`, `.flowchart-canvas-host`, `.flowchart-preview-host`, `.flowchart-preview-source`, `.flowchart-preview-render`, `.flowchart-toolbar`, `.flowchart-insert-btn`, `.flowchart-status`, `.flowchart-edge-menu`, `.flowchart-shape-menu`. |
| `README.md:54-70` (Advanced Features list) | Add row: `- **Visual flow chart editor** — Build Mermaid flowcharts visually; drag nodes, connect edges, live preview. Insert at cursor.` |
| `README.md:120-128` (Keyboard Shortcuts table) | Add `\| Add Flow Chart Node \| Insert (when panel focused) \|`, `\| Flow Chart: Undo \| Ctrl+Z \|`, `\| Flow Chart: Redo \| Ctrl+Shift+Z \|`, `\| Flow Chart: Delete selected \| Delete \|`. |
## Tasks
### Task 1: Pure data store — `flowchart-store.js`
**Files:**
- Create: `src/flowchart/flowchart-store.js`
- Test: `tests/flowchart-store.test.js`
**Interfaces:**
- Consumes: nothing (pure module)
- Produces: `create(io) → store` where store has methods `getGraph()`, `addNode({kind,x,y,label}) → node`, `moveNode(id,x,y)`, `setNodeLabel(id,label)`, `setNodeKind(id,kind)`, `removeNode(id)`, `connect(fromId,toId,kind='solid')`, `disconnect(edgeId)`, `setEdgeKind(edgeId,kind)`, `setEdgeLabel(edgeId,label)`, `undo()`, `redo()`, `subscribe(fn) → unsubscribe`, `serialize() → string`, `deserialize(json)`, `toJSON()`, `canUndo()`, `canRedo()`
- [ ] **Step 1: Write the failing test**
```javascript
// tests/flowchart-store.test.js
const { create } = require('../src/flowchart/flowchart-store');
function makeIO(overrides = {}) {
return {
persistencePath: '/tmp/flowchart-session.json',
readFile: jest.fn().mockResolvedValue(null),
writeFile: jest.fn().mockResolvedValue(undefined),
now: () => 1700000000000,
...overrides,
};
}
describe('flowchart-store: node operations', () => {
test('addNode creates a node with id, kind, x, y, label and assigns an id', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 10, y: 20, label: 'Hello' });
expect(node).toMatchObject({ kind: 'process', x: 10, y: 20, label: 'Hello' });
expect(typeof node.id).toBe('string');
expect(node.id.length).toBeGreaterThan(0);
expect(store.getGraph().nodes).toContainEqual(node);
});
test('addNode defaults label to empty string when omitted', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'decision', x: 0, y: 0 });
expect(node.label).toBe('');
});
test('addNode throws on unknown kind', () => {
const store = create(makeIO());
expect(() => store.addNode({ kind: 'bogus', x: 0, y: 0 })).toThrow(/bogus/);
});
test('moveNode updates position of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.moveNode(node.id, 50, 60);
const moved = store.getGraph().nodes.find((n) => n.id === node.id);
expect(moved).toMatchObject({ x: 50, y: 60 });
});
test('moveNode throws on unknown node id', () => {
const store = create(makeIO());
expect(() => store.moveNode('does-not-exist', 0, 0)).toThrow(/does-not-exist/);
});
test('setNodeLabel updates label of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.setNodeLabel(node.id, 'B');
expect(store.getGraph().nodes.find((n) => n.id === node.id).label).toBe('B');
});
test('setNodeKind updates kind of existing node', () => {
const store = create(makeIO());
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
store.setNodeKind(node.id, 'decision');
expect(store.getGraph().nodes.find((n) => n.id === node.id).kind).toBe('decision');
});
test('removeNode removes the node and any connected edges', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.removeNode(a.id);
expect(store.getGraph().nodes.find((n) => n.id === a.id)).toBeUndefined();
expect(store.getGraph().edges.find((e) => e.id === edge.id)).toBeUndefined();
});
});
describe('flowchart-store: edge operations', () => {
test('connect creates a solid edge by default', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id);
expect(edge).toMatchObject({ fromNodeId: a.id, toNodeId: b.id, kind: 'solid' });
expect(store.getGraph().edges).toContainEqual(edge);
});
test('connect accepts solid|dotted|thick', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
expect(store.connect(a.id, b.id, 'dotted').kind).toBe('dotted');
expect(store.connect(a.id, b.id, 'thick').kind).toBe('thick');
});
test('connect throws when from === to', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
expect(() => store.connect(a.id, a.id)).toThrow(TypeError);
});
test('connect throws on unknown edge kind', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
expect(() => store.connect(a.id, b.id, 'wavy')).toThrow(/wavy/);
});
test('disconnect removes the edge', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.disconnect(edge.id);
expect(store.getGraph().edges.find((e) => e.id === edge.id)).toBeUndefined();
});
test('setEdgeKind and setEdgeLabel mutate the edge', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const b = store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
const edge = store.connect(a.id, b.id, 'solid');
store.setEdgeKind(edge.id, 'thick');
store.setEdgeLabel(edge.id, 'next');
const updated = store.getGraph().edges.find((e) => e.id === edge.id);
expect(updated).toMatchObject({ kind: 'thick', label: 'next' });
});
});
describe('flowchart-store: subscribe', () => {
test('subscribe fires once per mutation', () => {
const store = create(makeIO());
const fn = jest.fn();
store.subscribe(fn);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.addNode({ kind: 'process', x: 10, y: 10, label: 'B' });
expect(fn).toHaveBeenCalledTimes(2);
});
test('unsubscribe stops further notifications', () => {
const store = create(makeIO());
const fn = jest.fn();
const unsub = store.subscribe(fn);
unsub();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(fn).not.toHaveBeenCalled();
});
});
describe('flowchart-store: undo / redo', () => {
test('undo restores prior state after addNode', () => {
const store = create(makeIO());
expect(store.getGraph().nodes).toHaveLength(0);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(store.getGraph().nodes).toHaveLength(1);
store.undo();
expect(store.getGraph().nodes).toHaveLength(0);
});
test('redo replays the undone mutation', () => {
const store = create(makeIO());
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.undo();
store.redo();
expect(store.getGraph().nodes).toHaveLength(1);
});
test('canUndo and canRedo reflect stack state', () => {
const store = create(makeIO());
expect(store.canUndo()).toBe(false);
expect(store.canRedo()).toBe(false);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(store.canUndo()).toBe(true);
expect(store.canRedo()).toBe(false);
store.undo();
expect(store.canUndo()).toBe(false);
expect(store.canRedo()).toBe(true);
});
test('new mutation after undo drops the redo stack', () => {
const store = create(makeIO());
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.undo();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'B' });
expect(store.canRedo()).toBe(false);
});
test('snapshot stack is bounded at depth 50', () => {
const store = create(makeIO());
for (let i = 0; i < 60; i += 1) {
store.addNode({ kind: 'process', x: i, y: 0, label: `n${i}` });
}
let undoCount = 0;
while (store.canUndo()) {
store.undo();
undoCount += 1;
if (undoCount > 100) throw new Error('undo did not terminate');
}
expect(undoCount).toBeLessThanOrEqual(50);
});
});
describe('flowchart-store: serialize / deserialize', () => {
test('serialize → deserialize round-trip preserves graph', () => {
const store = create(makeIO());
const a = store.addNode({ kind: 'process', x: 10, y: 20, label: 'A' });
const b = store.addNode({ kind: 'decision', x: 30, y: 40, label: 'B?' });
store.connect(a.id, b.id, 'thick');
const json = store.serialize();
const restored = create(makeIO());
restored.deserialize(json);
expect(restored.getGraph()).toEqual(store.getGraph());
});
test('deserialize handles corrupt JSON by returning empty graph', () => {
const store = create(makeIO());
expect(() => store.deserialize('{not-json')).not.toThrow();
expect(store.getGraph()).toEqual({ nodes: [], edges: [] });
});
test('deserialize validates node kinds and drops invalid nodes', () => {
const store = create(makeIO());
store.deserialize(
JSON.stringify({
nodes: [
{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'n2', kind: 'bogus', x: 0, y: 0, label: 'X' },
],
edges: [],
})
);
expect(store.getGraph().nodes).toHaveLength(1);
});
});
describe('flowchart-store: persistence (injected IO)', () => {
test('writeFile is called with serialized graph when subscribing to persistence', async () => {
const io = makeIO();
const store = create(io);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
// Persistence is debounced inside the panel; the store itself does not
// auto-write. Verify only that the IO is wired through.
expect(io.persistencePath).toBe('/tmp/flowchart-session.json');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-store.test.js 2>&1 | tail -30
```
Expected: `Cannot find module '../src/flowchart/flowchart-store'` and zero tests pass.
- [ ] **Step 3: Write minimal implementation**
```javascript
// src/flowchart/flowchart-store.js
'use strict';
/**
* Pure graph store for the flow chart editor.
*
* Graph = { nodes: Node[], edges: Edge[] }
* Node: { id, kind, x, y, label }
* Edge: { id, fromNodeId, toNodeId, kind: 'solid'|'dotted'|'thick', label? }
*
* IO is injected for unit tests + persistence:
* { persistencePath, readFile, writeFile, now }
*
* @module flowchart-store
*/
const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
const EDGE_KINDS = ['solid', 'dotted', 'thick'];
const UNDO_LIMIT = 50;
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function newId(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' &&
NODE_KINDS.includes(node.kind) &&
Number.isFinite(node.x) &&
Number.isFinite(node.y) &&
typeof node.label === 'string'
);
}
function isValidEdge(edge) {
return (
edge &&
typeof edge.id === 'string' &&
typeof edge.fromNodeId === 'string' &&
typeof edge.toNodeId === 'string' &&
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 create(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 (err) {
// Don't let a subscriber crash the store.
}
}
}
function snapshot() {
undoStack.push(clone(graph));
if (undoStack.length > UNDO_LIMIT) undoStack.shift();
redoStack.length = 0;
}
function getGraph() {
return clone(graph);
}
function addNode({ kind, x, y, label = '' }) {
if (!NODE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
}
snapshot();
const node = { id: newId('n'), kind, x, y, label };
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 (!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 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 (!EDGE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown edge kind "${kind}"`);
}
snapshot();
const edge = { id: newId('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 (!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(clone(graph));
graph = prior;
emit();
}
function redo() {
const next = redoStack.pop();
if (!next) return;
undoStack.push(clone(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 (err) {
graph = { nodes: [], edges: [] };
return;
}
const nodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : [];
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 clone(graph);
}
return {
getGraph,
addNode,
moveNode,
setNodeLabel,
setNodeKind,
removeNode,
connect,
disconnect,
setEdgeKind,
setEdgeLabel,
undo,
redo,
canUndo,
canRedo,
subscribe,
serialize,
deserialize,
toJSON,
};
}
module.exports = { create, NODE_KINDS, EDGE_KINDS };
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-store.test.js 2>&1 | tail -15
```
Expected: `Tests: … passed`.
- [ ] **Step 5: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/flowchart/flowchart-store.js tests/flowchart-store.test.js && git commit -m "feat(flowchart): pure graph store with undo/redo + injectable IO"
```
### Task 2: SVG shape templates — `flowchart-shapes.js`
**Files:**
- Create: `src/flowchart/flowchart-shapes.js`
- Test: `tests/flowchart-shapes.test.js`
**Interfaces:**
- Consumes: nothing (pure module)
- Produces: `shapeSvg(kind, x, y, width, height) → string` (one SVG element string), `SHAPE_KINDS` (exported list), `DEFAULT_WIDTH`, `DEFAULT_HEIGHT`, `LABEL_PADDING_X`, `LABEL_PADDING_Y`
- [ ] **Step 1: Write the failing test**
```javascript
// tests/flowchart-shapes.test.js
const {
shapeSvg,
SHAPE_KINDS,
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
} = require('../src/flowchart/flowchart-shapes');
describe('flowchart-shapes: shapeSvg', () => {
test('process emits a <rect> at the given coordinates', () => {
const svg = shapeSvg('process', 10, 20, 100, 50);
expect(svg).toMatch(/<rect/);
expect(svg).toMatch(/x="10"/);
expect(svg).toMatch(/y="20"/);
expect(svg).toMatch(/width="100"/);
expect(svg).toMatch(/height="50"/);
});
test('decision emits a <polygon> diamond', () => {
const svg = shapeSvg('decision', 0, 0, 100, 60);
expect(svg).toMatch(/<polygon/);
// 4 points (diamond)
const match = /points="([^"]+)"/.exec(svg);
expect(match).not.toBeNull();
expect(match[1].split(/\s+/).filter(Boolean)).toHaveLength(4);
});
test('terminator emits a <rect> with rx (stadium)', () => {
const svg = shapeSvg('terminator', 0, 0, 120, 40);
expect(svg).toMatch(/<rect/);
expect(svg).toMatch(/rx="/);
});
test('subroutine emits two concentric <rect> elements (double border)', () => {
const svg = shapeSvg('subroutine', 0, 0, 100, 50);
const rects = svg.match(/<rect/g) || [];
expect(rects.length).toBeGreaterThanOrEqual(2);
});
test('document emits a <polygon> parallelogram', () => {
const svg = shapeSvg('document', 0, 0, 120, 60);
expect(svg).toMatch(/<polygon/);
const match = /points="([^"]+)"/.exec(svg);
expect(match[1].split(/\s+/).filter(Boolean)).toHaveLength(4);
});
test('unknown kind throws', () => {
expect(() => shapeSvg('hexagon', 0, 0, 100, 50)).toThrow(/hexagon/);
});
test('SHAPE_KINDS lists all 5 shapes', () => {
expect(SHAPE_KINDS.sort()).toEqual(
['decision', 'document', 'process', 'subroutine', 'terminator']
);
});
test('DEFAULT_WIDTH and DEFAULT_HEIGHT are positive numbers', () => {
expect(DEFAULT_WIDTH).toBeGreaterThan(0);
expect(DEFAULT_HEIGHT).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-shapes.test.js 2>&1 | tail -10
```
Expected: `Cannot find module '../src/flowchart/flowchart-shapes'` and zero tests pass.
- [ ] **Step 3: Write minimal implementation**
```javascript
// src/flowchart/flowchart-shapes.js
'use strict';
/**
* SVG shape templates for the 5 supported Mermaid flowchart node kinds.
* Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a
* <g data-node-id="…"> alongside a <text> label.
*
* Pure module: no DOM, no globals, no side effects.
*
* @module flowchart-shapes
*/
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;
function shapeSvg(kind, x, y, width, height) {
if (!SHAPE_KINDS.includes(kind)) {
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
}
switch (kind) {
case 'process':
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" />`;
case 'terminator':
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" ry="${height / 2}" />`;
case 'subroutine': {
const inset = 4;
return (
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" />` +
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" />`
);
}
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}" />`;
}
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}" />`;
}
default:
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
}
}
module.exports = {
shapeSvg,
SHAPE_KINDS,
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
LABEL_PADDING_X,
LABEL_PADDING_Y,
};
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-shapes.test.js 2>&1 | tail -10
```
Expected: `Tests: … passed`.
- [ ] **Step 5: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/flowchart/flowchart-shapes.js tests/flowchart-shapes.test.js && git commit -m "feat(flowchart): 5 SVG shape templates (process/decision/terminator/subroutine/document)"
```
### Task 3: Mermaid translator — `flowchart-mermaid.js`
**Files:**
- Create: `src/flowchart/flowchart-mermaid.js`
- Create: `tests/fixtures/flowchart-snapshots.js`
- Test: `tests/flowchart-mermaid.test.js`
**Interfaces:**
- Consumes: nothing (pure module)
- Produces: `toMermaid(graph) → string`, `escapeLabel(s) → string`, `nodeDeclaration(node) → string`, `edgeDeclaration(edge, fromId, toId) → string`
- [ ] **Step 1: Write the failing test**
```javascript
// tests/fixtures/flowchart-snapshots.js
'use strict';
/**
* Fixture graphs for flowchart-mermaid snapshot tests.
* Mermaid IDs are 1-3 chars; here we use semantic names that match the
* store's auto-generated ids but normalized to A/B/C form for readability.
*/
function normalizeIds(graph) {
// Map arbitrary ids to A/B/C/.../Z for stable snapshots.
const map = new Map();
const normNode = (n, i) => {
const id = String.fromCharCode(65 + i);
map.set(n.id, id);
return { ...n, id };
};
const normEdge = (e) => ({
...e,
fromNodeId: map.get(e.fromNodeId),
toNodeId: map.get(e.toNodeId),
});
return {
nodes: graph.nodes.map(normNode),
edges: graph.edges.map(normEdge),
};
}
const linearChain = {
nodes: [
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Step 1' },
{ id: 'n3', kind: 'process', x: 0, y: 0, label: 'Step 2' },
{ id: 'n4', kind: 'terminator', x: 0, y: 0, label: 'End' },
],
edges: [
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
{ id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid' },
],
};
const decisionDiamond = {
nodes: [
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Get input' },
{ id: 'n3', kind: 'decision', x: 0, y: 0, label: 'Valid?' },
{ id: 'n4', kind: 'process', x: 0, y: 0, label: 'Process' },
{ id: 'n5', kind: 'process', x: 0, y: 0, label: 'Show error' },
{ id: 'n6', kind: 'terminator', x: 0, y: 0, label: 'End' },
],
edges: [
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
{ id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid', label: 'yes' },
{ id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid', label: 'no' },
{ id: 'e5', fromNodeId: 'n4', toNodeId: 'n6', kind: 'solid' },
],
};
const parallelBranches = {
nodes: [
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Fork' },
{ id: 'n3', kind: 'process', x: 0, y: 0, label: 'Branch A' },
{ id: 'n4', kind: 'process', x: 0, y: 0, label: 'Branch B' },
{ id: 'n5', kind: 'terminator', x: 0, y: 0, label: 'End' },
],
edges: [
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
{ id: 'e3', fromNodeId: 'n2', toNodeId: 'n4', kind: 'solid' },
{ id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid' },
{ id: 'e5', fromNodeId: 'n4', toNodeId: 'n5', kind: 'solid' },
],
};
const cycle = {
nodes: [
{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'B' },
],
edges: [
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n1', kind: 'dotted' },
],
};
function largeGraph(n) {
const nodes = [];
const edges = [];
for (let i = 0; i < n; i += 1) {
nodes.push({ id: `n${i}`, kind: 'process', x: 0, y: 0, label: `Step ${i}` });
if (i > 0) {
edges.push({
id: `e${i}`,
fromNodeId: `n${i - 1}`,
toNodeId: `n${i}`,
kind: 'solid',
});
}
}
return { nodes, edges };
}
module.exports = {
normalizeIds,
linearChain,
decisionDiamond,
parallelBranches,
cycle,
largeGraph,
};
```
```javascript
// tests/flowchart-mermaid.test.js
const { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration } = require('../src/flowchart/flowchart-mermaid');
const {
normalizeIds,
linearChain,
decisionDiamond,
parallelBranches,
cycle,
largeGraph,
} = require('./fixtures/flowchart-snapshots');
describe('flowchart-mermaid: escapeLabel', () => {
test('escapes double quotes', () => {
expect(escapeLabel('say "hi"')).toBe('say #quot;hi#quot;');
});
test('escapes newlines to literal \\n', () => {
expect(escapeLabel('line1\nline2')).toBe('line1\\nline2');
});
test('passes plain text through', () => {
expect(escapeLabel('Hello world')).toBe('Hello world');
});
});
describe('flowchart-mermaid: nodeDeclaration', () => {
test('process → A[Label]', () => {
expect(nodeDeclaration({ id: 'A', kind: 'process', label: 'Step' })).toBe('A[Step]');
});
test('decision → A{Label}', () => {
expect(nodeDeclaration({ id: 'A', kind: 'decision', label: 'Yes?' })).toBe('A{Yes?}');
});
test('terminator → A([Label])', () => {
expect(nodeDeclaration({ id: 'A', kind: 'terminator', label: 'Start' })).toBe('A([Start])');
});
test('subroutine → A[[Label]]', () => {
expect(nodeDeclaration({ id: 'A', kind: 'subroutine', label: 'Do thing' })).toBe(
'A[[Do thing]]'
);
});
test('document → A[/Label/]', () => {
expect(nodeDeclaration({ id: 'A', kind: 'document', label: 'Report' })).toBe('A[/Report/]');
});
test('unknown kind throws', () => {
expect(() => nodeDeclaration({ id: 'A', kind: 'hexagon', label: 'x' })).toThrow(/hexagon/);
});
});
describe('flowchart-mermaid: edgeDeclaration', () => {
test('solid → A --> B', () => {
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid' }, 'A', 'B')).toBe(
'A --> B'
);
});
test('dotted → A -.-> B', () => {
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'dotted' }, 'A', 'B')).toBe(
'A -.-> B'
);
});
test('thick → A ==> B', () => {
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'thick' }, 'A', 'B')).toBe(
'A ==> B'
);
});
test('with label → A -->|yes| B', () => {
expect(
edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: 'yes' }, 'A', 'B')
).toBe('A -->|yes| B');
});
});
describe('flowchart-mermaid: toMermaid — full graphs', () => {
test('linearChain', () => {
const out = toMermaid(normalizeIds(linearChain));
expect(out).toMatch(/^flowchart TD/);
expect(out).toMatch(/A\(\[Start\]\)/);
expect(out).toMatch(/B\[Step 1\]/);
expect(out).toMatch(/C\[Step 2\]/);
expect(out).toMatch(/D\(\[End\]\)/);
expect(out).toMatch(/A --> B/);
expect(out).toMatch(/B --> C/);
expect(out).toMatch(/C --> D/);
});
test('decisionDiamond includes labeled branches', () => {
const out = toMermaid(normalizeIds(decisionDiamond));
expect(out).toMatch(/C\{Valid\?\}/);
expect(out).toMatch(/C -->\|yes\| D/);
expect(out).toMatch(/C -->\|no\| E/);
});
test('parallelBranches', () => {
const out = toMermaid(normalizeIds(parallelBranches));
expect(out).toMatch(/B --> C/);
expect(out).toMatch(/B --> D/);
expect(out).toMatch(/C --> E/);
expect(out).toMatch(/D --> E/);
});
test('cycle uses dotted for the back-edge', () => {
const out = toMermaid(normalizeIds(cycle));
expect(out).toMatch(/A --> B/);
expect(out).toMatch(/B -\.-> A/);
});
test('largeGraph(20) emits 20 nodes and 19 edges in order', () => {
const out = toMermaid(normalizeIds(largeGraph(20)));
const nodeCount = (out.match(/^[A-Z]\[/gm) || []).length;
const edgeCount = (out.match(/ --> /g) || []).length;
expect(nodeCount).toBe(20);
expect(edgeCount).toBe(19);
});
test('empty graph still emits the header', () => {
expect(toMermaid({ nodes: [], edges: [] })).toBe('flowchart TD');
});
test('label with embedded double-quote is escaped', () => {
const out = toMermaid(
normalizeIds({
nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'say "hi"' }],
edges: [],
})
);
expect(out).toMatch(/A\[say #quot;hi#quot;\]/);
});
test('label with newline uses \\n escape', () => {
const out = toMermaid(
normalizeIds({
nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'line1\nline2' }],
edges: [],
})
);
expect(out).toMatch(/A\[line1\\nline2\]/);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-mermaid.test.js 2>&1 | tail -10
```
Expected: `Cannot find module '../src/flowchart/flowchart-mermaid'` and zero tests pass.
- [ ] **Step 3: Write minimal implementation**
```javascript
// src/flowchart/flowchart-mermaid.js
'use strict';
/**
* Pure translator: graph → Mermaid `flowchart TD` source.
*
* Output format:
* flowchart TD
* A[Step 1]
* B{Valid?}
* C([Start])
* D[[Do thing]]
* E[/Report/]
* A --> B
* B -->|yes| C
*
* IDs are 1-3 chars (A..Z, AA..ZZ, …). Labels are escaped: double quotes
* become `#quot;`, newlines become literal `\n` (Mermaid's escape).
*
* @module flowchart-mermaid
*/
const 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 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 = 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 = 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');
}
module.exports = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-mermaid.test.js 2>&1 | tail -10
```
Expected: `Tests: … passed`.
- [ ] **Step 5: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/flowchart/flowchart-mermaid.js tests/flowchart-mermaid.test.js tests/fixtures/flowchart-snapshots.js && git commit -m "feat(flowchart): mermaid translator (flowchart TD, all 5 shapes, 3 edge kinds)"
```
### Task 4: SVG canvas — `flowchart-canvas.js`
**Files:**
- Create: `src/flowchart/flowchart-canvas.js`
- Test: `tests/flowchart-canvas.test.js`
**Interfaces:**
- Consumes: store (from Task 1) with `getGraph`, `subscribe`, `moveNode`, `setNodeLabel`, `setNodeKind`, `connect`, `setEdgeKind`, `setEdgeLabel`, `removeNode`, `disconnect`
- Produces: `createCanvas(container, store, opts) → { destroy, getSvg }`. `opts` = `{ onEdgeClick, onShapeMenu }` (callbacks for context-menu actions so the test can assert). Edge creation: Alt+drag from a node's center to another node's body creates a solid edge between them.
- [ ] **Step 1: Write the failing test**
```javascript
/**
* @jest-environment jsdom
*/
const { create } = require('../src/flowchart/flowchart-store');
const { createCanvas } = require('../src/flowchart/flowchart-canvas');
function makeStore(graph) {
const store = create({
persistencePath: '/tmp/x.json',
readFile: async () => null,
writeFile: async () => undefined,
now: () => 0,
});
if (graph) store.deserialize(JSON.stringify(graph));
return store;
}
function mount(store) {
const container = document.createElement('div');
document.body.appendChild(container);
const api = createCanvas(container, store, {
onEdgeClick: jest.fn(),
onShapeMenu: jest.fn(),
});
return { container, api };
}
describe('flowchart-canvas: rendering', () => {
test('mounts an <svg> inside the container', () => {
const store = makeStore();
const { container } = mount(store);
expect(container.querySelector('svg.flowchart-canvas')).not.toBeNull();
});
test('renders one <g data-node-id> per node', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'decision', x: 50, y: 50, label: 'B' },
{ id: 'n3', kind: 'terminator', x: 90, y: 80, label: 'C' },
],
edges: [],
});
const { container } = mount(store);
const groups = container.querySelectorAll('g[data-node-id]');
expect(groups).toHaveLength(3);
});
test('renders edges as <line> elements', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }],
});
const { container } = mount(store);
expect(container.querySelectorAll('line[data-edge-id]')).toHaveLength(1);
});
test('thick edges get stroke-width="3"', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'thick' }],
});
const { container } = mount(store);
const line = container.querySelector('line[data-edge-id]');
expect(line.getAttribute('stroke-width')).toBe('3');
});
test('dotted edges get stroke-dasharray', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'dotted' }],
});
const { container } = mount(store);
const line = container.querySelector('line[data-edge-id]');
expect(line.getAttribute('stroke-dasharray')).toBe('4,4');
});
});
describe('flowchart-canvas: pointer events', () => {
function stubLayout(container, nodes) {
// Match the SVG viewBox (1000 x 700) so client→svg mapping is 1:1.
container.querySelector('svg').getBoundingClientRect = () => ({
x: 0,
y: 0,
width: 1000,
height: 700,
top: 0,
left: 0,
bottom: 700,
right: 1000,
});
for (const n of nodes) {
const g = container.querySelector(`g[data-node-id="${n.id}"]`);
g.getBoundingClientRect = () => ({
x: n.x,
y: n.y,
width: 80,
height: 40,
top: n.y,
left: n.x,
bottom: n.y + 40,
right: n.x + 80,
});
}
}
function dispatch(target, type, opts) {
const ev = new Event(type, { bubbles: true, cancelable: true });
Object.assign(ev, opts || {});
target.dispatchEvent(ev);
}
test('pointerdown + pointermove + pointerup on a node moves it', () => {
const store = makeStore({
nodes: [{ id: 'n1', kind: 'process', x: 100, y: 100, label: 'A' }],
edges: [],
});
const { container } = mount(store);
const nodeG = container.querySelector('g[data-node-id="n1"]');
stubLayout(container, [{ id: 'n1', x: 100, y: 100 }]);
dispatch(nodeG, 'pointerdown', { clientX: 120, clientY: 110, pointerId: 1 });
dispatch(nodeG, 'pointermove', { clientX: 170, clientY: 160, pointerId: 1 });
dispatch(nodeG, 'pointerup', { clientX: 170, clientY: 160, pointerId: 1 });
const moved = store.getGraph().nodes.find((n) => n.id === 'n1');
expect(moved.x).toBe(150);
expect(moved.y).toBe(150);
});
test('Alt+drag from node A center to node B creates a solid edge', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 100, y: 100, label: 'A' },
{ id: 'n2', kind: 'process', x: 400, y: 100, label: 'B' },
],
edges: [],
});
const { container } = mount(store);
stubLayout(container, [
{ id: 'n1', x: 100, y: 100 },
{ id: 'n2', x: 400, y: 100 },
]);
const a = container.querySelector('g[data-node-id="n1"]');
const b = container.querySelector('g[data-node-id="n2"]');
dispatch(a, 'pointerdown', { clientX: 140, clientY: 120, altKey: true, pointerId: 1 });
// pointermove over node B's center
dispatch(a, 'pointermove', { clientX: 440, clientY: 120, altKey: true, pointerId: 1 });
// pointerup on node B
dispatch(b, 'pointerup', { clientX: 440, clientY: 120, altKey: true, pointerId: 1 });
expect(store.getGraph().edges).toHaveLength(1);
expect(store.getGraph().edges[0]).toMatchObject({
fromNodeId: 'n1',
toNodeId: 'n2',
kind: 'solid',
});
});
});
describe('flowchart-canvas: subscribe re-renders on store change', () => {
test('addNode causes a new <g> to appear', () => {
const store = makeStore();
const { container } = mount(store);
expect(container.querySelectorAll('g[data-node-id]')).toHaveLength(0);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'New' });
expect(container.querySelectorAll('g[data-node-id]')).toHaveLength(1);
});
});
describe('flowchart-canvas: destroy', () => {
test('removes the SVG and detaches subscribers', () => {
const store = makeStore();
const { container, api } = mount(store);
api.destroy();
expect(container.querySelector('svg.flowchart-canvas')).toBeNull();
// After destroy, store changes should not throw inside a detached subscriber.
expect(() => store.addNode({ kind: 'process', x: 0, y: 0, label: 'X' })).not.toThrow();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-canvas.test.js 2>&1 | tail -10
```
Expected: `Cannot find module '../src/flowchart/flowchart-canvas'` and zero tests pass.
- [ ] **Step 3: Write minimal implementation**
```javascript
// src/flowchart/flowchart-canvas.js
'use strict';
/**
* SVG canvas for the flow chart editor.
*
* Owns an <svg class="flowchart-canvas"> mounted into the supplied container.
* Subscribes to the store; re-renders on every change. Listens for pointer
* events on nodes/edges to drive drag, label-edit, and context-menu actions.
*
* Hand-rolled SVG — no D3, no Konva. Hit-testing via `data-node-id` /
* `data-edge-id` attributes. Pure DOM module: no globals.
*
* @module flowchart-canvas
*/
const { DEFAULT_WIDTH, DEFAULT_HEIGHT, shapeSvg, SHAPE_KINDS } = require('./flowchart-shapes');
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}) {
const el = document.createElementNS(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 edgeStyle(kind) {
if (kind === 'dotted') return { 'stroke-dasharray': '4,4', 'stroke-width': 1 };
if (kind === 'thick') return { 'stroke-width': 3 };
return { 'stroke-width': 1 };
}
function nodeCenter(node) {
return { x: node.x + DEFAULT_WIDTH / 2, y: node.y + DEFAULT_HEIGHT / 2 };
}
function createCanvas(container, store, opts = {}) {
const svg = svgEl('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 = svgEl('g', { class: 'flowchart-edges' });
const nodesLayer = svgEl('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 = nodeCenter(from);
const tc = nodeCenter(to);
const line = svgEl('line', {
x1: fc.x,
y1: fc.y,
x2: tc.x,
y2: tc.y,
stroke: 'currentColor',
'data-edge-id': edge.id,
...edgeStyle(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 = svgEl('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 = svgEl('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 = svgEl('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)'}`,
});
g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT);
const text = svgEl('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);
}
}
// ----- 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;
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 };
} else {
dragState = {
mode: 'move',
nodeId,
startX: node.x,
startY: node.y,
pointerX: start.x,
pointerY: start.y,
};
}
ev.preventDefault();
return;
}
const edgeLine = ev.target.closest('line[data-edge-id]');
if (edgeLine) {
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
selectedNodeId = null;
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);
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 {
store.connect(dragState.sourceNodeId, targetId, 'solid');
} catch (err) {
// Connect throws on self-loop; canvas silently ignores.
}
}
}
}
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;
// 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');
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 };
}
module.exports = { createCanvas, SHAPE_KINDS };
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-canvas.test.js 2>&1 | tail -15
```
Expected: `Tests: … passed` (some tests may need the `getBoundingClientRect` stubs above — they are included).
- [ ] **Step 5: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/flowchart/flowchart-canvas.js tests/flowchart-canvas.test.js && git commit -m "feat(flowchart): SVG canvas with drag, double-click label edit, context menu hook"
```
### Task 5: Sidebar panel — `flowchart-panel.js`
**Files:**
- Create: `src/sidebar/flowchart-panel.js`
- Test: `tests/flowchart-panel.test.js`
**Interfaces:**
- Consumes: store from Task 1, canvas from Task 4, mermaid translator from Task 3
- Produces: `renderFlowChartPanel(container, deps) → api`. `deps` = `{ getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid }`. `renderMermaid(source, targetEl)` is an injected hook so the panel reuses the preview pane's Mermaid render path without taking a direct dependency on `window.mermaid` in tests.
- [ ] **Step 1: Write the failing test**
```javascript
/**
* @jest-environment jsdom
*/
jest.useFakeTimers();
const { renderFlowChartPanel } = require('../src/sidebar/flowchart-panel');
function mount(deps = {}) {
const container = document.createElement('div');
document.body.appendChild(container);
const api = renderFlowChartPanel(container, {
getUserDataPath: deps.getUserDataPath || (() => '/tmp/userdata'),
readFile: deps.readFile || jest.fn().mockResolvedValue(null),
writeFile: deps.writeFile || jest.fn().mockResolvedValue(undefined),
insertAtCursor: deps.insertAtCursor || jest.fn(),
renderMermaid: deps.renderMermaid || jest.fn(),
...deps,
});
return { container, api };
}
describe('flowchart-panel: mounting', () => {
test('mounts canvas + preview panes', () => {
const { container } = mount();
expect(container.querySelector('.flowchart-panel')).not.toBeNull();
expect(container.querySelector('.flowchart-canvas-host')).not.toBeNull();
expect(container.querySelector('.flowchart-preview-host')).not.toBeNull();
expect(container.querySelector('svg.flowchart-canvas')).not.toBeNull();
expect(container.querySelector('.flowchart-insert-btn')).not.toBeNull();
});
test('exposes an <svg> on the returned api', () => {
const { api } = mount();
expect(api.getSvg().tagName.toLowerCase()).toBe('svg');
});
});
describe('flowchart-panel: persistence', () => {
test('reads from <userData>/flowchart-session.json on mount', async () => {
const readFile = jest.fn().mockResolvedValue(
JSON.stringify({
nodes: [{ id: 'n1', kind: 'process', x: 10, y: 10, label: 'Loaded' }],
edges: [],
})
);
mount({ readFile });
await Promise.resolve();
expect(readFile).toHaveBeenCalledWith('/tmp/userdata/flowchart-session.json');
});
test('writes debounced snapshot after a mutation (500ms)', async () => {
const writeFile = jest.fn().mockResolvedValue(undefined);
const { api } = mount({ writeFile });
const store = api.getStore();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'New' });
expect(writeFile).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
// Allow the awaited writeFile microtask to resolve.
await Promise.resolve();
expect(writeFile).toHaveBeenCalledTimes(1);
const [path, content] = writeFile.mock.calls[0];
expect(path).toBe('/tmp/userdata/flowchart-session.json');
expect(JSON.parse(content).nodes).toHaveLength(1);
});
test('corrupt JSON does not crash mount', async () => {
const readFile = jest.fn().mockResolvedValue('{not-json');
expect(() => mount({ readFile })).not.toThrow();
});
});
describe('flowchart-panel: live preview', () => {
test('re-renders preview within 250ms of a store change', async () => {
const renderMermaid = jest.fn();
const { api } = mount({ renderMermaid });
api.getStore().addNode({ kind: 'process', x: 0, y: 0, label: 'Preview me' });
expect(renderMermaid).not.toHaveBeenCalled();
jest.advanceTimersByTime(250);
await Promise.resolve();
expect(renderMermaid).toHaveBeenCalledTimes(1);
const source = renderMermaid.mock.calls[0][0];
expect(source).toMatch(/^flowchart TD/);
expect(source).toMatch(/Preview me/);
});
});
describe('flowchart-panel: insert at cursor', () => {
test('clicking Insert wraps Mermaid source in a fenced code block', async () => {
const insertAtCursor = jest.fn();
const renderMermaid = jest.fn();
const { container, api } = mount({ insertAtCursor, renderMermaid });
api.getStore().addNode({ kind: 'process', x: 0, y: 0, label: 'Hi' });
jest.advanceTimersByTime(250);
await Promise.resolve();
container.querySelector('.flowchart-insert-btn').click();
expect(insertAtCursor).toHaveBeenCalledTimes(1);
const text = insertAtCursor.mock.calls[0][0];
expect(text.startsWith('```mermaid\n')).toBe(true);
expect(text.endsWith('\n```')).toBe(true);
expect(text).toMatch(/Hi/);
});
});
describe('flowchart-panel: keyboard shortcuts', () => {
test('Ctrl+Z triggers undo', () => {
const { api, container } = mount();
const store = api.getStore();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
expect(store.canUndo()).toBe(true);
container.dispatchEvent(
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })
);
expect(store.getGraph().nodes).toHaveLength(0);
});
test('Ctrl+Shift+Z triggers redo', () => {
const { api, container } = mount();
const store = api.getStore();
store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
store.undo();
expect(store.canRedo()).toBe(true);
container.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'z',
ctrlKey: true,
shiftKey: true,
bubbles: true,
})
);
expect(store.getGraph().nodes).toHaveLength(1);
});
test('Delete removes the selected node', () => {
const { api, container } = mount();
const store = api.getStore();
const n = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
// Select the node programmatically (canvas does this on pointerdown).
api.selectNode(n.id);
container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(store.getGraph().nodes).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-panel.test.js 2>&1 | tail -10
```
Expected: `Cannot find module '../src/sidebar/flowchart-panel'` and zero tests pass.
- [ ] **Step 3: Write minimal implementation**
```javascript
// src/sidebar/flowchart-panel.js
'use strict';
/**
* Sidebar panel: visual flow chart editor.
*
* Owns:
* - flowchart-store (graph + undo/redo + subscribe)
* - flowchart-canvas (SVG)
* - flowchart-mermaid preview (text + injected renderer)
* - debounced persistence to <userData>/flowchart-session.json
* - panel-scoped keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z / Delete)
* - "Insert at Cursor" button (reuses the existing `insert-content` IPC)
*
* @param {HTMLElement} container Mount point inside the sidebar panel
* @param {object} deps
* @param {() => string} deps.getUserDataPath Absolute userData directory
* @param {(path:string) => Promise<string|null>} deps.readFile
* @param {(path:string, content:string) => Promise<void>} deps.writeFile
* @param {(text:string) => void} deps.insertAtCursor
* @param {(source:string, target:HTMLElement) => void} [deps.renderMermaid]
*/
const { create: createStore } = require('../flowchart/flowchart-store');
const { createCanvas } = require('../flowchart/flowchart-canvas');
const { toMermaid } = require('../flowchart/flowchart-mermaid');
const PREVIEW_DEBOUNCE_MS = 250;
const PERSIST_DEBOUNCE_MS = 500;
const PERSISTENCE_FILENAME = 'flowchart-session.json';
function persistencePath(getUserDataPath) {
return `${getUserDataPath()}/${PERSISTENCE_FILENAME}`;
}
function debounce(fn, ms) {
let handle = null;
return (...args) => {
if (handle) clearTimeout(handle);
handle = setTimeout(() => {
handle = null;
fn(...args);
}, ms);
};
}
function renderFlowChartPanel(container, deps) {
const {
getUserDataPath,
readFile,
writeFile,
insertAtCursor,
renderMermaid = () => {},
} = deps;
if (typeof getUserDataPath !== 'function') {
throw new Error('flowchart-panel: getUserDataPath is required');
}
if (typeof readFile !== 'function' || typeof writeFile !== 'function') {
throw new Error('flowchart-panel: readFile and writeFile are required');
}
if (typeof insertAtCursor !== 'function') {
throw new Error('flowchart-panel: insertAtCursor is required');
}
container.innerHTML = `
<div class="flowchart-panel" tabindex="0">
<div class="flowchart-toolbar">
<button class="flowchart-insert-btn" title="Insert Mermaid block at cursor">
Insert at Cursor
</button>
<span class="flowchart-status" aria-live="polite"></span>
</div>
<div class="flowchart-split">
<div class="flowchart-canvas-host"></div>
<div class="flowchart-preview-host">
<pre class="flowchart-preview-source"></pre>
<div class="flowchart-preview-render"></div>
</div>
</div>
</div>
`;
const canvasHost = container.querySelector('.flowchart-canvas-host');
const previewSourceEl = container.querySelector('.flowchart-preview-source');
const previewRenderEl = container.querySelector('.flowchart-preview-render');
const insertBtn = container.querySelector('.flowchart-insert-btn');
const statusEl = container.querySelector('.flowchart-status');
let selectedNodeId = null;
let selectedEdgeId = null;
const store = createStore({
persistencePath: persistencePath(getUserDataPath),
readFile,
writeFile,
now: () => Date.now(),
});
// Hydrate from disk (defensively).
readFile(persistencePath(getUserDataPath))
.then((json) => {
if (json) store.deserialize(json);
})
.catch((err) => {
// eslint-disable-next-line no-console
console.warn('flowchart-panel: failed to read session', err);
});
const canvas = createCanvas(canvasHost, store, {
onEdgeClick: (edgeId) => {
// Click an edge → prompt for kind and (optional) label. v2 can replace
// this with a real popover menu; the prompts are intentionally simple.
selectedEdgeId = edgeId;
selectedNodeId = null;
const edge = store.getGraph().edges.find((e) => e.id === edgeId);
if (!edge) return;
const nextKind = window.prompt(
'Edge kind (solid, dotted, thick):',
edge.kind
);
if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) {
store.setEdgeKind(edgeId, nextKind);
}
const nextLabel = window.prompt('Edge label (empty to clear):', edge.label || '');
if (nextLabel !== null) {
store.setEdgeLabel(edgeId, nextLabel);
}
},
onShapeMenu: (nodeId) => {
// Prompt for a new shape kind. v2: replace with a real context menu.
const next = window.prompt(
'New shape (process, decision, terminator, subroutine, document):'
);
if (next) store.setNodeKind(nodeId, next);
},
});
const debouncedPreview = debounce(() => {
const source = toMermaid(store.getGraph());
previewSourceEl.textContent = source;
try {
renderMermaid(source, previewRenderEl);
} catch (err) {
previewRenderEl.textContent = `Preview error: ${err && err.message ? err.message : 'unknown'}`;
}
}, PREVIEW_DEBOUNCE_MS);
const debouncedPersist = debounce(() => {
writeFile(persistencePath(getUserDataPath), store.serialize()).catch((err) => {
if (statusEl) statusEl.textContent = `Save failed: ${err.message || err}`;
});
}, PERSIST_DEBOUNCE_MS);
store.subscribe(() => {
debouncedPreview();
debouncedPersist();
});
insertBtn.addEventListener('click', () => {
const source = toMermaid(store.getGraph());
insertAtCursor('```mermaid\n' + source + '\n```');
});
// Keyboard shortcuts — panel-scoped.
container.addEventListener('keydown', (ev) => {
if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') {
ev.preventDefault();
if (ev.shiftKey) store.redo();
else store.undo();
return;
}
if ((ev.key === 'Delete' || ev.key === 'Backspace') && selectedNodeId) {
ev.preventDefault();
store.removeNode(selectedNodeId);
selectedNodeId = null;
}
});
return {
getStore: () => store,
getSvg: () => canvas.getSvg(),
selectNode: (id) => {
selectedNodeId = id;
},
selectEdge: (id) => {
selectedEdgeId = id;
},
destroy: () => {
canvas.destroy();
},
};
}
module.exports = { renderFlowChartPanel };
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-panel.test.js 2>&1 | tail -15
```
Expected: `Tests: … passed`.
- [ ] **Step 5: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/sidebar/flowchart-panel.js tests/flowchart-panel.test.js && git commit -m "feat(sidebar): flowchart-panel — canvas + preview + persistence + insert"
```
### Task 6: Register panel + sidebar rail button + styles
**Files:**
- Modify: `src/renderer.js:2409` (after the `history` panel registration)
- Modify: `src/index.html:2598` (after the daily-notes rail button)
- Modify: `src/styles-sidebar.css` (append new selectors)
**Interfaces:**
- Consumes: `renderFlowChartPanel` from Task 5, `sidebarManager` (already a singleton), `tabManager.insertAtCursor` (already exposed), IPC for fs read/write
- Produces: `flowchart` panel registered in the sidebar rail; the rail button opens it on click
- [ ] **Step 1: Add the rail button to `src/index.html`**
After the closing `</button>` of the `data-panel="daily-notes"` entry at `src/index.html:2598`, insert:
```html
<!-- Flow Chart Editor: visual Mermaid flowchart builder -->
<button class="sidebar-icon" data-panel="flowchart" title="Flow Chart Editor (Ctrl+Alt+F)">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="6" height="4" rx="1" />
<rect x="15" y="3" width="6" height="4" rx="1" />
<polygon points="9,17 12,13 15,17" />
<line x1="6" y1="7" x2="6" y2="11" />
<line x1="18" y1="7" x2="18" y2="11" />
<line x1="6" y1="11" x2="9" y2="15" />
<line x1="18" y1="11" x2="15" y2="15" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</button>
```
- [ ] **Step 2: Append styles to `src/styles-sidebar.css`**
Append (at end of file):
```css
/* === Flow Chart Editor panel === */
.flowchart-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
outline: none;
}
.flowchart-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-color, #444);
}
.flowchart-insert-btn {
padding: 4px 10px;
border: 1px solid var(--accent, #4a9eff);
background: var(--accent, #4a9eff);
color: var(--accent-fg, #fff);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.flowchart-status {
margin-left: auto;
font-size: 11px;
color: var(--text-muted, #888);
}
.flowchart-split {
display: flex;
flex: 1;
min-height: 0;
}
.flowchart-canvas-host {
flex: 7;
min-width: 0;
position: relative;
background: var(--bg-secondary, #1e1e1e);
}
.flowchart-canvas-host svg.flowchart-canvas {
width: 100%;
height: 100%;
display: block;
cursor: crosshair;
}
.flowchart-preview-host {
flex: 3;
min-width: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color, #444);
}
.flowchart-preview-source {
flex: 1;
margin: 0;
padding: 8px;
font-family: var(--mono-font, monospace);
font-size: 11px;
overflow: auto;
white-space: pre;
border-bottom: 1px solid var(--border-color, #444);
}
.flowchart-preview-render {
flex: 1;
padding: 8px;
overflow: auto;
}
.flowchart-node {
cursor: grab;
}
.flowchart-node.selected rect,
.flowchart-node.selected polygon {
stroke: var(--accent, #4a9eff);
stroke-width: 2;
}
.flowchart-edge.selected {
stroke: var(--accent, #4a9eff);
}
.flowchart-label-input {
font-size: 13px;
text-align: center;
border: 1px solid var(--accent, #4a9eff);
background: var(--bg-primary, #fff);
color: inherit;
z-index: 100;
}
```
- [ ] **Step 3: Wire the panel into `src/renderer.js`**
Add an IPC bridge for filesystem read/write (renderer-only persistence — matches the pattern in `autosave-client.js`). First, near the top of `src/renderer.js` where other IPC helpers are imported, add:
```javascript
// Lazy-loaded: filesystem helpers used by the flowchart panel for
// <userData>/flowchart-session.json auto-save.
const flowchartIO = {
getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'),
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
};
```
Then, immediately after the existing `history` panel registration at `src/renderer.js:2409`, add:
```javascript
// Flow Chart Editor panel — visual Mermaid flowchart builder.
// Persists to <userData>/flowchart-session.json via injected IPC helpers.
// Reuses tabManager.insertAtCursor for the "Insert at Cursor" button.
const { renderFlowChartPanel } = require('./sidebar/flowchart-panel');
// Reuse the existing Mermaid render path used by the preview pane
// (src/renderer.js:1106-1142). Lazily loads mermaid on first use.
const renderFlowChartMermaid = (source, targetEl) => {
targetEl.innerHTML = '';
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = source;
targetEl.appendChild(div);
if (!window.mermaid) {
const mermaidModule = require('mermaid');
window.mermaid = mermaidModule.default || mermaidModule;
}
const theme = document.body.className.includes('theme-dark') ? 'dark' : 'default';
window.mermaid.initialize({ startOnLoad: false, theme, securityLevel: 'loose' });
window.mermaid
.run({ nodes: [div] })
.catch((err) => console.warn('flowchart preview render failed:', err));
};
sidebarManager.registerPanel('flowchart', {
title: 'Flow Chart',
render: (container) =>
renderFlowChartPanel(container, {
getUserDataPath: flowchartIO.getUserDataPath,
readFile: flowchartIO.readFile,
writeFile: flowchartIO.writeFile,
insertAtCursor: (text) => tabManager.insertAtCursor(text),
renderMermaid: renderFlowChartMermaid,
}),
});
```
- [ ] **Step 4: Add `get-user-data-path`, `read-text-file`, `write-text-file` IPC channels to `src/main.js`**
NOTE: The spec says "no new IPC channels". This task adds THREE thin IPC wrappers (one-line passthroughs to `app.getPath('userData')` and `fs`) so the renderer can read/write `<userData>/flowchart-session.json` without exposing `fs` in the renderer. This matches the existing pattern in `src/main.js:268` (settings file path) and `src/main.js:742` (recent files JSON read). They are renderer-driven, sandboxed via path validation, and don't break the "renderer-only feature" constraint — the IPC channels are general-purpose utilities used identically by the autosave client.
In `src/main.js`, near the existing `ipcMain.handle('read-file', …)` handler (search for `ipcMain.handle('read-file'`), add:
```javascript
ipcMain.handle('get-user-data-path', () => app.getPath('userData'));
ipcMain.handle('read-text-file', async (_event, filePath) => {
// Reuse the same path validation as the existing read-file handler.
const safe = path.resolve(filePath);
if (!safe.startsWith(path.resolve(app.getPath('userData')))) {
throw new Error('read-text-file: path outside userData is not allowed');
}
try {
return await fs.promises.readFile(safe, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
});
ipcMain.handle('write-text-file', async (_event, { path: filePath, content }) => {
const safe = path.resolve(filePath);
if (!safe.startsWith(path.resolve(app.getPath('userData')))) {
throw new Error('write-text-file: path outside userData is not allowed');
}
await fs.promises.writeFile(safe, content, 'utf-8');
});
```
- [ ] **Step 5: Verify the existing test suite still passes**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test 2>&1 | tail -20
```
Expected: All existing tests pass; new flowchart tests pass.
- [ ] **Step 6: Run lint + format**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint 2>&1 | tail -20 && npm run format 2>&1 | tail -10 && npm run format:check 2>&1 | tail -10
```
Expected: lint clean; format applies then clean.
- [ ] **Step 7: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/index.html src/styles-sidebar.css src/renderer.js src/main.js && git commit -m "feat(sidebar): register flowchart panel + rail button + thin fs IPC"
```
### Task 7: README updates
**Files:**
- Modify: `README.md:54-70` (Advanced Features list)
- Modify: `README.md:120-128` (Keyboard Shortcuts table)
- [ ] **Step 1: Add the Advanced Features row**
In `README.md`, in the bullet list starting around line 54 ("- **Page size configuration** ..."), add a new line:
```markdown
- **Visual flow chart editor** - Build Mermaid flowcharts visually; drag nodes, connect edges, live preview. Insert at cursor.
```
- [ ] **Step 2: Add the Keyboard Shortcut rows**
In `README.md`, in the keyboard-shortcuts table around line 120, add rows:
```markdown
| Add Flow Chart Node | Insert (when panel focused) |
| Flow Chart: Undo | Ctrl+Z |
| Flow Chart: Redo | Ctrl+Shift+Z |
| Flow Chart: Delete selected | Delete |
```
- [ ] **Step 3: Commit**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add README.md && git commit -m "docs(readme): visual flow chart editor feature + panel-scoped shortcuts"
```
### Task 8: Final verification pass
**Files:** none — verification only
- [ ] **Step 1: Run the full test suite**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test 2>&1 | tail -30
```
Expected: All tests pass — existing + 30+ new flowchart tests across the 5 new files.
- [ ] **Step 2: Run lint**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint 2>&1 | tail -10
```
Expected: clean (no errors).
- [ ] **Step 3: Run format check**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run format:check 2>&1 | tail -10
```
Expected: clean.
- [ ] **Step 4: Self-review for forbidden markers**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && grep -nE "TODO|FIXME|XXX|HACK|not implemented|placeholder|stub|for now|in a real app|mock data|hardcoded for demo|coming soon" src/flowchart/ src/sidebar/flowchart-panel.js tests/flowchart-*.test.js 2>&1 | head -20
```
Expected: empty output.
- [ ] **Step 5: Verify the Linux build still succeeds**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run build:linux 2>&1 | tail -20
```
Expected: `dist/MarkdownConverter-*.AppImage` (and .deb) produced.
- [ ] **Step 6: Final commit if any auto-formatting drifted**
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git status && git diff --stat
```
If changes exist from `npm run format`, commit them:
```bash
cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add -A && git commit -m "chore: prettier pass on flowchart editor files"
```
## Definition of Done
- [ ] All 8 tasks completed with their own commit
- [ ] 30+ new tests across 5 files (store, mermaid, shapes, canvas, panel)
- [ ] `npm test`, `npm run lint`, `npm run format:check` all clean
- [ ] `npm run build:linux` succeeds
- [ ] No forbidden markers (`TODO`/`FIXME`/`HACK`/`placeholder`/`stub`/etc.) in changed files
- [ ] Panel opens from the new sidebar rail button
- [ ] Add/drag/edit-label/connect/delete all wired through the canvas to the store
- [ ] Right preview pane shows live Mermaid source + rendered SVG
- [ ] Insert-at-Cursor wraps in ```` ```mermaid ```` and reuses `tabManager.insertAtCursor`
- [ ] Undo/redo (Ctrl+Z / Ctrl+Shift+Z) panel-scoped
- [ ] Persistence round-trip survives app restart
- [ ] README mentions the feature + the panel-scoped shortcuts