mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(sidebar): flowchart-panel — canvas + preview + persistence + insert
Amit Haridas
This commit is contained in:
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user