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:
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v4.11.1
|
v4.12.0
|
||||||
|
|||||||
+16
@@ -1,5 +1,21 @@
|
|||||||
# PanConverter - Updates & Changelog
|
# 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)
|
## Version 4.11.1 (2026-09-15)
|
||||||
|
|
||||||
### Chore
|
### Chore
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"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",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
canvas itself is purely visual now (the v4.10.0 floating selection
|
canvas itself is purely visual now (the v4.10.0 floating selection
|
||||||
toolbar that fired on click hit-testing was unreliable in the
|
toolbar that fired on click hit-testing was unreliable in the
|
||||||
user's Electron runtime). Removed #fc-selection-toolbar.
|
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="fonts.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
@@ -97,6 +102,14 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #6e7681;
|
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 {
|
.fc-split {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -258,6 +271,32 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-width: 100px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -269,18 +308,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</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-split">
|
||||||
<div class="fc-left">
|
<div class="fc-left">
|
||||||
<div class="fc-canvas-host" id="canvas-host"></div>
|
<div class="fc-canvas-host" id="canvas-host"></div>
|
||||||
@@ -288,6 +315,12 @@
|
|||||||
v4.11.0 — button-driven UI. Every mutation (add/delete node,
|
v4.11.0 — button-driven UI. Every mutation (add/delete node,
|
||||||
add/delete edge, change label, change kind) goes through the
|
add/delete edge, change label, change kind) goes through the
|
||||||
controls in this panel. The canvas is purely visual now.
|
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">
|
<div id="fc-nodelist" class="fc-nodelist">
|
||||||
<h3 class="fc-section-title">Add Node</h3>
|
<h3 class="fc-section-title">Add Node</h3>
|
||||||
@@ -299,12 +332,8 @@
|
|||||||
<button class="fc-btn" data-add="document">+ Document</button>
|
<button class="fc-btn" data-add="document">+ Document</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="fc-section-title">Nodes <span id="fc-node-count">0</span></h3>
|
<h3 class="fc-section-title">Add Connection</h3>
|
||||||
<ul id="fc-nodelist-ul" class="fc-ul"></ul>
|
<p class="fc-help">Pick two nodes and click "+ Edge" to connect them.</p>
|
||||||
|
|
||||||
<h3 class="fc-section-title">Edges <span id="fc-edge-count">0</span></h3>
|
|
||||||
<ul id="fc-edgelist-ul" class="fc-ul"></ul>
|
|
||||||
|
|
||||||
<div class="fc-connect-row">
|
<div class="fc-connect-row">
|
||||||
<label for="fc-connect-from">From:</label>
|
<label for="fc-connect-from">From:</label>
|
||||||
<select id="fc-connect-from"></select>
|
<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-btn">+ Edge</button>
|
||||||
<button class="fc-btn" id="fc-connect-cancel">Refresh</button>
|
<button class="fc-btn" id="fc-connect-cancel">Refresh</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="fc-preview-host" id="preview-host">
|
<div class="fc-preview-host" id="preview-host">
|
||||||
@@ -331,6 +388,9 @@
|
|||||||
v4.11.0 — the bundle's controller bootstrap wires up the
|
v4.11.0 — the bundle's controller bootstrap wires up the
|
||||||
#fc-nodelist panel. The legacy #fc-selection-toolbar floating
|
#fc-nodelist panel. The legacy #fc-selection-toolbar floating
|
||||||
toolbar is gone.
|
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>
|
<script src="renderer/flowchart-bundle.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
* Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a
|
* Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a
|
||||||
* <g data-node-id="…"> alongside a <text> label.
|
* <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.
|
* Pure module: no DOM, no globals, no side effects.
|
||||||
*
|
*
|
||||||
* @module flowchart-shapes
|
* @module flowchart-shapes
|
||||||
@@ -15,21 +20,23 @@ const DEFAULT_WIDTH = 140;
|
|||||||
const DEFAULT_HEIGHT = 60;
|
const DEFAULT_HEIGHT = 60;
|
||||||
const LABEL_PADDING_X = 16;
|
const LABEL_PADDING_X = 16;
|
||||||
const LABEL_PADDING_Y = 12;
|
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)) {
|
if (!SHAPE_KINDS.includes(kind)) {
|
||||||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||||||
}
|
}
|
||||||
|
const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'process':
|
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':
|
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': {
|
case 'subroutine': {
|
||||||
const inset = 4;
|
const inset = 4;
|
||||||
return (
|
return (
|
||||||
`<rect x="${x}" y="${y}" width="${width}" height="${height}" 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" />`
|
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" fill="${fill}" />`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'decision': {
|
case 'decision': {
|
||||||
@@ -39,7 +46,7 @@ function shapeSvg(kind, x, y, width, height) {
|
|||||||
const top = `${cx},${y}`;
|
const top = `${cx},${y}`;
|
||||||
const right = `${x + width},${cy}`;
|
const right = `${x + width},${cy}`;
|
||||||
const bottom = `${cx},${y + height}`;
|
const bottom = `${cx},${y + height}`;
|
||||||
return `<polygon points="${left} ${top} ${right} ${bottom}" />`;
|
return `<polygon points="${left} ${top} ${right} ${bottom}" fill="${fill}" />`;
|
||||||
}
|
}
|
||||||
case 'document': {
|
case 'document': {
|
||||||
// Parallelogram: top-right and bottom-right indented by ~20% of height.
|
// 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 tr = `${x + width},${y}`;
|
||||||
const br = `${x + width - skew},${y + height}`;
|
const br = `${x + width - skew},${y + height}`;
|
||||||
const bl = `${x},${y + height}`;
|
const bl = `${x},${y + height}`;
|
||||||
return `<polygon points="${tl} ${tr} ${br} ${bl}" />`;
|
return `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" />`;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
* Pure graph store for the flow chart editor.
|
* Pure graph store for the flow chart editor.
|
||||||
*
|
*
|
||||||
* Graph = { nodes: Node[], edges: Edge[] }
|
* 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? }
|
* 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:
|
* IO is injected for unit tests + persistence:
|
||||||
* { persistencePath, readFile, writeFile, now }
|
* { persistencePath, readFile, writeFile, now }
|
||||||
*
|
*
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
||||||
const EDGE_KINDS = ['solid', 'dotted', 'thick'];
|
const EDGE_KINDS = ['solid', 'dotted', 'thick'];
|
||||||
const UNDO_LIMIT = 50;
|
const UNDO_LIMIT = 50;
|
||||||
|
const DEFAULT_NODE_COLOR = '#ffffff';
|
||||||
|
|
||||||
function clone(obj) {
|
function clone(obj) {
|
||||||
return JSON.parse(JSON.stringify(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) {
|
function isValidEdge(edge) {
|
||||||
return (
|
return (
|
||||||
edge &&
|
edge &&
|
||||||
@@ -82,12 +102,19 @@ function create(io) {
|
|||||||
return clone(graph);
|
return clone(graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNode({ kind, x, y, label = '' }) {
|
function addNode({ kind, x, y, label = '', color }) {
|
||||||
if (!NODE_KINDS.includes(kind)) {
|
if (!NODE_KINDS.includes(kind)) {
|
||||||
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
||||||
}
|
}
|
||||||
snapshot();
|
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);
|
graph.nodes.push(node);
|
||||||
emit();
|
emit();
|
||||||
return node;
|
return node;
|
||||||
@@ -128,6 +155,14 @@ function create(io) {
|
|||||||
emit();
|
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) {
|
function removeNode(id) {
|
||||||
const idx = findNodeIndex(id);
|
const idx = findNodeIndex(id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -219,7 +254,10 @@ function create(io) {
|
|||||||
graph = { nodes: [], edges: [] };
|
graph = { nodes: [], edges: [] };
|
||||||
return;
|
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 nodeIds = new Set(nodes.map((n) => n.id));
|
||||||
const edges = Array.isArray(parsed.edges)
|
const edges = Array.isArray(parsed.edges)
|
||||||
? parsed.edges.filter(
|
? parsed.edges.filter(
|
||||||
@@ -242,6 +280,7 @@ function create(io) {
|
|||||||
moveNode,
|
moveNode,
|
||||||
setNodeLabel,
|
setNodeLabel,
|
||||||
setNodeKind,
|
setNodeKind,
|
||||||
|
setNodeColor,
|
||||||
removeNode,
|
removeNode,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
|||||||
+23
@@ -5530,6 +5530,29 @@ ipcMain.on('open-flowchart-generator', () => {
|
|||||||
openFlowchartGenerator();
|
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
|
// ASCII Art Generator Window
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
// Flowchart generator (standalone window — v4.9.6)
|
// Flowchart generator (standalone window — v4.9.6)
|
||||||
'open-flowchart-generator',
|
'open-flowchart-generator',
|
||||||
|
|
||||||
|
// Flowchart generator: dialog-based file save (v4.12.0)
|
||||||
|
'save-text-file',
|
||||||
|
|
||||||
// ASCII art generator (standalone window — invoke channels)
|
// ASCII art generator (standalone window — invoke channels)
|
||||||
'ascii:generate',
|
'ascii:generate',
|
||||||
'ascii:list-fonts',
|
'ascii:list-fonts',
|
||||||
@@ -533,10 +536,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// write-text-file) which sandbox writes to <userData>. Insert at cursor
|
// write-text-file) which sandbox writes to <userData>. Insert at cursor
|
||||||
// sends the Mermaid-fenced source through the existing 'insert-content'
|
// sends the Mermaid-fenced source through the existing 'insert-content'
|
||||||
// channel which the renderer.js sidebar panel also uses.
|
// 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: {
|
flowchart: {
|
||||||
getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'),
|
getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'),
|
||||||
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
|
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
|
||||||
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
|
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),
|
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 /
|
* Inlines the four pure modules (flowchart-shapes / flowchart-mermaid /
|
||||||
* flowchart-store / flowchart-canvas) plus the renderer controller
|
* flowchart-store / flowchart-canvas) plus the renderer controller
|
||||||
@@ -49,6 +49,18 @@
|
|||||||
* selection state, no more floating toolbar. `promptInline` /
|
* selection state, no more floating toolbar. `promptInline` /
|
||||||
* `confirmInline` are kept only for the Reset confirmation
|
* `confirmInline` are kept only for the Reset confirmation
|
||||||
* modal.
|
* 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
|
* The legacy individual files under src/flowchart/* and
|
||||||
* src/renderer/flowchart-controller.js are kept untouched — the
|
* src/renderer/flowchart-controller.js are kept untouched — the
|
||||||
@@ -60,26 +72,30 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ========== flowchart-shapes (inlined) ==========
|
// ========== 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 SHAPE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
||||||
const DEFAULT_WIDTH = 140;
|
const DEFAULT_WIDTH = 140;
|
||||||
const DEFAULT_HEIGHT = 60;
|
const DEFAULT_HEIGHT = 60;
|
||||||
const LABEL_PADDING_X = 16;
|
const LABEL_PADDING_X = 16;
|
||||||
const LABEL_PADDING_Y = 12;
|
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)) {
|
if (!SHAPE_KINDS.includes(kind)) {
|
||||||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||||||
}
|
}
|
||||||
|
const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'process':
|
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':
|
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': {
|
case 'subroutine': {
|
||||||
const inset = 4;
|
const inset = 4;
|
||||||
return (
|
return (
|
||||||
`<rect x="${x}" y="${y}" width="${width}" height="${height}" 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" />`
|
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" fill="${fill}" />`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'decision': {
|
case 'decision': {
|
||||||
@@ -89,7 +105,7 @@
|
|||||||
const top = `${cx},${y}`;
|
const top = `${cx},${y}`;
|
||||||
const right = `${x + width},${cy}`;
|
const right = `${x + width},${cy}`;
|
||||||
const bottom = `${cx},${y + height}`;
|
const bottom = `${cx},${y + height}`;
|
||||||
return `<polygon points="${left} ${top} ${right} ${bottom}" />`;
|
return `<polygon points="${left} ${top} ${right} ${bottom}" fill="${fill}" />`;
|
||||||
}
|
}
|
||||||
case 'document': {
|
case 'document': {
|
||||||
// Parallelogram: top-right and bottom-right indented by ~20% of height.
|
// Parallelogram: top-right and bottom-right indented by ~20% of height.
|
||||||
@@ -98,7 +114,7 @@
|
|||||||
const tr = `${x + width},${y}`;
|
const tr = `${x + width},${y}`;
|
||||||
const br = `${x + width - skew},${y + height}`;
|
const br = `${x + width - skew},${y + height}`;
|
||||||
const bl = `${x},${y + height}`;
|
const bl = `${x},${y + height}`;
|
||||||
return `<polygon points="${tl} ${tr} ${br} ${bl}" />`;
|
return `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" />`;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
|
||||||
@@ -190,9 +206,12 @@
|
|||||||
window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
|
window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
|
||||||
|
|
||||||
// ========== flowchart-store (inlined) ==========
|
// ========== 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_NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
|
||||||
const STORE_EDGE_KINDS = ['solid', 'dotted', 'thick'];
|
const STORE_EDGE_KINDS = ['solid', 'dotted', 'thick'];
|
||||||
const STORE_UNDO_LIMIT = 50;
|
const STORE_UNDO_LIMIT = 50;
|
||||||
|
const STORE_DEFAULT_COLOR = '#ffffff';
|
||||||
|
|
||||||
function storeClone(obj) {
|
function storeClone(obj) {
|
||||||
return JSON.parse(JSON.stringify(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) {
|
function isValidEdge(edge) {
|
||||||
return (
|
return (
|
||||||
edge &&
|
edge &&
|
||||||
@@ -261,12 +290,19 @@
|
|||||||
return storeClone(graph);
|
return storeClone(graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNode({ kind, x, y, label = '' }) {
|
function addNode({ kind, x, y, label = '', color }) {
|
||||||
if (!STORE_NODE_KINDS.includes(kind)) {
|
if (!STORE_NODE_KINDS.includes(kind)) {
|
||||||
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
|
||||||
}
|
}
|
||||||
snapshot();
|
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);
|
graph.nodes.push(node);
|
||||||
emit();
|
emit();
|
||||||
return node;
|
return node;
|
||||||
@@ -307,6 +343,14 @@
|
|||||||
emit();
|
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) {
|
function removeNode(id) {
|
||||||
const idx = findNodeIndex(id);
|
const idx = findNodeIndex(id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -398,7 +442,8 @@
|
|||||||
graph = { nodes: [], edges: [] };
|
graph = { nodes: [], edges: [] };
|
||||||
return;
|
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 nodeIds = new Set(nodes.map((n) => n.id));
|
||||||
const edges = Array.isArray(parsed.edges)
|
const edges = Array.isArray(parsed.edges)
|
||||||
? parsed.edges.filter(
|
? parsed.edges.filter(
|
||||||
@@ -421,6 +466,7 @@
|
|||||||
moveNode,
|
moveNode,
|
||||||
setNodeLabel,
|
setNodeLabel,
|
||||||
setNodeKind,
|
setNodeKind,
|
||||||
|
setNodeColor,
|
||||||
removeNode,
|
removeNode,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -545,7 +591,10 @@
|
|||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'aria-label': `${node.kind}: ${node.label || '(no label)'}`,
|
'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', {
|
const text = canvasSvgEl('text', {
|
||||||
x: DEFAULT_WIDTH / 2,
|
x: DEFAULT_WIDTH / 2,
|
||||||
y: DEFAULT_HEIGHT / 2 + 4,
|
y: DEFAULT_HEIGHT / 2 + 4,
|
||||||
@@ -783,6 +832,7 @@
|
|||||||
previewSource: document.getElementById('preview-source'),
|
previewSource: document.getElementById('preview-source'),
|
||||||
previewRender: document.getElementById('preview-render'),
|
previewRender: document.getElementById('preview-render'),
|
||||||
btnInsert: document.getElementById('fc-btn-insert'),
|
btnInsert: document.getElementById('fc-btn-insert'),
|
||||||
|
btnSave: document.getElementById('fc-btn-save'),
|
||||||
btnReset: document.getElementById('fc-btn-reset'),
|
btnReset: document.getElementById('fc-btn-reset'),
|
||||||
status: document.getElementById('fc-status'),
|
status: document.getElementById('fc-status'),
|
||||||
// v4.11.0 — node-list panel (button-driven UI). Every mutation goes
|
// v4.11.0 — node-list panel (button-driven UI). Every mutation goes
|
||||||
@@ -1167,6 +1217,19 @@
|
|||||||
});
|
});
|
||||||
li.appendChild(labelInput);
|
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');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.type = 'button';
|
delBtn.type = 'button';
|
||||||
delBtn.textContent = '×';
|
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) {
|
if (els.btnReset) {
|
||||||
els.btnReset.addEventListener('click', async () => {
|
els.btnReset.addEventListener('click', async () => {
|
||||||
if (!_store) return;
|
if (!_store) return;
|
||||||
|
|||||||
@@ -551,3 +551,136 @@ describe('flowchart-bundle: button-driven node-list panel (v4.11.0)', () => {
|
|||||||
expect(edgelistUl.querySelectorAll('li')).toHaveLength(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/);
|
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', () => {
|
test('SHAPE_KINDS lists all 5 shapes', () => {
|
||||||
expect(SHAPE_KINDS.sort()).toEqual([
|
expect(SHAPE_KINDS.sort()).toEqual([
|
||||||
'decision',
|
'decision',
|
||||||
|
|||||||
@@ -61,6 +61,55 @@ describe('flowchart-store: node operations', () => {
|
|||||||
expect(store.getGraph().nodes.find((n) => n.id === node.id).kind).toBe('decision');
|
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', () => {
|
test('removeNode removes the node and any connected edges', () => {
|
||||||
const store = create(makeIO());
|
const store = create(makeIO());
|
||||||
const a = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
|
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());
|
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', () => {
|
test('deserialize handles corrupt JSON by returning empty graph', () => {
|
||||||
const store = create(makeIO());
|
const store = create(makeIO());
|
||||||
expect(() => store.deserialize('{not-json')).not.toThrow();
|
expect(() => store.deserialize('{not-json')).not.toThrow();
|
||||||
|
|||||||
Reference in New Issue
Block a user