diff --git a/README.md b/README.md index 0ffa387..7149fbb 100644 --- a/README.md +++ b/README.md @@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com) ## Version -v4.9.8 +v4.9.9 diff --git a/UPDATES.md b/UPDATES.md index 7c54bf3..e59cbfa 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,12 @@ # PanConverter - Updates & Changelog +## Version 4.9.9 (2026-09-15) + +### Fix +- **Standalone Flowchart Generator window: replaced broken `window.prompt` / `window.confirm` with an inline DOM-modal dialog.** Electron renderer contexts (the BrowserWindow hosting the standalone window) return `undefined` when `window.prompt(...)` or `window.confirm(...)` is called — meaning every shape menu, edge-kind change, edge-label edit, and reset confirmation silently did nothing. The bundle now ships two helpers (`promptInline`, `confirmInline`) that build a small overlay with a styled title, message, OK / Cancel buttons, and Enter / Escape / backdrop-click handling. The four call sites (`onEdgeClick` for kind + label, `onShapeMenu`, and the Reset click handler) are now `async` and await the helpers. +- New `window.FlowchartModals = { promptInline, confirmInline }` export on the bundle so jsdom tests can drive the modals directly without rebuilding the IIFE. +- 6 new tests in `tests/flowchart-controller.test.js` — OK / Cancel / Escape resolution paths for `promptInline`, OK / Cancel for `confirmInline`, and the `danger` flag renders a red "Delete" primary button. + ## Version 4.9.8 (2026-09-15) ### Fix diff --git a/package.json b/package.json index d32199f..c690c09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown-converter", - "version": "4.9.8", + "version": "4.9.9", "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/renderer/flowchart-bundle.js b/src/renderer/flowchart-bundle.js index f185fc9..7b1be90 100644 --- a/src/renderer/flowchart-bundle.js +++ b/src/renderer/flowchart-bundle.js @@ -1,5 +1,5 @@ /** - * v4.9.8 — Bundled single-file Flowchart Generator loader. + * v4.9.9 — Bundled single-file Flowchart Generator loader. * * Inlines the four pure modules (flowchart-shapes / flowchart-mermaid / * flowchart-store / flowchart-canvas) plus the renderer controller @@ -19,6 +19,11 @@ * brute-force bundle everything into one self-contained file. No * cross-file script ordering, no UMD wrapper, no `require()`. The * standalone window now has exactly one script dependency. + * v4.9.9 — Electron renderer contexts disable `window.prompt` and + * `window.confirm`, so shape change / edge kind / edge label / + * reset confirmation did nothing. Replaced with `promptInline` + * and `confirmInline` (custom DOM-overlay modals). Exposed as + * `window.FlowchartModals` for jsdom tests. * * The legacy individual files under src/flowchart/* and * src/renderer/flowchart-controller.js are kept untouched — the @@ -814,6 +819,218 @@ }, PERSIST_DEBOUNCE_MS); } + // ========== Inline modal dialog (replacement for window.prompt/confirm) ========== + // v4.9.9 — window.prompt and window.confirm are disabled in Electron + // renderer contexts (the BrowserWindow of a BrowserView/WebContentsView + // returns undefined when called). Build minimal modal interactions on top + // of plain DOM nodes. Resolves with the entered string (or null on + // cancel/Esc/backdrop-click) for promptInline, and with a boolean for + // confirmInline. + function promptInline({ title, message, defaultValue = '', kind = 'text' }) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0.45)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 99999, + }); + const box = document.createElement('div'); + Object.assign(box.style, { + background: '#ffffff', + color: '#1f2328', + border: '1px solid #d0d7de', + borderRadius: '8px', + padding: '20px 24px', + minWidth: '320px', + maxWidth: '480px', + boxShadow: '0 12px 32px rgba(0,0,0,0.25)', + fontFamily: 'system-ui, sans-serif', + }); + if (title) { + const h = document.createElement('div'); + h.textContent = title; + Object.assign(h.style, { fontSize: '14px', fontWeight: '600', marginBottom: '12px' }); + box.appendChild(h); + } + if (message) { + const m = document.createElement('div'); + m.textContent = message; + Object.assign(m.style, { + fontSize: '12px', + color: '#57606a', + marginBottom: '12px', + whiteSpace: 'pre-wrap', + }); + box.appendChild(m); + } + const input = document.createElement('input'); + input.type = kind === 'number' ? 'number' : 'text'; + input.value = defaultValue; + Object.assign(input.style, { + width: '100%', + padding: '8px 10px', + fontSize: '13px', + border: '1px solid #d0d7de', + borderRadius: '4px', + boxSizing: 'border-box', + }); + box.appendChild(input); + + const buttons = document.createElement('div'); + Object.assign(buttons.style, { + marginTop: '14px', + display: 'flex', + gap: '8px', + justifyContent: 'flex-end', + }); + const ok = document.createElement('button'); + ok.textContent = 'OK'; + Object.assign(ok.style, { + padding: '6px 14px', + border: 'none', + borderRadius: '4px', + background: '#1f883d', + color: '#ffffff', + fontSize: '13px', + cursor: 'pointer', + }); + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, { + padding: '6px 14px', + border: '1px solid #d0d7de', + borderRadius: '4px', + background: '#f6f8fa', + color: '#1f2328', + fontSize: '13px', + cursor: 'pointer', + }); + buttons.appendChild(cancel); + buttons.appendChild(ok); + box.appendChild(buttons); + overlay.appendChild(box); + document.body.appendChild(overlay); + + let resolved = false; + const cleanup = (val) => { + if (resolved) return; + resolved = true; + document.body.removeChild(overlay); + resolve(val); + }; + ok.addEventListener('click', () => cleanup(input.value || null)); + cancel.addEventListener('click', () => cleanup(null)); + overlay.addEventListener('click', (ev) => { + if (ev.target === overlay) cleanup(null); + }); + input.addEventListener('keydown', (kev) => { + if (kev.key === 'Enter') cleanup(input.value || null); + if (kev.key === 'Escape') cleanup(null); + }); + setTimeout(() => input.focus(), 0); + }); + } + + function confirmInline({ title, message, danger = false }) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0.45)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 99999, + }); + const box = document.createElement('div'); + Object.assign(box.style, { + background: '#ffffff', + color: '#1f2328', + border: '1px solid #d0d7de', + borderRadius: '8px', + padding: '20px 24px', + minWidth: '320px', + maxWidth: '480px', + boxShadow: '0 12px 32px rgba(0,0,0,0.25)', + fontFamily: 'system-ui, sans-serif', + }); + if (title) { + const h = document.createElement('div'); + h.textContent = title; + Object.assign(h.style, { fontSize: '14px', fontWeight: '600', marginBottom: '12px' }); + box.appendChild(h); + } + if (message) { + const m = document.createElement('div'); + m.textContent = message; + Object.assign(m.style, { + fontSize: '13px', + color: '#1f2328', + marginBottom: '14px', + whiteSpace: 'pre-wrap', + }); + box.appendChild(m); + } + const buttons = document.createElement('div'); + Object.assign(buttons.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end' }); + const ok = document.createElement('button'); + ok.textContent = danger ? 'Delete' : 'OK'; + Object.assign(ok.style, { + padding: '6px 14px', + border: 'none', + borderRadius: '4px', + background: danger ? '#cf222e' : '#1f883d', + color: '#ffffff', + fontSize: '13px', + cursor: 'pointer', + }); + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + Object.assign(cancel.style, { + padding: '6px 14px', + border: '1px solid #d0d7de', + borderRadius: '4px', + background: '#f6f8fa', + color: '#1f2328', + fontSize: '13px', + cursor: 'pointer', + }); + buttons.appendChild(cancel); + buttons.appendChild(ok); + box.appendChild(buttons); + overlay.appendChild(box); + document.body.appendChild(overlay); + + let resolved = false; + const cleanup = (val) => { + if (resolved) return; + resolved = true; + document.body.removeChild(overlay); + resolve(val); + }; + ok.addEventListener('click', () => cleanup(true)); + cancel.addEventListener('click', () => cleanup(false)); + overlay.addEventListener('click', (ev) => { + if (ev.target === overlay) cleanup(false); + }); + document.addEventListener('keydown', function onKey(ev) { + if (ev.key === 'Enter') { + cleanup(true); + document.removeEventListener('keydown', onKey); + } + if (ev.key === 'Escape') { + cleanup(false); + document.removeEventListener('keydown', onKey); + } + }); + }); + } + async function bootstrap() { // Resolve the userData path ONCE on mount. The persistence path is // interpolated into a string on every read/write; calling the async @@ -836,14 +1053,22 @@ }); _canvas = createCanvas(els.canvasHost, _store, { - onEdgeClick: (edgeId) => { + onEdgeClick: async (edgeId) => { const edge = _store.getGraph().edges.find((e) => e.id === edgeId); if (!edge) return; - const nextKind = window.prompt('Edge kind (solid, dotted, thick):', edge.kind); + const nextKind = await promptInline({ + title: 'Edge kind', + message: 'Enter the new edge kind for this connection.', + defaultValue: edge.kind, + }); if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) { _store.setEdgeKind(edgeId, nextKind); } - const nextLabel = window.prompt('Edge label (empty to clear):', edge.label || ''); + const nextLabel = await promptInline({ + title: 'Edge label', + message: 'Enter a label for this edge (leave blank to clear).', + defaultValue: edge.label || '', + }); if (nextLabel !== null) { _store.setEdgeLabel(edgeId, nextLabel); } @@ -852,10 +1077,11 @@ // Canvas already paints the .selected highlight; nothing else // needed here for selection state. }, - onShapeMenu: (nodeId) => { - const next = window.prompt( - 'New shape (process, decision, terminator, subroutine, document):' - ); + onShapeMenu: async (nodeId) => { + const next = await promptInline({ + title: 'Change shape', + message: 'New shape (process, decision, terminator, subroutine, document):', + }); if (next) _store.setNodeKind(nodeId, next); }, }); @@ -884,9 +1110,14 @@ } if (els.btnReset) { - els.btnReset.addEventListener('click', () => { + els.btnReset.addEventListener('click', async () => { if (!_store) return; - if (!window.confirm('Clear all nodes and edges?')) return; + const ok = await confirmInline({ + title: 'Reset diagram', + message: 'Clear all nodes and edges? This cannot be undone.', + danger: true, + }); + if (!ok) return; _store.deserialize({ nodes: [], edges: [] }); setStatus('Reset'); }); @@ -945,6 +1176,11 @@ }, }; + // Expose the inline modal helpers (v4.9.9) so jsdom tests can drive them + // directly without rebuilding the bundle's IIFE. Production code accesses + // these by closure; this handle exists purely for unit tests. + window.FlowchartModals = { promptInline, confirmInline }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap); } else { diff --git a/tests/flowchart-controller.test.js b/tests/flowchart-controller.test.js index 8e73296..c1fe327 100644 --- a/tests/flowchart-controller.test.js +++ b/tests/flowchart-controller.test.js @@ -81,6 +81,7 @@ afterEach(() => { delete window.FlowchartMermaid; delete window.FlowchartStore; delete window.FlowchartCanvas; + delete window.FlowchartModals; }); describe('flowchart-generator.html — stylesheet links', () => { @@ -234,3 +235,127 @@ describe('flowchart pure modules — UMD browser-global assignment (v4.9.7)', () expect(src).toMatch(new RegExp(`window\\.${globalName}\\s*=\\s*exported`)); }); }); + +// v4.9.9 — Electron renderer contexts disable `window.prompt` and +// `window.confirm` (the BrowserWindow returns undefined when called), +// which broke shape change / edge kind / edge label / reset confirm in +// the standalone Flowchart Generator window. The bundle now ships +// `promptInline` and `confirmInline` (custom DOM overlay modals) and +// exposes them as `window.FlowchartModals` for testing. These tests load +// the real bundle into jsdom so we exercise the actual overlay code. +describe('flowchart-bundle: inline modal helpers (v4.9.9)', () => { + 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() { + const html = fs.readFileSync(HTML_PATH_BUNDLE, 'utf-8'); + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + document.body.innerHTML = bodyMatch ? bodyMatch[1] : html; + window.electronAPI = { + flowchart: { + getUserDataPath: jest.fn(async () => '/userdata'), + readFile: jest.fn(async () => null), + writeFile: jest.fn(async () => undefined), + insertAtCursor: jest.fn(), + }, + }; + const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8'); + // eslint-disable-next-line no-new-func + new Function('window', 'document', bundleSrc)(window, document); + // Flush microtasks so the bootstrap chain settles before tests run. + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + } + + test('promptInline resolves with the entered value on OK click', async () => { + await loadBundle(); + expect(typeof window.FlowchartModals.promptInline).toBe('function'); + const promise = window.FlowchartModals.promptInline({ + title: 'Edge kind', + message: 'Enter the new edge kind.', + defaultValue: 'solid', + }); + const input = document.querySelector('input'); + expect(input).not.toBeNull(); + input.value = 'dotted'; + const okButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'OK' + ); + expect(okButton).toBeDefined(); + okButton.click(); + await expect(promise).resolves.toBe('dotted'); + expect(document.querySelector('input')).toBeNull(); + }); + + test('promptInline resolves null on Cancel click', async () => { + await loadBundle(); + const promise = window.FlowchartModals.promptInline({ + title: 'Edge label', + message: 'Enter a label.', + defaultValue: '', + }); + const cancelButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'Cancel' + ); + expect(cancelButton).toBeDefined(); + cancelButton.click(); + await expect(promise).resolves.toBeNull(); + expect(document.querySelector('input')).toBeNull(); + }); + + test('promptInline resolves null on Escape key', async () => { + await loadBundle(); + const promise = window.FlowchartModals.promptInline({ + title: 'Edge kind', + defaultValue: 'solid', + }); + const input = document.querySelector('input'); + const ev = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + input.dispatchEvent(ev); + await expect(promise).resolves.toBeNull(); + }); + + test('confirmInline resolves true on OK click', async () => { + await loadBundle(); + const promise = window.FlowchartModals.confirmInline({ + title: 'Reset diagram', + message: 'Clear all nodes and edges?', + }); + const okButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'OK' + ); + expect(okButton).toBeDefined(); + okButton.click(); + await expect(promise).resolves.toBe(true); + }); + + test('confirmInline resolves false on Cancel click', async () => { + await loadBundle(); + const promise = window.FlowchartModals.confirmInline({ + title: 'Reset diagram', + message: 'Clear all nodes and edges?', + }); + const cancelButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'Cancel' + ); + expect(cancelButton).toBeDefined(); + cancelButton.click(); + await expect(promise).resolves.toBe(false); + }); + + test('confirmInline with danger flag renders a Delete primary button', async () => { + await loadBundle(); + const promise = window.FlowchartModals.confirmInline({ + title: 'Reset diagram', + message: 'Clear all nodes and edges?', + danger: true, + }); + const buttons = Array.from(document.querySelectorAll('button')); + const primary = buttons.find((b) => b.textContent === 'Delete'); + expect(primary).toBeDefined(); + expect(primary.style.background).toBe('rgb(207, 34, 46)'); + primary.click(); + await expect(promise).resolves.toBe(true); + }); +});