diff --git a/src/sidebar/flowchart-panel.js b/src/sidebar/flowchart-panel.js new file mode 100644 index 0000000..c5ebd9b --- /dev/null +++ b/src/sidebar/flowchart-panel.js @@ -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 /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} deps.readFile + * @param {(path:string, content:string) => Promise} 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 = ` +
+
+ + +
+
+
+
+

+          
+
+
+
+ `; + + 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 }; diff --git a/tests/flowchart-panel.test.js b/tests/flowchart-panel.test.js new file mode 100644 index 0000000..f0885f1 --- /dev/null +++ b/tests/flowchart-panel.test.js @@ -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 on the returned api', () => { + const { api } = mount(); + expect(api.getSvg().tagName.toLowerCase()).toBe('svg'); + }); +}); + +describe('flowchart-panel: persistence', () => { + test('reads from /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); + }); +});