mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(flowchart): Open from file button + parse on load (C9)
v4.13.0 — round-trips Mermaid source through the editor:
- new open-text-file-dialog invoke IPC in main.js opens a system
Open dialog filtered for .mmd / .mermaid / .md / .markdown / .txt,
reads the chosen file, returns { path, content } or null on cancel.
- new openFile() on the flowchart preload bridge.
- new Open from File button in the standalone window toolbar
alongside Insert / Save / Reset.
- On click: confirms overwrite when the current graph is non-empty,
strips any mermaid fence, parses via the inlined fromMermaid()
(the inverse of toMermaid), and calls _store.deserialize so the
canvas redraws. Status bar reports Loaded path.
The fromMermaid() in the bundle is duplicated from
src/flowchart/flowchart-mermaid-parse.js (23 tests in C8 land the
same logic) so the standalone window does not depend on the bundle
rebuild step. Pure module + bundle inline stay in sync logically;
future work could collapse them via a build-time inclusion.
Amit Haridas
This commit is contained in:
@@ -384,6 +384,13 @@
|
|||||||
>
|
>
|
||||||
Save to File
|
Save to File
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="fc-btn"
|
||||||
|
id="fc-btn-open"
|
||||||
|
title="Open a .mmd / .md file and load its flowchart"
|
||||||
|
>
|
||||||
|
Open from File
|
||||||
|
</button>
|
||||||
<button class="fc-btn" id="fc-btn-reset" title="Clear all nodes and edges">
|
<button class="fc-btn" id="fc-btn-reset" title="Clear all nodes and edges">
|
||||||
Reset All
|
Reset All
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+28
@@ -5606,6 +5606,34 @@ ipcMain.handle('save-text-file', async (event, { content, defaultName } = {}) =>
|
|||||||
return { canceled: false, path: result.filePath };
|
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
|
// ASCII Art Generator Window
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+4
-1
@@ -114,6 +114,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
|
|
||||||
// Flowchart generator: dialog-based file save (v4.12.0)
|
// Flowchart generator: dialog-based file save (v4.12.0)
|
||||||
'save-text-file',
|
'save-text-file',
|
||||||
|
'open-text-file-dialog',
|
||||||
|
|
||||||
// ASCII art generator (standalone window — invoke channels)
|
// ASCII art generator (standalone window — invoke channels)
|
||||||
'ascii:generate',
|
'ascii:generate',
|
||||||
@@ -566,7 +567,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
|
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
|
||||||
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
|
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
|
||||||
saveFile: (content, defaultName) =>
|
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),
|
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,105 @@
|
|||||||
|
|
||||||
window.FlowchartMermaid = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
|
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) ==========
|
// ========== flowchart-store (inlined) ==========
|
||||||
// v4.12.0 — Nodes carry an optional `color` field; `setNodeColor(id, color)`
|
// v4.12.0 — Nodes carry an optional `color` field; `setNodeColor(id, color)`
|
||||||
// mutates it, and serialize/deserialize round-trip it.
|
// mutates it, and serialize/deserialize round-trip it.
|
||||||
@@ -833,6 +932,7 @@
|
|||||||
previewRender: document.getElementById('preview-render'),
|
previewRender: document.getElementById('preview-render'),
|
||||||
btnInsert: document.getElementById('fc-btn-insert'),
|
btnInsert: document.getElementById('fc-btn-insert'),
|
||||||
btnSave: document.getElementById('fc-btn-save'),
|
btnSave: document.getElementById('fc-btn-save'),
|
||||||
|
btnOpen: document.getElementById('fc-btn-open'),
|
||||||
btnReset: document.getElementById('fc-btn-reset'),
|
btnReset: document.getElementById('fc-btn-reset'),
|
||||||
btnUndo: document.getElementById('fc-btn-undo'),
|
btnUndo: document.getElementById('fc-btn-undo'),
|
||||||
btnRedo: document.getElementById('fc-btn-redo'),
|
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) {
|
if (els.btnReset) {
|
||||||
els.btnReset.addEventListener('click', async () => {
|
els.btnReset.addEventListener('click', async () => {
|
||||||
if (!_store) return;
|
if (!_store) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user