From 23046967c3ae22f1f40524b1b083de950642f3a7 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Tue, 15 Sep 2026 09:23:58 +0530 Subject: [PATCH] feat(flowchart): discoverable connect form + per-node color + save-to-file export; v4.12.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on v4.11.0: the Add Connection form was buried below the node/edge lists and they couldn't find it; they also asked for per-node color and Save to File export. v4.12.0 reorganises the #fc-nodelist panel to put the connect form right after the Add Node buttons, adds a native per node row that drives a new store.setNodeColor mutator (with serialize/deserialize round-trip), and wires a Save to File button that pops a system save dialog via a new 'save-text-file' IPC channel. - src/flowchart-generator.html — panel order is now Add Node / Add Connection / Nodes / Edges / Export (Insert · Save · Reset). Removed the legacy top toolbar; status moved into the panel. Added .fc-help, per-node color-picker CSS, .fc-toolbar-row. - src/flowchart/flowchart-shapes.js — shapeSvg() accepts an optional 6th 'color' arg; emits fill=... on rects and polygons. Falls back to #ffffff for empty/null/undefined so old callers keep working. - src/flowchart/flowchart-store.js — setNodeColor(id, color) mutator with snapshot/emit, normalizeColor() helper (hex validation), addNode accepts color, deserialize normalises missing color. - src/renderer/flowchart-bundle.js — mirrors all of the above inline (the bundle is loaded as a single diff --git a/src/flowchart/flowchart-shapes.js b/src/flowchart/flowchart-shapes.js index bb40b91..0be8de9 100644 --- a/src/flowchart/flowchart-shapes.js +++ b/src/flowchart/flowchart-shapes.js @@ -3,6 +3,11 @@ * Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a * alongside a label. * + * v4.12.0 — Added an optional `color` (6th) argument so callers can set a + * per-node fill color. Falls back to `#ffffff` when omitted so callers that + * don't care about color (the existing tests, the sidebar panel) keep + * working unchanged. + * * Pure module: no DOM, no globals, no side effects. * * @module flowchart-shapes @@ -15,21 +20,23 @@ const DEFAULT_WIDTH = 140; const DEFAULT_HEIGHT = 60; const LABEL_PADDING_X = 16; const LABEL_PADDING_Y = 12; +const DEFAULT_FILL = '#ffffff'; -function shapeSvg(kind, x, y, width, height) { +function shapeSvg(kind, x, y, width, height, color) { if (!SHAPE_KINDS.includes(kind)) { throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`); } + const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL; switch (kind) { case 'process': - return ``; + return ``; case 'terminator': - return ``; + return ``; case 'subroutine': { const inset = 4; return ( - `` + - `` + `` + + `` ); } case 'decision': { @@ -39,7 +46,7 @@ function shapeSvg(kind, x, y, width, height) { const top = `${cx},${y}`; const right = `${x + width},${cy}`; const bottom = `${cx},${y + height}`; - return ``; + return ``; } case 'document': { // Parallelogram: top-right and bottom-right indented by ~20% of height. @@ -48,7 +55,7 @@ function shapeSvg(kind, x, y, width, height) { const tr = `${x + width},${y}`; const br = `${x + width - skew},${y + height}`; const bl = `${x},${y + height}`; - return ``; + return ``; } default: throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`); diff --git a/src/flowchart/flowchart-store.js b/src/flowchart/flowchart-store.js index 665d8b1..a4468d8 100644 --- a/src/flowchart/flowchart-store.js +++ b/src/flowchart/flowchart-store.js @@ -2,9 +2,12 @@ * Pure graph store for the flow chart editor. * * Graph = { nodes: Node[], edges: Edge[] } - * Node: { id, kind, x, y, label } + * Node: { id, kind, x, y, label, color? } (color: CSS hex string, defaults to #ffffff) * Edge: { id, fromNodeId, toNodeId, kind: 'solid'|'dotted'|'thick', label? } * + * v4.12.0 — Nodes carry an optional `color` field (CSS hex string). + * `setNodeColor(id, color)` mutates it; `serialize`/`deserialize` round-trip it. + * * IO is injected for unit tests + persistence: * { persistencePath, readFile, writeFile, now } * @@ -14,6 +17,7 @@ const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document']; const EDGE_KINDS = ['solid', 'dotted', 'thick']; const UNDO_LIMIT = 50; +const DEFAULT_NODE_COLOR = '#ffffff'; function clone(obj) { return JSON.parse(JSON.stringify(obj)); @@ -35,6 +39,22 @@ function isValidNode(node) { ); } +/** + * Normalise a node's `color` field. Accepts a CSS hex string (with or without + * the leading `#`), rejects anything else by falling back to the default. + * Returns `undefined` when the input is falsy so callers can use the spread + * operator (`{ ...node, ...normalizeColor(node.color) }`) without overwriting + * existing fields with `undefined`. + */ +function normalizeColor(color) { + if (typeof color !== 'string' || color.length === 0) return { color: DEFAULT_NODE_COLOR }; + const trimmed = color.trim(); + // Hex: #rgb / #rrggbb (case-insensitive). Anything else falls back to default. + const hex = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(trimmed); + if (!hex) return { color: DEFAULT_NODE_COLOR }; + return { color: trimmed.startsWith('#') ? trimmed : `#${trimmed}` }; +} + function isValidEdge(edge) { return ( edge && @@ -82,12 +102,19 @@ function create(io) { return clone(graph); } - function addNode({ kind, x, y, label = '' }) { + function addNode({ kind, x, y, label = '', color }) { if (!NODE_KINDS.includes(kind)) { throw new Error(`flowchart-store: unknown node kind "${kind}"`); } snapshot(); - const node = { id: newId('n'), kind, x, y, label }; + const node = { + id: newId('n'), + kind, + x, + y, + label, + ...normalizeColor(color), + }; graph.nodes.push(node); emit(); return node; @@ -128,6 +155,14 @@ function create(io) { emit(); } + function setNodeColor(id, color) { + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], ...normalizeColor(color) }; + emit(); + } + function removeNode(id) { const idx = findNodeIndex(id); if (idx === -1) return; @@ -219,7 +254,10 @@ function create(io) { graph = { nodes: [], edges: [] }; return; } - const nodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : []; + const rawNodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : []; + // v4.12.0 — normalise the optional `color` field on every node so the + // rehydrated graph has a guaranteed valid color (#ffffff by default). + const nodes = rawNodes.map((n) => ({ ...n, ...normalizeColor(n.color) })); const nodeIds = new Set(nodes.map((n) => n.id)); const edges = Array.isArray(parsed.edges) ? parsed.edges.filter( @@ -242,6 +280,7 @@ function create(io) { moveNode, setNodeLabel, setNodeKind, + setNodeColor, removeNode, connect, disconnect, diff --git a/src/main.js b/src/main.js index 75f6915..e360070 100644 --- a/src/main.js +++ b/src/main.js @@ -5530,6 +5530,29 @@ ipcMain.on('open-flowchart-generator', () => { openFlowchartGenerator(); }); +// v4.12.0 — Save the flowchart's Mermaid source to a user-chosen file via a +// system Save dialog. Mirrors the v4.9.6 ASCII `ascii:save` handler shape +// (`{ canceled } | { canceled: false, path }`) but exposes a generic IPC +// channel so the standalone Flowchart Generator window can call it. The +// destination is fully user-controlled (the dialog enforces the path), so no +// userData-sandbox check is applied here. +ipcMain.handle('save-text-file', async (event, { content, defaultName } = {}) => { + const { dialog } = require('electron'); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win || undefined, { + title: 'Save Mermaid Source', + defaultPath: typeof defaultName === 'string' && defaultName ? defaultName : 'flowchart.mmd', + filters: [ + { name: 'Mermaid', extensions: ['mmd', 'md'] }, + { name: 'Text', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePath) return { canceled: true }; + await require('fs').promises.writeFile(result.filePath, content ?? '', 'utf-8'); + return { canceled: false, path: result.filePath }; +}); + // ============================================ // ASCII Art Generator Window // ============================================ diff --git a/src/preload.js b/src/preload.js index 687e229..9521e05 100644 --- a/src/preload.js +++ b/src/preload.js @@ -101,6 +101,9 @@ const ALLOWED_SEND_CHANNELS = [ // Flowchart generator (standalone window — v4.9.6) 'open-flowchart-generator', + // Flowchart generator: dialog-based file save (v4.12.0) + 'save-text-file', + // ASCII art generator (standalone window — invoke channels) 'ascii:generate', 'ascii:list-fonts', @@ -533,10 +536,16 @@ contextBridge.exposeInMainWorld('electronAPI', { // write-text-file) which sandbox writes to . Insert at cursor // sends the Mermaid-fenced source through the existing 'insert-content' // channel which the renderer.js sidebar panel also uses. + // + // v4.12.0 — `saveFile` opens a system Save dialog and writes the content + // to a user-chosen path. Bypasses the userData sandbox (user can save + // anywhere) — the dialog enforces the destination. flowchart: { 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 }), + saveFile: (content, defaultName) => + ipcRenderer.invoke('save-text-file', { content, defaultName }), insertAtCursor: (text) => ipcRenderer.send('insert-content', text), }, diff --git a/src/renderer/flowchart-bundle.js b/src/renderer/flowchart-bundle.js index 0a867ed..f92419a 100644 --- a/src/renderer/flowchart-bundle.js +++ b/src/renderer/flowchart-bundle.js @@ -1,5 +1,5 @@ /** - * v4.11.0 — Bundled single-file Flowchart Generator loader. + * v4.12.0 — Bundled single-file Flowchart Generator loader. * * Inlines the four pure modules (flowchart-shapes / flowchart-mermaid / * flowchart-store / flowchart-canvas) plus the renderer controller @@ -49,6 +49,18 @@ * selection state, no more floating toolbar. `promptInline` / * `confirmInline` are kept only for the Reset confirmation * modal. + * v4.12.0 — User feedback: the v4.11.0 connect form (From dropdown + + * To dropdown + "+ Edge") was buried below the node/edge + * lists and they couldn't find it. Moved the connect form + * up to the second section in #fc-nodelist (right after Add + * Node). Also added (a) a per-node color picker in the + * node list (`` → `store.setNodeColor`) + * — `shapeSvg` now accepts an optional color arg and + * normalises `#ffffff` by default; (b) a "Save to File" + * button alongside "Insert at Cursor" that opens a system + * save dialog via a new `save-text-file` IPC channel. The + * standalone top toolbar was removed; Insert / Save / Reset + * now live inside the panel's new "Export" section. * * The legacy individual files under src/flowchart/* and * src/renderer/flowchart-controller.js are kept untouched — the @@ -60,26 +72,30 @@ 'use strict'; // ========== flowchart-shapes (inlined) ========== + // v4.12.0 — accepts an optional `color` (6th) arg so the canvas can paint + // each node with its per-node fill color. Falls back to `#ffffff`. const SHAPE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document']; const DEFAULT_WIDTH = 140; const DEFAULT_HEIGHT = 60; const LABEL_PADDING_X = 16; const LABEL_PADDING_Y = 12; + const DEFAULT_FILL = '#ffffff'; - function shapeSvg(kind, x, y, width, height) { + function shapeSvg(kind, x, y, width, height, color) { if (!SHAPE_KINDS.includes(kind)) { throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`); } + const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL; switch (kind) { case 'process': - return ``; + return ``; case 'terminator': - return ``; + return ``; case 'subroutine': { const inset = 4; return ( - `` + - `` + `` + + `` ); } case 'decision': { @@ -89,7 +105,7 @@ const top = `${cx},${y}`; const right = `${x + width},${cy}`; const bottom = `${cx},${y + height}`; - return ``; + return ``; } case 'document': { // Parallelogram: top-right and bottom-right indented by ~20% of height. @@ -98,7 +114,7 @@ const tr = `${x + width},${y}`; const br = `${x + width - skew},${y + height}`; const bl = `${x},${y + height}`; - return ``; + return ``; } default: throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`); @@ -190,9 +206,12 @@ window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration }; // ========== flowchart-store (inlined) ========== + // v4.12.0 — Nodes carry an optional `color` field; `setNodeColor(id, color)` + // mutates it, and serialize/deserialize round-trip it. const STORE_NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document']; const STORE_EDGE_KINDS = ['solid', 'dotted', 'thick']; const STORE_UNDO_LIMIT = 50; + const STORE_DEFAULT_COLOR = '#ffffff'; function storeClone(obj) { return JSON.parse(JSON.stringify(obj)); @@ -214,6 +233,16 @@ ); } + function normalizeColor(color) { + if (typeof color !== 'string' || color.length === 0) { + return { color: STORE_DEFAULT_COLOR }; + } + const trimmed = color.trim(); + const hex = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(trimmed); + if (!hex) return { color: STORE_DEFAULT_COLOR }; + return { color: trimmed.startsWith('#') ? trimmed : `#${trimmed}` }; + } + function isValidEdge(edge) { return ( edge && @@ -261,12 +290,19 @@ return storeClone(graph); } - function addNode({ kind, x, y, label = '' }) { + function addNode({ kind, x, y, label = '', color }) { if (!STORE_NODE_KINDS.includes(kind)) { throw new Error(`flowchart-store: unknown node kind "${kind}"`); } snapshot(); - const node = { id: storeNewId('n'), kind, x, y, label }; + const node = { + id: storeNewId('n'), + kind, + x, + y, + label, + ...normalizeColor(color), + }; graph.nodes.push(node); emit(); return node; @@ -307,6 +343,14 @@ emit(); } + function setNodeColor(id, color) { + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], ...normalizeColor(color) }; + emit(); + } + function removeNode(id) { const idx = findNodeIndex(id); if (idx === -1) return; @@ -398,7 +442,8 @@ graph = { nodes: [], edges: [] }; return; } - const nodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : []; + const rawNodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : []; + const nodes = rawNodes.map((n) => ({ ...n, ...normalizeColor(n.color) })); const nodeIds = new Set(nodes.map((n) => n.id)); const edges = Array.isArray(parsed.edges) ? parsed.edges.filter( @@ -421,6 +466,7 @@ moveNode, setNodeLabel, setNodeKind, + setNodeColor, removeNode, connect, disconnect, @@ -545,7 +591,10 @@ tabindex: '0', 'aria-label': `${node.kind}: ${node.label || '(no label)'}`, }); - g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT); + // v4.12.0 — pass the per-node fill color through to the shape SVG. + // `shapeSvg` itself falls back to #ffffff when the color is missing + // or invalid, so old (uncolored) sessions keep rendering correctly. + g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT, node.color); const text = canvasSvgEl('text', { x: DEFAULT_WIDTH / 2, y: DEFAULT_HEIGHT / 2 + 4, @@ -783,6 +832,7 @@ previewSource: document.getElementById('preview-source'), previewRender: document.getElementById('preview-render'), btnInsert: document.getElementById('fc-btn-insert'), + btnSave: document.getElementById('fc-btn-save'), btnReset: document.getElementById('fc-btn-reset'), status: document.getElementById('fc-status'), // v4.11.0 — node-list panel (button-driven UI). Every mutation goes @@ -1167,6 +1217,19 @@ }); li.appendChild(labelInput); + // v4.12.0 — per-node fill color. Native opens a + // platform color picker (presets + custom). We listen for `input` + // (continuous as the user drags) so the canvas re-renders live. + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.value = node.color || '#ffffff'; + colorInput.title = 'Node fill color'; + colorInput.setAttribute('aria-label', 'Node fill color'); + colorInput.addEventListener('input', () => { + if (_store) _store.setNodeColor(node.id, colorInput.value); + }); + li.appendChild(colorInput); + const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.textContent = '×'; @@ -1363,6 +1426,30 @@ }); } + // v4.12.0 — Save to File. Pops a system save dialog and writes the + // Mermaid-fenced source to the user-chosen path via the generic + // 'save-text-file' IPC channel. The main-process handler resolves with + // `{ canceled: true }` if the user dismissed the dialog. + if (els.btnSave) { + els.btnSave.addEventListener('click', async () => { + if (!_store || !api.saveFile) return; + const source = toMermaid(_store.getGraph()); + const fenced = '```mermaid\n' + source + '\n```'; + try { + const result = await api.saveFile(fenced, 'flowchart.mmd'); + if (result && result.canceled) { + setStatus('Save cancelled'); + } else if (result && result.path) { + setStatus(`Saved to ${result.path}`); + } else { + setStatus('Saved'); + } + } catch (err) { + setStatus(`Save failed: ${err && err.message ? err.message : err}`); + } + }); + } + if (els.btnReset) { els.btnReset.addEventListener('click', async () => { if (!_store) return; diff --git a/tests/flowchart-controller.test.js b/tests/flowchart-controller.test.js index 8f810a2..8c08d35 100644 --- a/tests/flowchart-controller.test.js +++ b/tests/flowchart-controller.test.js @@ -551,3 +551,136 @@ describe('flowchart-bundle: button-driven node-list panel (v4.11.0)', () => { expect(edgelistUl.querySelectorAll('li')).toHaveLength(0); }); }); + +// v4.12.0 — per-node fill color picker. The user asked for the ability to +// color individual nodes. The bundle renders a native +// per node row, and `input` events call store.setNodeColor. The default +// (newly-added) value is #ffffff. +describe('flowchart-bundle: per-node color picker (v4.12.0)', () => { + const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js'); + const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html'); + + async function loadBundle(apiOverrides = {}) { + const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8'); + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + document.body.innerHTML = bodyMatch ? bodyMatch[1] : html; + const apiMock = { + getUserDataPath: jest.fn(async () => '/userdata'), + readFile: jest.fn(async () => null), + writeFile: jest.fn(async () => undefined), + insertAtCursor: jest.fn(), + saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/out.mmd' })), + ...apiOverrides, + }; + window.electronAPI = { flowchart: apiMock }; + const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8'); + // eslint-disable-next-line no-new-func + new Function('window', 'document', bundleSrc)(window, document); + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + return { store: window.FlowchartController.store, apiMock }; + } + + test('each node row exposes a color ', async () => { + const { store } = await loadBundle(); + store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' }); + const li = document.getElementById('fc-nodelist-ul').querySelector('li'); + const colorInput = li.querySelector('input[type="color"]'); + expect(colorInput).not.toBeNull(); + expect(colorInput.value).toBe('#ffffff'); + }); + + test('changing the color calls store.setNodeColor', async () => { + const { store } = await loadBundle(); + store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' }); + const li = document.getElementById('fc-nodelist-ul').querySelector('li'); + const colorInput = li.querySelector('input[type="color"]'); + colorInput.value = '#336699'; + colorInput.dispatchEvent(new window.Event('input', { bubbles: true })); + expect(store.getGraph().nodes[0].color).toBe('#336699'); + }); + + test('the canvas SVG reflects the chosen color after a setNodeColor mutation', async () => { + const { store } = await loadBundle(); + store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' }); + store.setNodeColor(store.getGraph().nodes[0].id, '#abcdef'); + await Promise.resolve(); + const nodeG = document.querySelector('svg.flowchart-canvas g[data-node-id]'); + const rect = nodeG && nodeG.querySelector('rect'); + expect(rect).not.toBeNull(); + expect(rect.getAttribute('fill')).toBe('#abcdef'); + }); +}); + +// v4.12.0 — Save to File. The bundle wires #fc-btn-save to api.saveFile +// with the Mermaid-fenced source and a default filename of 'flowchart.mmd'. +// Cancel / error paths surface in the status text. +describe('flowchart-bundle: Save to File button (v4.12.0)', () => { + const BUNDLE_PATH = path.join(__dirname, '..', 'src', 'renderer', 'flowchart-bundle.js'); + const HTML_PATH_BUNDLE = path.join(__dirname, '..', 'src', 'flowchart-generator.html'); + + async function loadBundle(apiOverrides = {}) { + const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8'); + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + document.body.innerHTML = bodyMatch ? bodyMatch[1] : html; + const apiMock = { + getUserDataPath: jest.fn(async () => '/userdata'), + readFile: jest.fn(async () => null), + writeFile: jest.fn(async () => undefined), + insertAtCursor: jest.fn(), + saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/out.mmd' })), + ...apiOverrides, + }; + window.electronAPI = { flowchart: apiMock }; + const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8'); + // eslint-disable-next-line no-new-func + new Function('window', 'document', bundleSrc)(window, document); + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + return { store: window.FlowchartController.store, apiMock }; + } + + test('Save to File calls api.saveFile with the Mermaid-fenced source', async () => { + const { store, apiMock } = await loadBundle(); + store.addNode({ kind: 'process', x: 0, y: 0, label: 'Save' }); + const btn = document.getElementById('fc-btn-save'); + expect(btn).not.toBeNull(); + btn.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(apiMock.saveFile).toHaveBeenCalledTimes(1); + const [content, defaultName] = apiMock.saveFile.mock.calls[0]; + expect(typeof content).toBe('string'); + expect(content.startsWith('```mermaid\n')).toBe(true); + expect(content.endsWith('\n```')).toBe(true); + expect(content).toContain('flowchart TD'); + expect(content).toContain('A[Save]'); + expect(defaultName).toBe('flowchart.mmd'); + }); + + test('Save to File surfaces "cancel" status when the user dismisses the dialog', async () => { + const { apiMock } = await loadBundle({ + saveFile: jest.fn(async () => ({ canceled: true })), + }); + document.getElementById('fc-btn-save').click(); + await Promise.resolve(); + await Promise.resolve(); + expect(apiMock.saveFile).toHaveBeenCalledTimes(1); + expect(document.getElementById('fc-status').textContent).toBe('Save cancelled'); + }); + + test('Save to File surfaces the error when the IPC handler throws', async () => { + const { apiMock } = await loadBundle({ + saveFile: jest.fn(async () => { + throw new Error('disk full'); + }), + }); + document.getElementById('fc-btn-save').click(); + await Promise.resolve(); + await Promise.resolve(); + expect(apiMock.saveFile).toHaveBeenCalledTimes(1); + expect(document.getElementById('fc-status').textContent).toBe('Save failed: disk full'); + }); +}); diff --git a/tests/flowchart-shapes.test.js b/tests/flowchart-shapes.test.js index 204b936..e9bc830 100644 --- a/tests/flowchart-shapes.test.js +++ b/tests/flowchart-shapes.test.js @@ -50,6 +50,50 @@ describe('flowchart-shapes: shapeSvg', () => { expect(() => shapeSvg('hexagon', 0, 0, 100, 50)).toThrow(/hexagon/); }); + // v4.12.0 — optional fill color. Defaults to #ffffff when omitted, used + // when provided, and applied to every emitted element (rect/polygon and + // both rects of the subroutine double-border). + test('process emits a with default fill #ffffff when color is omitted', () => { + const svg = shapeSvg('process', 10, 20, 100, 50); + expect(svg).toMatch(/fill="#ffffff"/); + }); + + test('process emits a with the given fill color', () => { + const svg = shapeSvg('process', 0, 0, 100, 50, '#ff0000'); + expect(svg).toMatch(/fill="#ff0000"/); + expect(svg).toMatch(/ { + const svg = shapeSvg('decision', 0, 0, 100, 60, '#336699'); + expect(svg).toMatch(/s with the fill color', () => { + const svg = shapeSvg('subroutine', 0, 0, 100, 50, '#aabbcc'); + const fills = svg.match(/fill="#aabbcc"/g) || []; + expect(fills.length).toBeGreaterThanOrEqual(2); + }); + + test('terminator honors the fill color', () => { + const svg = shapeSvg('terminator', 0, 0, 120, 40, '#123456'); + expect(svg).toMatch(/fill="#123456"/); + expect(svg).toMatch(/rx="/); + }); + + test('document (parallelogram) honors the fill color', () => { + const svg = shapeSvg('document', 0, 0, 120, 60, '#abcdef'); + expect(svg).toMatch(/ { + expect(shapeSvg('process', 0, 0, 100, 50, '')).toMatch(/fill="#ffffff"/); + expect(shapeSvg('process', 0, 0, 100, 50, null)).toMatch(/fill="#ffffff"/); + expect(shapeSvg('process', 0, 0, 100, 50, undefined)).toMatch(/fill="#ffffff"/); + }); + test('SHAPE_KINDS lists all 5 shapes', () => { expect(SHAPE_KINDS.sort()).toEqual([ 'decision', diff --git a/tests/flowchart-store.test.js b/tests/flowchart-store.test.js index 82c1c36..5e1ec45 100644 --- a/tests/flowchart-store.test.js +++ b/tests/flowchart-store.test.js @@ -61,6 +61,55 @@ describe('flowchart-store: node operations', () => { expect(store.getGraph().nodes.find((n) => n.id === node.id).kind).toBe('decision'); }); + // v4.12.0 — per-node fill color. Mirrors setNodeKind/setNodeLabel semantics. + test('addNode defaults color to #ffffff when not provided', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + expect(node.color).toBe('#ffffff'); + expect(store.getGraph().nodes[0].color).toBe('#ffffff'); + }); + + test('addNode accepts an explicit color', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '', color: '#ff0000' }); + expect(node.color).toBe('#ff0000'); + }); + + test('setNodeColor updates the color of an existing node', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeColor(node.id, '#336699'); + expect(store.getGraph().nodes.find((n) => n.id === node.id).color).toBe('#336699'); + }); + + test('setNodeColor accepts hex without leading #', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeColor(node.id, 'abcdef'); + expect(store.getGraph().nodes[0].color).toBe('#abcdef'); + }); + + test('setNodeColor falls back to #ffffff for non-hex strings', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeColor(node.id, 'not-a-color'); + expect(store.getGraph().nodes[0].color).toBe('#ffffff'); + }); + + test('setNodeColor throws on unknown node id', () => { + const store = create(makeIO()); + expect(() => store.setNodeColor('nope', '#ff0000')).toThrow(/nope/); + }); + + test('setNodeColor pushes an undo snapshot', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeColor(node.id, '#abcdef'); + expect(store.getGraph().nodes[0].color).toBe('#abcdef'); + store.undo(); + expect(store.getGraph().nodes[0].color).toBe('#ffffff'); + }); + 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' }); @@ -209,6 +258,31 @@ describe('flowchart-store: serialize / deserialize', () => { expect(restored.getGraph()).toEqual(store.getGraph()); }); + // v4.12.0 — color is part of the persisted graph and survives round-trip. + test('serialize → deserialize round-trip preserves per-node color', () => { + const store = create(makeIO()); + const a = store.addNode({ kind: 'process', x: 10, y: 20, label: 'A', color: '#ff0000' }); + const b = store.addNode({ kind: 'decision', x: 30, y: 40, label: 'B?', color: '#00aaff' }); + const json = store.serialize(); + const restored = create(makeIO()); + restored.deserialize(json); + const restoredA = restored.getGraph().nodes.find((n) => n.id === a.id); + const restoredB = restored.getGraph().nodes.find((n) => n.id === b.id); + expect(restoredA.color).toBe('#ff0000'); + expect(restoredB.color).toBe('#00aaff'); + }); + + test('deserialize normalises missing color to #ffffff', () => { + const store = create(makeIO()); + store.deserialize( + JSON.stringify({ + nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' }], + edges: [], + }) + ); + expect(store.getGraph().nodes[0].color).toBe('#ffffff'); + }); + test('deserialize handles corrupt JSON by returning empty graph', () => { const store = create(makeIO()); expect(() => store.deserialize('{not-json')).not.toThrow();