diff --git a/README.md b/README.md index 4adb8d9..9682158 100644 --- a/README.md +++ b/README.md @@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com) ## Version -v4.9.3 +v4.9.4 diff --git a/UPDATES.md b/UPDATES.md index 760e985..766490b 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,22 @@ # 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 `` / `` 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 ``, clicking a second node moves `.selected` from the first to the second, clicking an edge applies `.selected` to the matching ``, 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) ### Fixes diff --git a/package.json b/package.json index e7d8a39..ab2f111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "src/main.js", "scripts": { diff --git a/src/flowchart/flowchart-canvas.js b/src/flowchart/flowchart-canvas.js index 2f63bc4..8decc5c 100644 --- a/src/flowchart/flowchart-canvas.js +++ b/src/flowchart/flowchart-canvas.js @@ -131,6 +131,28 @@ function createCanvas(container, store, opts = {}) { } } + // Surgical selection highlight — toggles the `.selected` class on the + // existing SVG / 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 ----- let dragState = null; @@ -152,6 +174,13 @@ function createCanvas(container, store, opts = {}) { if (!node) return; selectedNodeId = nodeId; 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); if (ev.altKey) { // 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, }; } + // 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(); return; } @@ -174,6 +208,10 @@ function createCanvas(container, store, opts = {}) { if (edgeLine) { selectedEdgeId = edgeLine.getAttribute('data-edge-id'); 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') { opts.onEdgeClick(selectedEdgeId, ev); } diff --git a/src/sidebar/flowchart-panel.js b/src/sidebar/flowchart-panel.js index 3f0f868..03aa0b0 100644 --- a/src/sidebar/flowchart-panel.js +++ b/src/sidebar/flowchart-panel.js @@ -8,6 +8,9 @@ * - debounced persistence to /flowchart-session.json * - panel-scoped keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z / Delete) * - "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 {object} deps @@ -27,6 +30,10 @@ const { toMermaid } = require('../flowchart/flowchart-mermaid'); const PREVIEW_DEBOUNCE_MS = 250; const PERSIST_DEBOUNCE_MS = 500; 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) { const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps; @@ -51,6 +58,14 @@ function renderFlowChartPanel(container, deps) { Insert at Cursor +
@@ -67,9 +82,29 @@ function renderFlowChartPanel(container, deps) { const previewRenderEl = container.querySelector('.flowchart-preview-render'); const insertBtn = container.querySelector('.flowchart-insert-btn'); const statusEl = container.querySelector('.flowchart-status'); + const maximizeBtn = container.querySelector('.flowchart-maximize-btn'); + const maximizeLabel = container.querySelector('.flowchart-maximize-label'); let selectedNodeId = 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({ persistencePath: persistenceFile, @@ -91,6 +126,9 @@ function renderFlowChartPanel(container, deps) { onEdgeClick: (edgeId) => { // Click an edge → prompt for kind and (optional) label. v2 can replace // 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; selectedNodeId = null; const edge = store.getGraph().edges.find((e) => e.id === edgeId); @@ -104,6 +142,14 @@ function renderFlowChartPanel(container, deps) { 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) => { // Prompt for a new shape kind. v2: replace with a real context menu. const next = window.prompt( @@ -157,6 +203,35 @@ function renderFlowChartPanel(container, deps) { } 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. function onContainerKeyDown(ev) { if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') { @@ -203,6 +278,10 @@ function renderFlowChartPanel(container, deps) { unsubscribeStore(); container.removeEventListener('keydown', onContainerKeyDown); 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(); }, }; diff --git a/src/styles-sidebar.css b/src/styles-sidebar.css index fc01083..f8a194e 100644 --- a/src/styles-sidebar.css +++ b/src/styles-sidebar.css @@ -1111,3 +1111,66 @@ body[class*='dark'] .preview-content a.wiki-link { color: inherit; 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; +} diff --git a/tests/flowchart-panel.test.js b/tests/flowchart-panel.test.js index ce7cf05..3063845 100644 --- a/tests/flowchart-panel.test.js +++ b/tests/flowchart-panel.test.js @@ -291,3 +291,198 @@ describe('flowchart-panel: keyboard shortcuts', () => { 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 ', () => { + 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 ', () => { + 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); + }); +});