diff --git a/src/flowchart-generator.html b/src/flowchart-generator.html index 738272d..3fa7ab4 100644 --- a/src/flowchart-generator.html +++ b/src/flowchart-generator.html @@ -384,6 +384,13 @@ > Save to File + diff --git a/src/main.js b/src/main.js index a1a326c..87e2f5e 100644 --- a/src/main.js +++ b/src/main.js @@ -5606,6 +5606,34 @@ ipcMain.handle('save-text-file', async (event, { content, defaultName } = {}) => return { canceled: false, path: result.filePath }; }); +// v4.13.0 — Open a Mermaid (.mmd / .md / .markdown) file via system dialog. +// Returns { path, content } on success, null on cancel. Path traversal is +// implicit — the user picked the file, so any location is fine. +ipcMain.handle('open-text-file-dialog', async (event, _opts = {}) => { + const { dialog } = require('electron'); + const fs = require('fs'); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win || undefined, { + title: 'Open Mermaid Source', + properties: ['openFile'], + filters: [ + { name: 'Mermaid', extensions: ['mmd', 'mermaid'] }, + { name: 'Markdown', extensions: ['md', 'markdown'] }, + { name: 'Text', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePaths || !result.filePaths[0]) return null; + const filePath = result.filePaths[0]; + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return { path: filePath, content }; + } catch (err) { + console.warn(`open-text-file-dialog: failed to read ${filePath}: ${err.message}`); + return null; + } +}); + // ============================================ // ASCII Art Generator Window // ============================================ diff --git a/src/preload.js b/src/preload.js index 9a74d1d..be333ff 100644 --- a/src/preload.js +++ b/src/preload.js @@ -114,6 +114,7 @@ const ALLOWED_SEND_CHANNELS = [ // Flowchart generator: dialog-based file save (v4.12.0) 'save-text-file', + 'open-text-file-dialog', // ASCII art generator (standalone window — invoke channels) 'ascii:generate', @@ -566,7 +567,9 @@ contextBridge.exposeInMainWorld('electronAPI', { readFile: (p) => ipcRenderer.invoke('read-text-file', p), writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }), saveFile: (content, defaultName) => - ipcRenderer.invoke('save-text-file', { content, defaultName }), + ipcRenderer.invoke('save-text-file', + 'open-text-file-dialog', { content, defaultName }), + openFile: () => ipcRenderer.invoke('open-text-file-dialog'), insertAtCursor: (text) => ipcRenderer.send('insert-content', text), }, diff --git a/src/renderer/flowchart-bundle.js b/src/renderer/flowchart-bundle.js index a284b0f..d081aff 100644 --- a/src/renderer/flowchart-bundle.js +++ b/src/renderer/flowchart-bundle.js @@ -205,6 +205,105 @@ window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration }; + // ========== flowchart-mermaid-parse (inlined v4.13.0) ========== + // Inverse of toMermaid. Hand-edited to drop the duplicate parser code — + // re-import this from src/flowchart/flowchart-mermaid-parse.js as a + // pure function bundle step. Since this is a bundle, we replicate the + // minimal API surface needed by the renderer here. + function unescapeLabel(label) { + return String(label || '') + .replace(/#quot;/g, '"') + .replace(/\\n/g, '\n'); + } + function fromMermaid(source) { + const nodes = []; + const edges = []; + const idMap = new Map(); + if (typeof source !== 'string') return { nodes, edges }; + const lines = source + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('%%')); + const SHAPES = [ + { p: '[[', s: ']]', k: 'subroutine' }, + { p: '([', s: '])', k: 'terminator' }, + { p: '[/', s: '/]', k: 'document' }, + { p: '{', s: '}', k: 'decision' }, + { p: '[', s: ']', k: 'process' }, + ]; + const ARROWS = { '-->': 'solid', '-.->': 'dotted', '==>': 'thick' }; + let auto = 0; + for (const line of lines) { + if (/^flowchart\s+(TD|LR|BT|RL)/i.test(line)) continue; + let arrow = null, + arrowAt = -1; + for (const a of Object.keys(ARROWS)) { + const i = line.indexOf(a); + if (i !== -1 && (arrowAt === -1 || i < arrowAt)) { + arrow = a; + arrowAt = i; + } + } + if (arrow) { + const before = line.slice(0, arrowAt).trim(); + const after = line.slice(arrowAt + arrow.length).trim(); + const beforeLabel = /\|([^|]*)\|/.exec(before); + const afterLabel = /\|([^|]*)\|/.exec(after); + const from = beforeLabel ? before.replace(beforeLabel[0], '').trim() : before; + const to = afterLabel ? after.replace(afterLabel[0], '').trim() : after; + const label = (afterLabel && afterLabel[1]) || (beforeLabel && beforeLabel[1]) || ''; + const fid = ensureId(from, idMap, nodes, () => autoNode(auto++)); + const tid = ensureId(to, idMap, nodes, () => autoNode(auto++)); + edges.push({ + id: 'e_' + edges.length, + fromNodeId: fid, + toNodeId: tid, + kind: ARROWS[arrow], + label, + }); + continue; + } + const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*(.*)$/.exec(line); + if (m) { + const id = m[1]; + const body = m[2]; + for (const sh of SHAPES) { + if (body.startsWith(sh.p) && body.endsWith(sh.s)) { + const label = unescapeLabel(body.slice(sh.p.length, body.length - sh.s.length)); + idMap.set(id, 'n_' + id); + if (!nodes.find((n) => n.id === 'n_' + id)) { + nodes.push({ + id: 'n_' + id, + kind: sh.k, + x: 40 + (nodes.length % 5) * 160, + y: 40 + Math.floor(nodes.length / 5) * 100, + label, + }); + } + break; + } + } + } + } + function ensureId(mid, map, ns, mk) { + if (map.has(mid)) return map.get(mid); + const n = mk(); + ns.push(n); + map.set(mid, n.id); + return n.id; + } + function autoNode(n) { + return { + id: 'n_auto_' + n, + kind: 'process', + x: 40 + (n % 5) * 160, + y: 40 + Math.floor(n / 5) * 100, + label: '', + }; + } + return { nodes, edges }; + } + // ========== flowchart-store (inlined) ========== // v4.12.0 — Nodes carry an optional `color` field; `setNodeColor(id, color)` // mutates it, and serialize/deserialize round-trip it. @@ -833,6 +932,7 @@ previewRender: document.getElementById('preview-render'), btnInsert: document.getElementById('fc-btn-insert'), btnSave: document.getElementById('fc-btn-save'), + btnOpen: document.getElementById('fc-btn-open'), btnReset: document.getElementById('fc-btn-reset'), btnUndo: document.getElementById('fc-btn-undo'), btnRedo: document.getElementById('fc-btn-redo'), @@ -1455,6 +1555,43 @@ }); } + // v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog, + // strips the ```mermaid fence (if any), parses via fromMermaid() + // (inlined above) and replaces the current graph. Confirms + // overwrite before destroying unsaved work. + if (els.btnOpen) { + els.btnOpen.addEventListener('click', async () => { + if (!_store || !api.openFile) return; + let result; + try { + result = await api.openFile(); + } catch (err) { + setStatus(`Open failed: ${err && err.message ? err.message : err}`); + return; + } + if (!result) { + setStatus('Open cancelled'); + return; + } + const graph = _store.getGraph(); + if (graph.nodes.length > 0 || graph.edges.length > 0) { + const ok = await confirmInline({ + title: 'Open file', + message: 'This will replace the current diagram. Continue?', + danger: true, + }); + if (!ok) return; + } + // Strip the ```mermaid fence if present + let source = result.content || ''; + const fence = source.match(/```(?:mermaid)?\s*\n?([\s\S]*?)\n?```/); + if (fence) source = fence[1]; + const parsed = fromMermaid(source); + _store.deserialize(parsed); + setStatus(`Loaded ${result.path}`); + }); + } + if (els.btnReset) { els.btnReset.addEventListener('click', async () => { if (!_store) return;