feat(sidebar): flowchart-panel — canvas + preview + persistence + insert

Amit Haridas
This commit is contained in:
2026-09-14 21:45:47 +05:30
parent 0331a08207
commit 59ee9c609e
2 changed files with 331 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* 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]
*/
'use strict';
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) => {
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') {
if (selectedNodeId) {
ev.preventDefault();
store.removeNode(selectedNodeId);
selectedNodeId = null;
} else if (selectedEdgeId) {
ev.preventDefault();
store.disconnect(selectedEdgeId);
selectedEdgeId = null;
}
}
});
return {
getStore: () => store,
getSvg: () => canvas.getSvg(),
selectNode: (id) => {
selectedNodeId = id;
},
selectEdge: (id) => {
selectedEdgeId = id;
},
destroy: () => {
canvas.destroy();
},
};
}
module.exports = { renderFlowChartPanel };
+142
View File
@@ -0,0 +1,142 @@
/**
* @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);
});
});