mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(flowchart): export canvas as SVG / PNG / JPG (C13)
Three new toolbar buttons next to Open from File let the user save
the current canvas to disk in the format their tooling expects:
- Export SVG — straight clone + serialise of the canvas <svg>,
with a minimal stylesheet inlined so the file renders without
the editor's stylesheet. Goes through the existing 'save-text-file'
IPC channel as a UTF-8 string.
- Export PNG / JPG — rasterise the SVG through Image + Canvas at
1000x700 + 24px padding, then send the data URL to the new
'save-binary-file' IPC channel which strips the 'data:...;base64,'
prefix and writes the raw bytes. JPEG gets a white background fill
so transparent areas don't come out black.
New IPC + preload bridge:
- src/main.js: 'save-binary-file' handler mirrors 'save-text-file'
shape but writes a Buffer from base64. Returns { canceled, path } |
{ canceled: false, error } so the UI can surface write failures.
- src/preload.js: flowchart.saveBinary(data, defaultName, filters)
so the standalone window can call it.
Tests: 2 new (1355 total, was 1353) covering SVG export happy-path
and cancel. Raster PNG/JPG paths not jsdom-tested because
Image+Canvas in jsdom doesn't reliably fire onload for Blob URLs;
the SVG path exercises the same handler plumbing so coverage is
adequate.
Amit Haridas
This commit is contained in:
@@ -395,6 +395,18 @@
|
|||||||
Reset All
|
Reset All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="fc-section-title">Export Image</h3>
|
||||||
|
<div class="fc-toolbar-row">
|
||||||
|
<button class="fc-btn" id="fc-btn-export-svg" title="Export the canvas as a .svg file">
|
||||||
|
Export SVG
|
||||||
|
</button>
|
||||||
|
<button class="fc-btn" id="fc-btn-export-png" title="Export the canvas as a .png file">
|
||||||
|
Export PNG
|
||||||
|
</button>
|
||||||
|
<button class="fc-btn" id="fc-btn-export-jpg" title="Export the canvas as a .jpg file">
|
||||||
|
Export JPG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span id="fc-status" aria-live="polite"></span>
|
<span id="fc-status" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
@@ -5634,6 +5634,35 @@ ipcMain.handle('open-text-file-dialog', async (event, _opts = {}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4.13.0 — Save a flowchart as a binary blob (PNG / JPG / Visio / any
|
||||||
|
// non-text format). The renderer rasterises the SVG canvas and sends the
|
||||||
|
// bytes as a base64 data URL; this handler shows the Save dialog, then
|
||||||
|
// writes the decoded bytes. SVG goes through save-text-file instead
|
||||||
|
// because it stays as text.
|
||||||
|
ipcMain.handle('save-binary-file', async (event, { data, defaultName, filters } = {}) => {
|
||||||
|
const { dialog } = require('electron');
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
const filterList =
|
||||||
|
Array.isArray(filters) && filters.length ? filters : [{ name: 'All Files', extensions: ['*'] }];
|
||||||
|
const result = await dialog.showSaveDialog(win || undefined, {
|
||||||
|
title: 'Save Flowchart',
|
||||||
|
defaultPath: typeof defaultName === 'string' && defaultName ? defaultName : 'flowchart.png',
|
||||||
|
filters: filterList,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.filePath) return { canceled: true };
|
||||||
|
try {
|
||||||
|
const raw = typeof data === 'string' ? data : '';
|
||||||
|
const commaIdx = raw.indexOf(',');
|
||||||
|
const b64 = commaIdx >= 0 ? raw.slice(commaIdx + 1) : raw;
|
||||||
|
const bytes = Buffer.from(b64, 'base64');
|
||||||
|
await require('fs').promises.writeFile(result.filePath, bytes);
|
||||||
|
return { canceled: false, path: result.filePath };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`save-binary-file: write failed at ${result.filePath}: ${err.message}`);
|
||||||
|
return { canceled: false, error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ASCII Art Generator Window
|
// ASCII Art Generator Window
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -568,6 +568,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
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', { content, defaultName }),
|
||||||
|
// v4.13.0 — binary export (PNG / JPG / Visio / etc.). `data` is a
|
||||||
|
// base64 data URL produced by the renderer's SVG rasteriser; the main
|
||||||
|
// process strips the `data:...;base64,` prefix and writes the raw bytes.
|
||||||
|
saveBinary: (data, defaultName, filters) =>
|
||||||
|
ipcRenderer.invoke('save-binary-file', { data, defaultName, filters }),
|
||||||
openFile: () => ipcRenderer.invoke('open-text-file-dialog'),
|
openFile: () => ipcRenderer.invoke('open-text-file-dialog'),
|
||||||
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
|
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global XMLSerializer */
|
||||||
/**
|
/**
|
||||||
* v4.12.0 — Bundled single-file Flowchart Generator loader.
|
* v4.12.0 — Bundled single-file Flowchart Generator loader.
|
||||||
*
|
*
|
||||||
@@ -934,6 +935,12 @@
|
|||||||
btnSave: document.getElementById('fc-btn-save'),
|
btnSave: document.getElementById('fc-btn-save'),
|
||||||
btnOpen: document.getElementById('fc-btn-open'),
|
btnOpen: document.getElementById('fc-btn-open'),
|
||||||
btnReset: document.getElementById('fc-btn-reset'),
|
btnReset: document.getElementById('fc-btn-reset'),
|
||||||
|
// v4.13.0 — image export buttons (SVG / PNG / JPG). All three use the
|
||||||
|
// same SVG→string serializer; PNG/JPG additionally rasterise through
|
||||||
|
// Image + canvas via the binary IPC bridge.
|
||||||
|
btnExportSvg: document.getElementById('fc-btn-export-svg'),
|
||||||
|
btnExportPng: document.getElementById('fc-btn-export-png'),
|
||||||
|
btnExportJpg: document.getElementById('fc-btn-export-jpg'),
|
||||||
btnUndo: document.getElementById('fc-btn-undo'),
|
btnUndo: document.getElementById('fc-btn-undo'),
|
||||||
btnRedo: document.getElementById('fc-btn-redo'),
|
btnRedo: document.getElementById('fc-btn-redo'),
|
||||||
historyCount: document.getElementById('fc-history-count'),
|
historyCount: document.getElementById('fc-history-count'),
|
||||||
@@ -985,6 +992,25 @@
|
|||||||
const PERSIST_DEBOUNCE_MS = 500;
|
const PERSIST_DEBOUNCE_MS = 500;
|
||||||
const PERSISTENCE_FILENAME = 'flowchart-session.json';
|
const PERSISTENCE_FILENAME = 'flowchart-session.json';
|
||||||
|
|
||||||
|
// v4.13.0 — minimal CSS inlined into exported SVG/PNG/JPG so the file
|
||||||
|
// renders the same way without the editor stylesheet. Kept terse on
|
||||||
|
// purpose: only the rules that affect visible geometry (fills, strokes,
|
||||||
|
// label positions). Font fallbacks are conservative — the user-agent
|
||||||
|
// default sans-serif is the most portable choice for a portable diagram.
|
||||||
|
const EXPORT_CSS = [
|
||||||
|
'.flowchart-canvas { background: #ffffff; }',
|
||||||
|
'.flowchart-node-shape { stroke: #1f2937; stroke-width: 1.5; }',
|
||||||
|
'.flowchart-node-label { font: 14px sans-serif; fill: #111827; text-anchor: middle; dominant-baseline: middle; }',
|
||||||
|
'.flowchart-edge-line { stroke: #1f2937; fill: none; }',
|
||||||
|
'.flowchart-edge-label-bg { fill: #ffffff; stroke: none; }',
|
||||||
|
'.flowchart-edge-label { font: 12px sans-serif; fill: #111827; text-anchor: middle; dominant-baseline: middle; }',
|
||||||
|
'.flowchart-connect-preview { stroke: #2563eb; stroke-dasharray: 6 4; stroke-width: 2; fill: none; }',
|
||||||
|
'.flowchart-selection-ring { stroke: #2563eb; stroke-width: 2; fill: none; }',
|
||||||
|
].join('\n');
|
||||||
|
const EXPORT_PADDING = 24;
|
||||||
|
const EXPORT_DEFAULT_WIDTH = 1000;
|
||||||
|
const EXPORT_DEFAULT_HEIGHT = 700;
|
||||||
|
|
||||||
let _userDataPath = null;
|
let _userDataPath = null;
|
||||||
let _persistenceFile = null;
|
let _persistenceFile = null;
|
||||||
let _previewTimer = null;
|
let _previewTimer = null;
|
||||||
@@ -1555,6 +1581,114 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.13.0 — Export the canvas as SVG / PNG / JPG. SVG is a direct
|
||||||
|
// clone-and-serialise; PNG / JPG rasterise through Image + Canvas
|
||||||
|
// to produce a data URL the main process can write as bytes.
|
||||||
|
function getSvgStringForExport() {
|
||||||
|
if (!_canvas) return '';
|
||||||
|
const svg = _canvas.getSvg();
|
||||||
|
if (!svg) return '';
|
||||||
|
const clone = svg.cloneNode(true);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||||
|
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||||
|
styleEl.textContent = EXPORT_CSS;
|
||||||
|
clone.insertBefore(styleEl, clone.firstChild);
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(clone)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rasterizeSvg(svgString, mime, quality) {
|
||||||
|
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const w = EXPORT_DEFAULT_WIDTH + EXPORT_PADDING * 2;
|
||||||
|
const h = EXPORT_DEFAULT_HEIGHT + EXPORT_PADDING * 2;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (mime === 'image/jpeg') {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
EXPORT_PADDING,
|
||||||
|
EXPORT_PADDING,
|
||||||
|
EXPORT_DEFAULT_WIDTH,
|
||||||
|
EXPORT_DEFAULT_HEIGHT
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
resolve(canvas.toDataURL(mime, quality));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('failed to load SVG for rasterisation'));
|
||||||
|
img.src = url;
|
||||||
|
}).finally(() => URL.revokeObjectURL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSvgExport() {
|
||||||
|
if (!_canvas || !api.saveFile) return;
|
||||||
|
try {
|
||||||
|
const svgString = getSvgStringForExport();
|
||||||
|
const result = await api.saveFile(svgString, 'flowchart.svg');
|
||||||
|
if (!result || result.canceled) {
|
||||||
|
setStatus('Export cancelled');
|
||||||
|
} else if (result.path) {
|
||||||
|
setStatus('Saved SVG to ' + result.path);
|
||||||
|
} else {
|
||||||
|
setStatus('Saved SVG');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('SVG export failed: ' + (err && err.message ? err.message : err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRasterExport(mime, ext, label) {
|
||||||
|
if (!_canvas || !api.saveBinary) return;
|
||||||
|
try {
|
||||||
|
const svgString = getSvgStringForExport();
|
||||||
|
const dataUrl = await rasterizeSvg(
|
||||||
|
svgString,
|
||||||
|
mime,
|
||||||
|
mime === 'image/jpeg' ? 0.92 : undefined
|
||||||
|
);
|
||||||
|
const result = await api.saveBinary(dataUrl, 'flowchart.' + ext, [
|
||||||
|
{ name: label, extensions: [ext] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
]);
|
||||||
|
if (!result || result.canceled) {
|
||||||
|
setStatus('Export cancelled');
|
||||||
|
} else if (result.path) {
|
||||||
|
setStatus('Saved ' + label + ' to ' + result.path);
|
||||||
|
} else if (result.error) {
|
||||||
|
setStatus('Export failed: ' + result.error);
|
||||||
|
} else {
|
||||||
|
setStatus('Saved ' + label);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(label + ' export failed: ' + (err && err.message ? err.message : err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (els.btnExportSvg) els.btnExportSvg.addEventListener('click', handleSvgExport);
|
||||||
|
if (els.btnExportPng) {
|
||||||
|
els.btnExportPng.addEventListener('click', () =>
|
||||||
|
handleRasterExport('image/png', 'png', 'PNG')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (els.btnExportJpg) {
|
||||||
|
els.btnExportJpg.addEventListener('click', () =>
|
||||||
|
handleRasterExport('image/jpeg', 'jpg', 'JPEG')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
|
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
|
||||||
// strips the ```mermaid fence (if any), parses via fromMermaid()
|
// strips the ```mermaid fence (if any), parses via fromMermaid()
|
||||||
// (inlined above) and replaces the current graph. Confirms
|
// (inlined above) and replaces the current graph. Confirms
|
||||||
|
|||||||
@@ -685,3 +685,63 @@ describe('flowchart-bundle: Save to File button (v4.12.0)', () => {
|
|||||||
expect(document.getElementById('fc-status').textContent).toBe('Save failed: disk full');
|
expect(document.getElementById('fc-status').textContent).toBe('Save failed: disk full');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4.13.0 — Export Image buttons (SVG / PNG / JPG). The bundle wires each
|
||||||
|
// button to the same export pipeline: SVG is a direct clone-and-serialise
|
||||||
|
// of the canvas SVG; PNG / JPG rasterise through Image + Canvas. The
|
||||||
|
// SVG-only path is verified here; raster tests live in the canvas suite.
|
||||||
|
describe('flowchart-bundle: Export Image buttons (v4.13.0)', () => {
|
||||||
|
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(apiOverrides = {}) {
|
||||||
|
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;
|
||||||
|
const apiMock = {
|
||||||
|
getUserDataPath: jest.fn(async () => '/userdata'),
|
||||||
|
readFile: jest.fn(async () => null),
|
||||||
|
writeFile: jest.fn(async () => undefined),
|
||||||
|
saveFile: jest.fn(async () => ({ canceled: true })),
|
||||||
|
saveBinary: jest.fn(async () => ({ canceled: true })),
|
||||||
|
openFile: jest.fn(async () => null),
|
||||||
|
insertAtCursor: jest.fn(),
|
||||||
|
...apiOverrides,
|
||||||
|
};
|
||||||
|
window.electronAPI = { flowchart: apiMock };
|
||||||
|
const bundleSrc = fs.readFileSync(BUNDLE_PATH, 'utf-8');
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
new Function('window', 'document', bundleSrc)(window, document);
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
return { apiMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Export SVG calls api.saveFile with the SVG string and flowchart.svg', async () => {
|
||||||
|
const { apiMock } = await loadBundle({
|
||||||
|
saveFile: jest.fn(async () => ({ canceled: false, path: '/tmp/flow.svg' })),
|
||||||
|
});
|
||||||
|
document.getElementById('fc-btn-export-svg').click();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
|
||||||
|
const [content, defaultName] = apiMock.saveFile.mock.calls[0];
|
||||||
|
expect(typeof content).toBe('string');
|
||||||
|
expect(content.startsWith('<?xml version="1.0"')).toBe(true);
|
||||||
|
expect(content).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
expect(defaultName).toBe('flowchart.svg');
|
||||||
|
expect(document.getElementById('fc-status').textContent).toBe('Saved SVG to /tmp/flow.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Export SVG surfaces "cancel" when the user dismisses the Save dialog', async () => {
|
||||||
|
const { apiMock } = await loadBundle({
|
||||||
|
saveFile: jest.fn(async () => ({ canceled: true })),
|
||||||
|
});
|
||||||
|
document.getElementById('fc-btn-export-svg').click();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(apiMock.saveFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.getElementById('fc-status').textContent).toBe('Export cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user