mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): discoverable connect form + per-node color + save-to-file export; v4.12.0
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
<input type="color"> 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 <script>), wires fc-btn-save to
api.saveFile, passes node.color to shapeSvg in the canvas render,
and renders <input type='color'> per node row.
- src/main.js — new ipcMain.handle('save-text-file', ...) using a
system Save dialog (mirrors the ascii:save handler shape).
- src/preload.js — added 'save-text-file' to ALLOWED_SEND_CHANNELS
and a saveFile(content, defaultName) helper on the flowchart bridge.
Tests: 92/92 suites, 1165/1165 tests pass on this run
- +9 store tests (color defaults, setNodeColor, undo, hex validation,
round-trip, deserialize normalisation)
- +7 shapes tests (fill color on all 5 kinds, fallback to #ffffff)
- +6 controller tests (color picker wires setNodeColor, canvas rect
reflects color, Save to File calls api.saveFile with the fenced
Mermaid source and 'flowchart.mmd', cancel + error paths surface in
status)
Verification:
- npm run lint clean
- npm run format:check clean
- npm test 92/92 suites, 1165/1165 tests pass
Amit Haridas
This commit is contained in:
@@ -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 <input type="color">
|
||||
// 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(/<body[^>]*>([\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 <input type="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 <input> 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 <rect> 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(/<body[^>]*>([\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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <rect> 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 <rect> with the given fill color', () => {
|
||||
const svg = shapeSvg('process', 0, 0, 100, 50, '#ff0000');
|
||||
expect(svg).toMatch(/fill="#ff0000"/);
|
||||
expect(svg).toMatch(/<rect/);
|
||||
});
|
||||
|
||||
test('decision (polygon) honors the fill color', () => {
|
||||
const svg = shapeSvg('decision', 0, 0, 100, 60, '#336699');
|
||||
expect(svg).toMatch(/<polygon/);
|
||||
expect(svg).toMatch(/fill="#336699"/);
|
||||
});
|
||||
|
||||
test('subroutine paints both concentric <rect>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(/<polygon/);
|
||||
expect(svg).toMatch(/fill="#abcdef"/);
|
||||
});
|
||||
|
||||
test('empty / falsy color falls back to #ffffff', () => {
|
||||
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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user