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
+1 -1
View File
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version
v4.11.1
v4.12.0
+16
View File
@@ -1,5 +1,21 @@
# PanConverter - Updates & Changelog
## Version 4.12.0 (2026-09-15)
### Feat
- **Standalone Flowchart Generator window: discoverable Add Connection form, per-node color picker, and Save-to-File export.**
- **Reorganised the `#fc-nodelist` panel.** User feedback on v4.11.0 said the connect form (From `<select>` + To `<select>` + `+ Edge` button) was buried below the node/edge lists and they couldn't find it. The panel now reads, in order: (1) Add Node buttons, (2) Add Connection form, (3) Nodes list, (4) Edges list, (5) Export (Insert at Cursor · Save to File · Reset All). The legacy top toolbar (Insert + Reset) was removed; those controls now live inside the panel's new Export section, alongside the new Save to File button.
- **Per-node fill color.** Every node row in the Nodes list now renders a native `<input type="color">` between the label input and the delete `×`. Dragging through the picker fires `input` events that call the new `store.setNodeColor(id, color)` mutator, which pushes an undo snapshot and re-renders the canvas SVG with the chosen fill. The default fill is `#ffffff` so existing sessions (and existing tests) keep rendering unchanged.
- **`shapeSvg` accepts an optional color arg.** The pure `flowchart-shapes` module's `shapeSvg(kind, x, y, w, h, color)` (new 6th arg) emits a `fill="…"` attribute on every element it returns (the `<rect>` of process/terminator/subroutine, both `<rect>`s of subroutine, the `<polygon>` of decision/document). Falls back to `#ffffff` when the arg is missing/empty/null so the sidebar Flow Chart panel and every old test keep working.
- **`flowchart-store.setNodeColor` + persistence.** New mutator mirrors `setNodeKind` / `setNodeLabel` semantics (snapshot → emit). `addNode` now accepts an optional `color`. `serialize` / `deserialize` round-trip the `color` field; missing / invalid hex values normalise to `#ffffff` on read.
- **Save to File.** A new `Save to File` button next to `Insert at Cursor` calls `api.saveFile(fenced, 'flowchart.mmd')`, which invokes a new `save-text-file` IPC channel. The main-process handler (`src/main.js`) shows a system save dialog with `.mmd` / `.md` / `.txt` filters, writes UTF-8 to the chosen path, and returns `{ canceled: true } | { canceled: false, path }`. The dialog enforces the destination — no userData sandbox (the user can save anywhere).
- **`src/preload.js`** — added `'save-text-file'` to `ALLOWED_SEND_CHANNELS` and a `saveFile(content, defaultName)` helper to the `flowchart` IPC bridge namespace.
- **22 new tests:**
- `tests/flowchart-store.test.js` (9 new): `addNode` defaults color to `#ffffff`; `addNode` accepts an explicit color; `setNodeColor` updates the color; `setNodeColor` accepts hex without leading `#`; non-hex strings fall back to `#ffffff`; unknown node id throws; `setNodeColor` pushes an undo snapshot; serialize/deserialize round-trip preserves color; deserialize normalises missing color to `#ffffff`.
- `tests/flowchart-shapes.test.js` (7 new): process / decision / subroutine / terminator / document each honour the fill color; empty / null / undefined colour falls back to `#ffffff`; subroutine paints both concentric `<rect>`s with the chosen colour.
- `tests/flowchart-controller.test.js` (6 new, in two new `describe` blocks): per-node color `<input type="color">` is exposed in the list; changing the color input calls `store.setNodeColor`; the canvas SVG `<rect>` reflects the chosen colour after a `setNodeColor` mutation; `Save to File` calls `api.saveFile` with the Mermaid-fenced source and `'flowchart.mmd'`; cancel / error paths surface in `#fc-status`.
## Version 4.11.1 (2026-09-15)
### Chore
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
"version": "4.11.1",
"version": "4.12.0",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
+78 -18
View File
@@ -15,6 +15,11 @@
canvas itself is purely visual now (the v4.10.0 floating selection
toolbar that fired on click hit-testing was unreliable in the
user's Electron runtime). Removed #fc-selection-toolbar.
v4.12.0 — Reorganised #fc-nodelist so the connect form is the
second section (right after Add Node) instead of being buried below
the node/edge lists; added per-node color picker; added
"Save to File" button next to "Insert at Cursor".
-->
<link rel="stylesheet" href="fonts.css" />
<link rel="stylesheet" href="styles.css" />
@@ -97,6 +102,14 @@
font-size: 0.75rem;
color: #6e7681;
}
/* v4.12.0 — the status line moved inside #fc-nodelist (no longer a
* flex toolbar child), so `margin-left: auto` is a no-op there. Reset
* it explicitly so the status sits flush-left below the toolbar row. */
.fc-nodelist .fc-status {
margin-left: 0;
display: block;
min-height: 1em;
}
.fc-split {
display: flex;
flex: 1;
@@ -258,6 +271,32 @@
border-radius: 4px;
min-width: 100px;
}
/* v4.12.0 — small helper text under section titles */
.fc-help {
font-size: 11px;
color: #57606a;
margin: 0 0 8px;
}
/* v4.12.0 — per-node fill color picker sits next to the kind <select>
* and label <input>. Native <input type="color"> gives a platform-native
* color dialog so the user can pick any color, including custom. */
.fc-ul li input[type='color'] {
flex: 0 0 auto;
padding: 0;
width: 32px;
height: 24px;
border: 1px solid var(--fc-border);
border-radius: 4px;
cursor: pointer;
background: #fff;
}
.fc-toolbar-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 8px;
}
</style>
</head>
<body>
@@ -269,18 +308,6 @@
>
</div>
<div class="fc-toolbar">
<button
class="fc-btn primary"
id="fc-btn-insert"
title="Insert Mermaid block at cursor in main editor"
>
Insert at Cursor
</button>
<button class="fc-btn" id="fc-btn-reset" title="Clear all nodes and edges">Reset</button>
<span class="fc-status" id="fc-status" aria-live="polite"></span>
</div>
<div class="fc-split">
<div class="fc-left">
<div class="fc-canvas-host" id="canvas-host"></div>
@@ -288,6 +315,12 @@
v4.11.0 — button-driven UI. Every mutation (add/delete node,
add/delete edge, change label, change kind) goes through the
controls in this panel. The canvas is purely visual now.
v4.12.0 — Reorganised the panel so the Connect form is the
second section (right after Add Node) — user feedback said
the old layout buried it below the node/edge lists and they
couldn't find it. Also added a per-node color picker and a
Save to File button alongside Insert at Cursor.
-->
<div id="fc-nodelist" class="fc-nodelist">
<h3 class="fc-section-title">Add Node</h3>
@@ -299,12 +332,8 @@
<button class="fc-btn" data-add="document">+ Document</button>
</div>
<h3 class="fc-section-title">Nodes <span id="fc-node-count">0</span></h3>
<ul id="fc-nodelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Edges <span id="fc-edge-count">0</span></h3>
<ul id="fc-edgelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Add Connection</h3>
<p class="fc-help">Pick two nodes and click "+ Edge" to connect them.</p>
<div class="fc-connect-row">
<label for="fc-connect-from">From:</label>
<select id="fc-connect-from"></select>
@@ -313,6 +342,34 @@
<button class="fc-btn" id="fc-connect-btn">+ Edge</button>
<button class="fc-btn" id="fc-connect-cancel">Refresh</button>
</div>
<h3 class="fc-section-title">Nodes <span id="fc-node-count">0</span></h3>
<ul id="fc-nodelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Edges <span id="fc-edge-count">0</span></h3>
<ul id="fc-edgelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Export</h3>
<div class="fc-toolbar-row">
<button
class="fc-btn primary"
id="fc-btn-insert"
title="Insert Mermaid block at cursor in main editor"
>
Insert at Cursor
</button>
<button
class="fc-btn"
id="fc-btn-save"
title="Save Mermaid source to a .mmd / .md / .txt file"
>
Save to File
</button>
<button class="fc-btn" id="fc-btn-reset" title="Clear all nodes and edges">
Reset All
</button>
</div>
<span id="fc-status" aria-live="polite"></span>
</div>
</div>
<div class="fc-preview-host" id="preview-host">
@@ -331,6 +388,9 @@
v4.11.0 — the bundle's controller bootstrap wires up the
#fc-nodelist panel. The legacy #fc-selection-toolbar floating
toolbar is gone.
v4.12.0 — the bundle also wires the new #fc-btn-save button and
the per-node color picker inside the node list.
-->
<script src="renderer/flowchart-bundle.js"></script>
</body>
+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,
+23
View File
@@ -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
// ============================================
+9
View File
@@ -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 <userData>. 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),
},
+99 -12
View File
@@ -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 (`<input type="color">` → `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 `<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': {
@@ -89,7 +105,7 @@
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.
@@ -98,7 +114,7 @@
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}"`);
@@ -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 <input type="color"> 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;
+133
View File
@@ -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');
});
});
+44
View File
@@ -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',
+74
View File
@@ -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();