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:
2026-09-15 09:23:58 +05:30
parent e87e301b0f
commit 23046967c3
12 changed files with 535 additions and 43 deletions
+14 -7
View File
@@ -3,6 +3,11 @@
* Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a
* <g data-node-id="…"> alongside a <text> 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 `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" />`;
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />`;
case 'terminator':
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" ry="${height / 2}" />`;
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" ry="${height / 2}" fill="${fill}" />`;
case 'subroutine': {
const inset = 4;
return (
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" />` +
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" />`
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />` +
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" fill="${fill}" />`
);
}
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 `<polygon points="${left} ${top} ${right} ${bottom}" />`;
return `<polygon points="${left} ${top} ${right} ${bottom}" fill="${fill}" />`;
}
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 `<polygon points="${tl} ${tr} ${br} ${bl}" />`;
return `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" />`;
}
default:
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
+43 -4
View File
@@ -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,