mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
fix(flowchart): wire selection + layout precedence; v4.9.4
- Add opts.onNodeClick + surgical applySelectionHighlight() so node/edge clicks immediately paint .selected and mirror id into panel state (Delete/Backspace works on freshly-clicked node). - Add Maximize/Restore button to flowchart toolbar: toggles .main-content.flowchart-takeover which hides .editor-container and lets the canvas + preview split the full window width instead of the 280px sidebar. - destroy() clears the takeover class so the editor stays usable after leaving the panel. - 8 new tests in tests/flowchart-panel.test.js for selection wiring and takeover. Amit Haridas
This commit is contained in:
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v4.9.3
|
v4.9.4
|
||||||
|
|||||||
+17
@@ -1,5 +1,22 @@
|
|||||||
# PanConverter - Updates & Changelog
|
# PanConverter - Updates & Changelog
|
||||||
|
|
||||||
|
## Version 4.9.4 (2026-09-14)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **Flowchart Panel — selection was invisible**: clicking a node or edge updated the canvas's internal `selectedNodeId` / `selectedEdgeId` but never repainted, so the `.flowchart-node.selected` / `.flowchart-edge.selected` CSS highlight only appeared when the user actually dragged (which triggers `store.subscribe` → `render()`). A bare click left the canvas looking unchanged, and the panel's own `selectedNodeId` (used by the panel-scoped Delete/Backspace shortcut) stayed `null`, so Delete on a freshly-clicked node silently no-op'd. Wired `opts.onNodeClick(id)` end-to-end:
|
||||||
|
- `src/flowchart/flowchart-canvas.js` — added an `opts.onNodeClick` callback parallel to the existing `opts.onEdgeClick`; on click, both branches now call a new surgical `applySelectionHighlight()` that toggles the `.selected` class on the existing `<g data-node-id>` / `<line data-edge-id>` elements without going through `render()` (which would detach the very element the user's pointer is still on, breaking `pointermove`/`pointerup` bubbling during a drag).
|
||||||
|
- `src/sidebar/flowchart-panel.js` — the panel's `onNodeClick` handler mirrors the id into the panel's `selectedNodeId` (clearing `selectedEdgeId`) so Delete/Backspace routes correctly. Same symmetry was already in place for `onEdgeClick`.
|
||||||
|
- **Flowchart Panel — narrow sidebar cramped the canvas + preview**: the panel lives in the 280 px sidebar, which split the canvas vs. preview to ~175 px each — too tight to edit a flowchart. Added a "Maximize / Restore" button to the panel toolbar (between the status text and the right edge). Clicking it toggles a `flowchart-takeover` class on `.main-content`:
|
||||||
|
- `src/styles-sidebar.css` — new `.main-content.flowchart-takeover` rules hide `.editor-container` (`display: none`) and let `.sidebar` / `.sidebar-panel` grow with `flex: 1` so the canvas + preview split the full window width instead of the 280 px sidebar.
|
||||||
|
- The button label flips between "Maximize" and "Restore", `aria-label` and `title` update, and the button gets an `.active` highlight while takeover is on. `destroy()` clears the class so leaving the panel doesn't leave the editor hidden for the rest of the session.
|
||||||
|
- The class lookup walks up from the panel container to the nearest `.main-content` ancestor (with a `document.querySelector('.main-content')` fallback) so the panel doesn't need to know whether the sidebar lives inside `#sidebar` or any future container.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/flowchart-panel.test.js` — added two new `describe` blocks (8 tests total):
|
||||||
|
- "selection wiring (canvas click → panel state + SVG class)": clicking a node applies `.selected` to the matching `<g>`, clicking a second node moves `.selected` from the first to the second, clicking an edge applies `.selected` to the matching `<line>`, and a regression test verifying Delete removes a freshly-clicked node (was broken in v4.9.3 because the panel's `selectedNodeId` was never updated by canvas clicks).
|
||||||
|
- "maximize / takeover": the maximize button is exposed in the toolbar, clicking it toggles `.flowchart-takeover` on `.main-content` and flips the button label/active class, and `destroy()` clears the class so the editor stays usable.
|
||||||
|
- New `mountWithMainContent()` helper wraps the panel container in a fake `.main-content` (mirroring the real DOM layout in `src/index.html:2341`) so the takeover's class-toggling is observable from the test.
|
||||||
|
|
||||||
## Version 4.9.3 (2026-09-14)
|
## Version 4.9.3 (2026-09-14)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.9.3",
|
"version": "4.9.4",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -131,6 +131,28 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surgical selection highlight — toggles the `.selected` class on the
|
||||||
|
// existing SVG <g> / <line> elements without going through render() (which
|
||||||
|
// replaces all children and would detach the very element the user's
|
||||||
|
// pointer is still on, breaking pointermove/pointerup bubbling on the
|
||||||
|
// same node during a drag). Called from onPointerDown after the internal
|
||||||
|
// selectedNodeId/selectedEdgeId update. The existing
|
||||||
|
// .flowchart-node.selected / .flowchart-edge.selected CSS rules (see
|
||||||
|
// src/styles-sidebar.css) handle the visual highlight.
|
||||||
|
function applySelectionHighlight() {
|
||||||
|
if (destroyed) return;
|
||||||
|
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
|
||||||
|
nodeEls.forEach((g) => {
|
||||||
|
const id = g.getAttribute('data-node-id');
|
||||||
|
g.classList.toggle('selected', id === selectedNodeId);
|
||||||
|
});
|
||||||
|
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
|
||||||
|
edgeEls.forEach((l) => {
|
||||||
|
const id = l.getAttribute('data-edge-id');
|
||||||
|
l.classList.toggle('selected', id === selectedEdgeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----- pointer events -----
|
// ----- pointer events -----
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
|
|
||||||
@@ -152,6 +174,13 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
selectedNodeId = nodeId;
|
selectedNodeId = nodeId;
|
||||||
selectedEdgeId = null;
|
selectedEdgeId = null;
|
||||||
|
// Paint the .flowchart-node.selected highlight immediately on a bare
|
||||||
|
// click (without a drag). store.subscribe would normally trigger
|
||||||
|
// render() after moveNode; a click-only path has no store mutation, so
|
||||||
|
// we apply the highlight ourselves. Surgical toggle — not a full
|
||||||
|
// render() — so the pointerdown target stays attached and subsequent
|
||||||
|
// pointermove/pointerup can still bubble on the same element.
|
||||||
|
applySelectionHighlight();
|
||||||
const start = getSvgPoint(ev.clientX, ev.clientY);
|
const start = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
if (ev.altKey) {
|
if (ev.altKey) {
|
||||||
// Alt+drag = create a new edge from this node to wherever the pointer
|
// Alt+drag = create a new edge from this node to wherever the pointer
|
||||||
@@ -167,6 +196,11 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
pointerY: start.y,
|
pointerY: start.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Notify the panel so its internal selection state (used by Delete /
|
||||||
|
// Backspace keyboard shortcuts) tracks the canvas selection.
|
||||||
|
if (typeof opts.onNodeClick === 'function') {
|
||||||
|
opts.onNodeClick(nodeId, ev);
|
||||||
|
}
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,6 +208,10 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
if (edgeLine) {
|
if (edgeLine) {
|
||||||
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
|
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
|
||||||
selectedNodeId = null;
|
selectedNodeId = null;
|
||||||
|
// Same reasoning as the node branch above: paint the edge highlight
|
||||||
|
// immediately so a click-without-drag isn't invisible until the next
|
||||||
|
// store mutation triggers a re-render.
|
||||||
|
applySelectionHighlight();
|
||||||
if (typeof opts.onEdgeClick === 'function') {
|
if (typeof opts.onEdgeClick === 'function') {
|
||||||
opts.onEdgeClick(selectedEdgeId, ev);
|
opts.onEdgeClick(selectedEdgeId, ev);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
* - debounced persistence to <userData>/flowchart-session.json
|
* - debounced persistence to <userData>/flowchart-session.json
|
||||||
* - panel-scoped keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z / Delete)
|
* - panel-scoped keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z / Delete)
|
||||||
* - "Insert at Cursor" button (reuses the existing `insert-content` IPC)
|
* - "Insert at Cursor" button (reuses the existing `insert-content` IPC)
|
||||||
|
* - "Maximize / Restore" toggle that promotes the panel to fill the
|
||||||
|
* main-content area (hides the editor-container) so the canvas +
|
||||||
|
* preview split get the full window width instead of the 280px sidebar.
|
||||||
*
|
*
|
||||||
* @param {HTMLElement} container Mount point inside the sidebar panel
|
* @param {HTMLElement} container Mount point inside the sidebar panel
|
||||||
* @param {object} deps
|
* @param {object} deps
|
||||||
@@ -27,6 +30,10 @@ const { toMermaid } = require('../flowchart/flowchart-mermaid');
|
|||||||
const PREVIEW_DEBOUNCE_MS = 250;
|
const PREVIEW_DEBOUNCE_MS = 250;
|
||||||
const PERSIST_DEBOUNCE_MS = 500;
|
const PERSIST_DEBOUNCE_MS = 500;
|
||||||
const PERSISTENCE_FILENAME = 'flowchart-session.json';
|
const PERSISTENCE_FILENAME = 'flowchart-session.json';
|
||||||
|
// CSS class toggled on `.main-content` while the panel is in takeover mode.
|
||||||
|
// Kept colocated with the panel so any reader can grep for it. See
|
||||||
|
// src/styles-sidebar.css `.main-content.flowchart-takeover` rules.
|
||||||
|
const TAKEOVER_CLASS = 'flowchart-takeover';
|
||||||
|
|
||||||
function renderFlowChartPanel(container, deps) {
|
function renderFlowChartPanel(container, deps) {
|
||||||
const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps;
|
const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps;
|
||||||
@@ -51,6 +58,14 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
Insert at Cursor
|
Insert at Cursor
|
||||||
</button>
|
</button>
|
||||||
<span class="flowchart-status" aria-live="polite"></span>
|
<span class="flowchart-status" aria-live="polite"></span>
|
||||||
|
<button
|
||||||
|
class="flowchart-maximize-btn"
|
||||||
|
title="Maximize: hide the editor and let the canvas + preview fill the main area"
|
||||||
|
aria-label="Maximize Flow Chart panel"
|
||||||
|
>
|
||||||
|
<span class="flowchart-maximize-icon" aria-hidden="true">⤢</span>
|
||||||
|
<span class="flowchart-maximize-label">Maximize</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flowchart-split">
|
<div class="flowchart-split">
|
||||||
<div class="flowchart-canvas-host"></div>
|
<div class="flowchart-canvas-host"></div>
|
||||||
@@ -67,9 +82,29 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
const previewRenderEl = container.querySelector('.flowchart-preview-render');
|
const previewRenderEl = container.querySelector('.flowchart-preview-render');
|
||||||
const insertBtn = container.querySelector('.flowchart-insert-btn');
|
const insertBtn = container.querySelector('.flowchart-insert-btn');
|
||||||
const statusEl = container.querySelector('.flowchart-status');
|
const statusEl = container.querySelector('.flowchart-status');
|
||||||
|
const maximizeBtn = container.querySelector('.flowchart-maximize-btn');
|
||||||
|
const maximizeLabel = container.querySelector('.flowchart-maximize-label');
|
||||||
|
|
||||||
let selectedNodeId = null;
|
let selectedNodeId = null;
|
||||||
let selectedEdgeId = null;
|
let selectedEdgeId = null;
|
||||||
|
// Takeover state: when true, `.main-content` carries `flowchart-takeover`
|
||||||
|
// and the editor-container is hidden so the panel + canvas + preview split
|
||||||
|
// the full window width. Toggled by the maximize button (and cleaned up
|
||||||
|
// by destroy() so leaving it doesn't leave the editor hidden).
|
||||||
|
let takeoverActive = false;
|
||||||
|
|
||||||
|
// The sidebar lives inside `.main-content` (see src/styles-sidebar.css).
|
||||||
|
// Look it up by walking up from the panel container — that way the panel
|
||||||
|
// doesn't need to know whether the renderer mounted it via #sidebar or
|
||||||
|
// any future container.
|
||||||
|
function findMainContent() {
|
||||||
|
let el = container;
|
||||||
|
while (el && el.parentElement) {
|
||||||
|
el = el.parentElement;
|
||||||
|
if (el.classList && el.classList.contains('main-content')) return el;
|
||||||
|
}
|
||||||
|
return document.querySelector('.main-content');
|
||||||
|
}
|
||||||
|
|
||||||
const store = createStore({
|
const store = createStore({
|
||||||
persistencePath: persistenceFile,
|
persistencePath: persistenceFile,
|
||||||
@@ -91,6 +126,9 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
onEdgeClick: (edgeId) => {
|
onEdgeClick: (edgeId) => {
|
||||||
// Click an edge → prompt for kind and (optional) label. v2 can replace
|
// Click an edge → prompt for kind and (optional) label. v2 can replace
|
||||||
// this with a real popover menu; the prompts are intentionally simple.
|
// this with a real popover menu; the prompts are intentionally simple.
|
||||||
|
// Mirror the selection into panel state so Delete/Backspace on the
|
||||||
|
// panel-scoped keydown handler routes to the right entity even after a
|
||||||
|
// pure click (no drag, no store mutation to trigger render-based sync).
|
||||||
selectedEdgeId = edgeId;
|
selectedEdgeId = edgeId;
|
||||||
selectedNodeId = null;
|
selectedNodeId = null;
|
||||||
const edge = store.getGraph().edges.find((e) => e.id === edgeId);
|
const edge = store.getGraph().edges.find((e) => e.id === edgeId);
|
||||||
@@ -104,6 +142,14 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
store.setEdgeLabel(edgeId, nextLabel);
|
store.setEdgeLabel(edgeId, nextLabel);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onNodeClick: (nodeId) => {
|
||||||
|
// Canvas → panel selection sync. The canvas already paints the
|
||||||
|
// .flowchart-node.selected highlight (see flowchart-canvas.js render()
|
||||||
|
// and the .flowchart-node.selected CSS rule). Mirror the id into the
|
||||||
|
// panel's selection state so Delete/Backspace routes here.
|
||||||
|
selectedNodeId = nodeId;
|
||||||
|
selectedEdgeId = null;
|
||||||
|
},
|
||||||
onShapeMenu: (nodeId) => {
|
onShapeMenu: (nodeId) => {
|
||||||
// Prompt for a new shape kind. v2: replace with a real context menu.
|
// Prompt for a new shape kind. v2: replace with a real context menu.
|
||||||
const next = window.prompt(
|
const next = window.prompt(
|
||||||
@@ -157,6 +203,35 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
}
|
}
|
||||||
insertBtn.addEventListener('click', onInsertClick);
|
insertBtn.addEventListener('click', onInsertClick);
|
||||||
|
|
||||||
|
// Maximize / restore toggle. When active, .main-content.flowchart-takeover
|
||||||
|
// hides the editor-container and lets the sidebar + canvas fill the row.
|
||||||
|
// Click again to restore. Kept inside the panel so its lifecycle matches
|
||||||
|
// the panel's destroy() cleanup.
|
||||||
|
function setTakeover(active) {
|
||||||
|
const mainContent = findMainContent();
|
||||||
|
if (!mainContent) return;
|
||||||
|
takeoverActive = active;
|
||||||
|
mainContent.classList.toggle(TAKEOVER_CLASS, active);
|
||||||
|
maximizeBtn.classList.toggle('active', active);
|
||||||
|
maximizeBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
active ? 'Restore Flow Chart panel' : 'Maximize Flow Chart panel'
|
||||||
|
);
|
||||||
|
maximizeBtn.setAttribute(
|
||||||
|
'title',
|
||||||
|
active
|
||||||
|
? 'Restore: show the editor again'
|
||||||
|
: 'Maximize: hide the editor and let the canvas + preview fill the main area'
|
||||||
|
);
|
||||||
|
if (maximizeLabel) {
|
||||||
|
maximizeLabel.textContent = active ? 'Restore' : 'Maximize';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onMaximizeClick() {
|
||||||
|
setTakeover(!takeoverActive);
|
||||||
|
}
|
||||||
|
maximizeBtn.addEventListener('click', onMaximizeClick);
|
||||||
|
|
||||||
// Keyboard shortcuts — panel-scoped.
|
// Keyboard shortcuts — panel-scoped.
|
||||||
function onContainerKeyDown(ev) {
|
function onContainerKeyDown(ev) {
|
||||||
if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') {
|
if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') {
|
||||||
@@ -203,6 +278,10 @@ function renderFlowChartPanel(container, deps) {
|
|||||||
unsubscribeStore();
|
unsubscribeStore();
|
||||||
container.removeEventListener('keydown', onContainerKeyDown);
|
container.removeEventListener('keydown', onContainerKeyDown);
|
||||||
insertBtn.removeEventListener('click', onInsertClick);
|
insertBtn.removeEventListener('click', onInsertClick);
|
||||||
|
maximizeBtn.removeEventListener('click', onMaximizeClick);
|
||||||
|
// Restore the editor if we were in takeover mode — leaving the class
|
||||||
|
// on .main-content would hide the editor for the rest of the session.
|
||||||
|
if (takeoverActive) setTakeover(false);
|
||||||
canvas.destroy();
|
canvas.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1111,3 +1111,66 @@ body[class*='dark'] .preview-content a.wiki-link {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Takeover mode — the Flow Chart panel promotes itself to fill the main
|
||||||
|
* area, hiding the editor-container so the canvas + preview split the full
|
||||||
|
* window width instead of the 280px sidebar. Toggled by the maximize
|
||||||
|
* button in src/sidebar/flowchart-panel.js (adds/removes
|
||||||
|
* `.flowchart-takeover` on `.main-content`). Restore on second click or on
|
||||||
|
* panel destroy(). */
|
||||||
|
.main-content.flowchart-takeover .editor-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.main-content.flowchart-takeover .sidebar {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.main-content.flowchart-takeover .sidebar:not(.collapsed) {
|
||||||
|
/* The takeover is only meaningful with the flowchart panel open, so the
|
||||||
|
* 48px collapsed-state width doesn't apply. Force the wide layout. */
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.main-content.flowchart-takeover .sidebar-panel {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.main-content.flowchart-takeover .sidebar-panel-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* The panel itself should fill the panel-content column instead of being
|
||||||
|
* constrained by the normal 12px padding-block flow. */
|
||||||
|
.main-content.flowchart-takeover .flowchart-panel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maximize button — small icon+label pill in the panel toolbar. */
|
||||||
|
.flowchart-maximize-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.flowchart-maximize-btn:hover {
|
||||||
|
border-color: var(--accent, #4a9eff);
|
||||||
|
color: var(--accent, #4a9eff);
|
||||||
|
}
|
||||||
|
.flowchart-maximize-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #4a9eff);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.flowchart-maximize-btn.active {
|
||||||
|
background: var(--accent, #4a9eff);
|
||||||
|
border-color: var(--accent, #4a9eff);
|
||||||
|
color: var(--accent-fg, #fff);
|
||||||
|
}
|
||||||
|
.flowchart-maximize-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -291,3 +291,198 @@ describe('flowchart-panel: keyboard shortcuts', () => {
|
|||||||
expect(store.getGraph().nodes).toHaveLength(0);
|
expect(store.getGraph().nodes).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('flowchart-panel: selection wiring (canvas click → panel state + SVG class)', () => {
|
||||||
|
// Match the SVG viewBox (1000x700) so client→svg mapping is 1:1 for the
|
||||||
|
// canvas's getSvgPoint. Mirrors the layout prime in
|
||||||
|
// tests/flowchart-canvas.test.js — duplicated here so the panel tests
|
||||||
|
// stay self-contained.
|
||||||
|
function primeCanvasLayout(container, nodes) {
|
||||||
|
const svg = container.querySelector('svg.flowchart-canvas');
|
||||||
|
if (!svg) return;
|
||||||
|
svg.getBoundingClientRect = () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 700,
|
||||||
|
right: 1000,
|
||||||
|
});
|
||||||
|
for (const n of nodes) {
|
||||||
|
const g = container.querySelector(`g[data-node-id="${n.id}"]`);
|
||||||
|
if (!g) continue;
|
||||||
|
g.getBoundingClientRect = () => ({
|
||||||
|
x: n.x,
|
||||||
|
y: n.y,
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
top: n.y,
|
||||||
|
left: n.x,
|
||||||
|
bottom: n.y + 40,
|
||||||
|
right: n.x + 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dispatch(target, type, opts) {
|
||||||
|
const ev = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.assign(ev, opts || {});
|
||||||
|
target.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('clicking a node applies .selected class to the matching SVG <g>', () => {
|
||||||
|
const { container, api } = mount();
|
||||||
|
const store = api.getStore();
|
||||||
|
const a = store.addNode({ kind: 'process', x: 100, y: 100, label: 'A' });
|
||||||
|
const b = store.addNode({ kind: 'process', x: 400, y: 100, label: 'B' });
|
||||||
|
primeCanvasLayout(container, [
|
||||||
|
{ id: a.id, x: 100, y: 100 },
|
||||||
|
{ id: b.id, x: 400, y: 100 },
|
||||||
|
]);
|
||||||
|
const nodeA = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
// jsdom doesn't carry CSSOM class assertions from stylesheets — assert
|
||||||
|
// on the SVG `class` attribute, which is what render() writes.
|
||||||
|
expect(nodeA.getAttribute('class')).not.toMatch(/selected/);
|
||||||
|
|
||||||
|
dispatch(nodeA, 'pointerdown', { clientX: 120, clientY: 110, pointerId: 1 });
|
||||||
|
|
||||||
|
const updated = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
expect(updated.getAttribute('class')).toMatch(/selected/);
|
||||||
|
// The other node must remain un-selected.
|
||||||
|
const other = container.querySelector(`g[data-node-id="${b.id}"]`);
|
||||||
|
expect(other.getAttribute('class')).not.toMatch(/selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a second node moves .selected from the first to the second', () => {
|
||||||
|
const { container, api } = mount();
|
||||||
|
const store = api.getStore();
|
||||||
|
const a = store.addNode({ kind: 'process', x: 100, y: 100, label: 'A' });
|
||||||
|
const b = store.addNode({ kind: 'process', x: 400, y: 100, label: 'B' });
|
||||||
|
primeCanvasLayout(container, [
|
||||||
|
{ id: a.id, x: 100, y: 100 },
|
||||||
|
{ id: b.id, x: 400, y: 100 },
|
||||||
|
]);
|
||||||
|
const nodeA = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
// Click A.
|
||||||
|
dispatch(nodeA, 'pointerdown', { clientX: 120, clientY: 110, pointerId: 1 });
|
||||||
|
// SVG elements are replaced on each render() — re-query, then assert.
|
||||||
|
const afterA = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
const otherAfterA = container.querySelector(`g[data-node-id="${b.id}"]`);
|
||||||
|
expect(afterA.getAttribute('class')).toMatch(/selected/);
|
||||||
|
expect(otherAfterA.getAttribute('class')).not.toMatch(/selected/);
|
||||||
|
|
||||||
|
// Re-prime the layout for the new B element (old one is detached) and
|
||||||
|
// click B by re-querying it before dispatching.
|
||||||
|
const freshB = container.querySelector(`g[data-node-id="${b.id}"]`);
|
||||||
|
primeCanvasLayout(container, [{ id: b.id, x: 400, y: 100 }]);
|
||||||
|
dispatch(freshB, 'pointerdown', { clientX: 420, clientY: 110, pointerId: 1 });
|
||||||
|
const newA = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
const newB = container.querySelector(`g[data-node-id="${b.id}"]`);
|
||||||
|
expect(newA.getAttribute('class')).not.toMatch(/selected/);
|
||||||
|
expect(newB.getAttribute('class')).toMatch(/selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking an edge applies .selected class to the matching SVG <line>', () => {
|
||||||
|
const { container, api } = mount();
|
||||||
|
const store = api.getStore();
|
||||||
|
const a = store.addNode({ kind: 'process', x: 100, y: 100, label: 'A' });
|
||||||
|
const b = store.addNode({ kind: 'process', x: 400, y: 100, label: 'B' });
|
||||||
|
const e = store.connect(a.id, b.id, 'solid');
|
||||||
|
primeCanvasLayout(container, [
|
||||||
|
{ id: a.id, x: 100, y: 100 },
|
||||||
|
{ id: b.id, x: 400, y: 100 },
|
||||||
|
]);
|
||||||
|
const edgeLine = container.querySelector(`line[data-edge-id="${e.id}"]`);
|
||||||
|
expect(edgeLine.getAttribute('class')).not.toMatch(/selected/);
|
||||||
|
|
||||||
|
// The panel's onEdgeClick handler calls window.prompt — replace it
|
||||||
|
// with a no-op jest.fn so the test doesn't hang or throw in jsdom.
|
||||||
|
window.prompt = jest.fn().mockReturnValue(null);
|
||||||
|
dispatch(edgeLine, 'pointerdown', { clientX: 280, clientY: 120, pointerId: 1 });
|
||||||
|
|
||||||
|
const updated = container.querySelector(`line[data-edge-id="${e.id}"]`);
|
||||||
|
expect(updated.getAttribute('class')).toMatch(/selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a node populates panel selection state so Delete removes it', () => {
|
||||||
|
// Regression: v4.9.3 left the panel's `selectedNodeId` untouched on a
|
||||||
|
// canvas click — only programmatic selectNode() worked. The result was
|
||||||
|
// Delete/Backspace silently no-op'ing on a freshly-clicked node.
|
||||||
|
const { container, api } = mount();
|
||||||
|
const store = api.getStore();
|
||||||
|
const a = store.addNode({ kind: 'process', x: 100, y: 100, label: 'A' });
|
||||||
|
primeCanvasLayout(container, [{ id: a.id, x: 100, y: 100 }]);
|
||||||
|
const nodeA = container.querySelector(`g[data-node-id="${a.id}"]`);
|
||||||
|
dispatch(nodeA, 'pointerdown', { clientX: 120, clientY: 110, pointerId: 1 });
|
||||||
|
|
||||||
|
expect(store.getGraph().nodes).toHaveLength(1);
|
||||||
|
container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
expect(store.getGraph().nodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flowchart-panel: maximize / takeover', () => {
|
||||||
|
// Wrap the panel container in a fake `.main-content` so the takeover's
|
||||||
|
// findMainContent() walker can locate it. Mirrors the real DOM layout in
|
||||||
|
// src/index.html where `.sidebar-panel-content` lives inside `.sidebar`,
|
||||||
|
// which lives inside `.main-content`.
|
||||||
|
function mountWithMainContent(deps = {}) {
|
||||||
|
const mainContent = document.createElement('div');
|
||||||
|
mainContent.className = 'main-content';
|
||||||
|
const sidebar = document.createElement('div');
|
||||||
|
sidebar.className = 'sidebar';
|
||||||
|
const panelContent = document.createElement('div');
|
||||||
|
panelContent.className = 'sidebar-panel-content';
|
||||||
|
panelContent.id = 'sidebar-panel-content';
|
||||||
|
mainContent.appendChild(sidebar);
|
||||||
|
sidebar.appendChild(panelContent);
|
||||||
|
document.body.appendChild(mainContent);
|
||||||
|
const api = renderFlowChartPanel(panelContent, {
|
||||||
|
getUserDataPath: deps.getUserDataPath || (() => '/tmp/userdata'),
|
||||||
|
readFile: deps.readFile || jest.fn().mockResolvedValue(null),
|
||||||
|
writeFile: deps.writeFile || jest.fn().mockResolvedValue(undefined),
|
||||||
|
insertAtCursor: deps.insertAtCursor || jest.fn(),
|
||||||
|
renderMermaid: deps.renderMermaid || jest.fn(),
|
||||||
|
...deps,
|
||||||
|
});
|
||||||
|
return { mainContent, container: panelContent, api };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mount exposes a maximize button in the toolbar', () => {
|
||||||
|
const { container } = mountWithMainContent();
|
||||||
|
const btn = container.querySelector('.flowchart-maximize-btn');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn.textContent).toMatch(/Maximize/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking maximize toggles flowchart-takeover on .main-content', () => {
|
||||||
|
const { mainContent, container } = mountWithMainContent();
|
||||||
|
const btn = container.querySelector('.flowchart-maximize-btn');
|
||||||
|
expect(mainContent.classList.contains('flowchart-takeover')).toBe(false);
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(mainContent.classList.contains('flowchart-takeover')).toBe(true);
|
||||||
|
// Button label flips to "Restore" so the user knows a second click
|
||||||
|
// reverses the action.
|
||||||
|
expect(btn.textContent).toMatch(/Restore/);
|
||||||
|
expect(btn.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(mainContent.classList.contains('flowchart-takeover')).toBe(false);
|
||||||
|
expect(btn.textContent).toMatch(/Maximize/);
|
||||||
|
expect(btn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy() clears the takeover class so the editor stays usable', () => {
|
||||||
|
// Regression guard: if destroy() doesn't clear the takeover class,
|
||||||
|
// switching panels (or closing the flowchart panel) leaves the editor
|
||||||
|
// hidden for the rest of the session — a bad surprise.
|
||||||
|
const { mainContent, container, api } = mountWithMainContent();
|
||||||
|
const btn = container.querySelector('.flowchart-maximize-btn');
|
||||||
|
btn.click();
|
||||||
|
expect(mainContent.classList.contains('flowchart-takeover')).toBe(true);
|
||||||
|
api.destroy();
|
||||||
|
expect(mainContent.classList.contains('flowchart-takeover')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user