fix(flowchart): replace window.prompt/confirm with inline modal in standalone window; v4.9.9

Electron renderer contexts return undefined from window.prompt/window.confirm,
so shape change, edge kind, edge label, and reset confirmation did nothing.
Bundle now ships promptInline/confirmInline (custom DOM overlay modals) and
uses them in onEdgeClick / onShapeMenu / reset handler. Exposed as
window.FlowchartModals for jsdom tests.

Amit Haridas
This commit is contained in:
2026-09-15 00:27:24 +05:30
parent ca5983e9ff
commit 81e8561403
5 changed files with 380 additions and 12 deletions
+1 -1
View File
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.9.8 v4.9.9
+7
View File
@@ -1,5 +1,12 @@
# PanConverter - Updates & Changelog # 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) ## Version 4.9.8 (2026-09-15)
### Fix ### Fix
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "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", "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": {
+246 -10
View File
@@ -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 / * 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
@@ -19,6 +19,11 @@
* brute-force bundle everything into one self-contained file. No * brute-force bundle everything into one self-contained file. No
* cross-file script ordering, no UMD wrapper, no `require()`. The * cross-file script ordering, no UMD wrapper, no `require()`. The
* standalone window now has exactly one script dependency. * 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 * 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
@@ -814,6 +819,218 @@
}, PERSIST_DEBOUNCE_MS); }, 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() { async function bootstrap() {
// Resolve the userData path ONCE on mount. The persistence path is // Resolve the userData path ONCE on mount. The persistence path is
// interpolated into a string on every read/write; calling the async // interpolated into a string on every read/write; calling the async
@@ -836,14 +1053,22 @@
}); });
_canvas = createCanvas(els.canvasHost, _store, { _canvas = createCanvas(els.canvasHost, _store, {
onEdgeClick: (edgeId) => { onEdgeClick: async (edgeId) => {
const edge = _store.getGraph().edges.find((e) => e.id === edgeId); const edge = _store.getGraph().edges.find((e) => e.id === edgeId);
if (!edge) return; 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)) { if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) {
_store.setEdgeKind(edgeId, 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) { if (nextLabel !== null) {
_store.setEdgeLabel(edgeId, nextLabel); _store.setEdgeLabel(edgeId, nextLabel);
} }
@@ -852,10 +1077,11 @@
// Canvas already paints the .selected highlight; nothing else // Canvas already paints the .selected highlight; nothing else
// needed here for selection state. // needed here for selection state.
}, },
onShapeMenu: (nodeId) => { onShapeMenu: async (nodeId) => {
const next = window.prompt( const next = await promptInline({
'New shape (process, decision, terminator, subroutine, document):' title: 'Change shape',
); message: 'New shape (process, decision, terminator, subroutine, document):',
});
if (next) _store.setNodeKind(nodeId, next); if (next) _store.setNodeKind(nodeId, next);
}, },
}); });
@@ -884,9 +1110,14 @@
} }
if (els.btnReset) { if (els.btnReset) {
els.btnReset.addEventListener('click', () => { els.btnReset.addEventListener('click', async () => {
if (!_store) return; 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: [] }); _store.deserialize({ nodes: [], edges: [] });
setStatus('Reset'); 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') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap); document.addEventListener('DOMContentLoaded', bootstrap);
} else { } else {
+125
View File
@@ -81,6 +81,7 @@ afterEach(() => {
delete window.FlowchartMermaid; delete window.FlowchartMermaid;
delete window.FlowchartStore; delete window.FlowchartStore;
delete window.FlowchartCanvas; delete window.FlowchartCanvas;
delete window.FlowchartModals;
}); });
describe('flowchart-generator.html — stylesheet links', () => { 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`)); 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(/<body[^>]*>([\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);
});
});