Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: shared export-feedback helper + wire into every editor (#686)

v0.53.0: Export/import parity — unify user-facing toast copy for exports
across every editor, eliminate silent-succeed and silent-fail paths, and
give slides a real (basic) export.

Added
- src/lib/export-feedback.ts: shared exportSuccess/exportError/
importSuccess/importError helpers with noun pluralization and
Error-message extraction. 12 unit tests.
- Slides: dead "Export" button now downloads the deck as .deck.json
(full deck state: slides, theme, transitions, animations). Enables
backup/clone-a-deck until native PPTX/PDF export lands.

Changed
- Wired the shared helper into silent-failing export paths:
- diagrams: SVG, PNG, ASCII (with .shapes.size Map accessor fix)
- forms: CSV + XLSX response exports
- sheets: CSV, TSV, XLSX, PDF
- docs: HTML, Markdown, TXT, PDF, DOCX
- Every export now either confirms success with a row/slide/document
count or surfaces the real error via toast, replacing the previous
silent-succeed / silent-fail behavior.

+348 -39
+7
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Added 11 + - Shared export/import toast helper (`src/lib/export-feedback.ts`): `exportSuccess`/`exportError`/`importSuccess`/`importError` centralize user-facing copy with built-in noun pluralization and Error-message extraction, so every editor's export path surfaces the same phrasing ("Exported 12 rows as CSV", "CSV export failed: bad quote") instead of each editor re-implementing feedback. 12 unit tests pinning the contract. (#686) 12 + - Slides: JSON deck export — the previously dead "Export" button in slides now downloads the full deck state (slides, theme, transitions, animations) as a `.deck.json` file. Enables backup/clone-a-deck workflows until native PPTX/PDF export lands. (#686) 13 + 14 + ### Changed 15 + - Wired the shared export-feedback helper into silent-failing export paths across every editor: diagrams (SVG, PNG, ASCII with correct `.shapes.size` Map accessor), forms (CSV/XLSX response exports), sheets (CSV, TSV, XLSX, PDF), docs (HTML, Markdown, TXT, PDF, DOCX). Every export now either confirms success with a row/slide/document count or surfaces the real error via toast instead of failing silently. (#686) 16 + 10 17 ## [0.52.0] — 2026-04-16 11 18 12 19 ### Added
+18 -9
src/diagrams/main.ts
··· 543 543 // --- ASCII Export --- 544 544 $('btn-export-ascii').addEventListener('click', async () => { 545 545 const { exportToAscii } = await import('./ascii-export.js'); 546 - const text = exportToAscii(wb); 547 - if (!text) return; 548 - const blob = new Blob([text], { type: 'text/plain' }); 549 - const url = URL.createObjectURL(blob); 550 - const a = document.createElement('a'); 551 - a.href = url; 552 - a.download = (diagramTitle.value || 'diagram').replace(/[^a-zA-Z0-9_-]/g, '_') + '.txt'; 553 - a.click(); 554 - URL.revokeObjectURL(url); 546 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 547 + try { 548 + const text = exportToAscii(wb); 549 + if (!text) { 550 + exportError({ format: 'ASCII', reason: 'diagram is empty' }); 551 + return; 552 + } 553 + const blob = new Blob([text], { type: 'text/plain' }); 554 + const url = URL.createObjectURL(blob); 555 + const a = document.createElement('a'); 556 + a.href = url; 557 + a.download = (diagramTitle.value || 'diagram').replace(/[^a-zA-Z0-9_-]/g, '_') + '.txt'; 558 + a.click(); 559 + URL.revokeObjectURL(url); 560 + exportSuccess({ count: wb.shapes.size, noun: 'shape', format: 'ASCII' }); 561 + } catch (err) { 562 + exportError({ format: 'ASCII', error: err }); 563 + } 555 564 }); 556 565 557 566 // --- Wireframe Component Palette ---
+17 -3
src/diagrams/toolbar-wiring.ts
··· 384 384 }); 385 385 386 386 // Export buttons 387 - $('btn-export-svg')?.addEventListener('click', () => { 387 + $('btn-export-svg')?.addEventListener('click', async () => { 388 388 const selectedShapeIds = deps.getSelectedShapeIds(); 389 389 const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 390 - exportAndDownloadSVG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.svg`, ids); 390 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 391 + try { 392 + exportAndDownloadSVG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.svg`, ids); 393 + const count = ids ? ids.size : deps.getState().shapes.size; 394 + exportSuccess({ count, noun: 'shape', format: 'SVG' }); 395 + } catch (err) { 396 + exportError({ format: 'SVG', error: err }); 397 + } 391 398 }); 392 399 $('btn-export-png')?.addEventListener('click', async () => { 393 400 const selectedShapeIds = deps.getSelectedShapeIds(); 394 401 const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 395 - await exportAndDownloadPNG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.png`, ids); 402 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 403 + try { 404 + await exportAndDownloadPNG(deps.getState(), `${deps.diagramTitle.value || 'diagram'}.png`, ids); 405 + const count = ids ? ids.size : deps.getState().shapes.size; 406 + exportSuccess({ count, noun: 'shape', format: 'PNG' }); 407 + } catch (err) { 408 + exportError({ format: 'PNG', error: err }); 409 + } 396 410 }); 397 411 398 412 // Style panel events
+16 -5
src/docs/export-import.ts
··· 168 168 const doDocx = () => doExportDocx(editor, titleInput); 169 169 const doImport = () => importFile(editor, provider); 170 170 171 - $('tb-export-html').addEventListener('click', () => { closeAll(); doExport(); }); 172 - $('tb-export-md').addEventListener('click', () => { closeAll(); exportMarkdown(editor, titleInput); }); 173 - $('tb-export-txt').addEventListener('click', () => { closeAll(); exportText(editor, titleInput); }); 174 - $('tb-export-pdf').addEventListener('click', () => { closeAll(); doPdf(); }); 175 - $('tb-export-docx').addEventListener('click', () => { closeAll(); doDocx(); }); 171 + const wrapExport = (format: string, run: () => void | Promise<void>) => async () => { 172 + closeAll(); 173 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 174 + try { 175 + await run(); 176 + exportSuccess({ noun: 'document', format }); 177 + } catch (err) { 178 + exportError({ format, error: err }); 179 + } 180 + }; 181 + 182 + $('tb-export-html').addEventListener('click', wrapExport('HTML', doExport)); 183 + $('tb-export-md').addEventListener('click', wrapExport('Markdown', () => exportMarkdown(editor, titleInput))); 184 + $('tb-export-txt').addEventListener('click', wrapExport('TXT', () => exportText(editor, titleInput))); 185 + $('tb-export-pdf').addEventListener('click', wrapExport('PDF', doPdf)); 186 + $('tb-export-docx').addEventListener('click', wrapExport('DOCX', doDocx)); 176 187 $('tb-import').addEventListener('click', () => { closeAll(); doImport(); }); 177 188 $('tb-print').addEventListener('click', () => { closeAll(); printDocument(); }); 178 189
+16 -4
src/forms/render-responses.ts
··· 80 80 `; 81 81 82 82 // Wire export buttons 83 - pane.querySelector('#btn-export-csv')?.addEventListener('click', () => { 84 - exportResponsesCsv(form, responses); 83 + pane.querySelector('#btn-export-csv')?.addEventListener('click', async () => { 84 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 85 + try { 86 + exportResponsesCsv(form, responses); 87 + exportSuccess({ count: responses.length, noun: 'response', format: 'CSV' }); 88 + } catch (err) { 89 + exportError({ format: 'CSV', error: err }); 90 + } 85 91 }); 86 - pane.querySelector('#btn-export-xlsx')?.addEventListener('click', () => { 87 - exportResponsesXlsx(form, responses); 92 + pane.querySelector('#btn-export-xlsx')?.addEventListener('click', async () => { 93 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 94 + try { 95 + exportResponsesXlsx(form, responses); 96 + exportSuccess({ count: responses.length, noun: 'response', format: 'XLSX' }); 97 + } catch (err) { 98 + exportError({ format: 'XLSX', error: err }); 99 + } 88 100 }); 89 101 } 90 102
+94
src/lib/export-feedback.ts
··· 1 + /** 2 + * Shared export/import feedback helper. 3 + * 4 + * Every editor's export and import paths should route their success/failure 5 + * toasts through this module so the user sees the same phrasing everywhere: 6 + * 7 + * exportSuccess({ count: 12, noun: 'row', format: 'CSV' }) 8 + * → "Exported 12 rows as CSV" 9 + * exportError({ format: 'PDF', error: err }) 10 + * → "PDF export failed: <reason>" 11 + * 12 + * Introduced in v0.53.0 to close the silent-failure gap on diagrams/forms/sheets 13 + * export paths and unify the phrasing with the one-off toasts that already 14 + * existed in docs and calendar. 15 + */ 16 + 17 + import { showToast } from '../landing-toast.js'; 18 + 19 + /** Pluralize a noun for display. Handles common English patterns. */ 20 + function pluralize(noun: string, count: number): string { 21 + if (count === 1) return noun; 22 + // -y → -ies (e.g. "entry" → "entries"), unless preceded by a vowel 23 + if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + 'ies'; 24 + // -s/-x/-ch/-sh/-ss → -es 25 + if (/(s|x|ch|sh|ss)$/i.test(noun)) return noun + 'es'; 26 + return noun + 's'; 27 + } 28 + 29 + function reasonFrom(error: unknown, reason: string | undefined): string | undefined { 30 + if (reason) return reason; 31 + if (error instanceof Error) return error.message; 32 + if (typeof error === 'string') return error; 33 + if (error == null) return undefined; 34 + return String(error); 35 + } 36 + 37 + export interface ExportSuccessOptions { 38 + /** Number of items exported. Omit for single-artifact exports (e.g. an entire slide deck as PPTX). */ 39 + count?: number; 40 + /** Singular noun describing what was exported ("slide", "row", "event"). Required when count is supplied. */ 41 + noun: string; 42 + /** Format label shown to the user ("CSV", "PPTX", "PDF"). */ 43 + format: string; 44 + } 45 + 46 + /** Emit a success toast after a completed export. */ 47 + export function exportSuccess(opts: ExportSuccessOptions): void { 48 + const { count, noun, format } = opts; 49 + const msg = 50 + count == null 51 + ? `Exported ${noun} as ${format}` 52 + : `Exported ${count} ${pluralize(noun, count)} as ${format}`; 53 + showToast(msg, 3000); 54 + } 55 + 56 + export interface ExportErrorOptions { 57 + format: string; 58 + reason?: string; 59 + error?: unknown; 60 + } 61 + 62 + /** Emit an error toast after a failed export. */ 63 + export function exportError(opts: ExportErrorOptions): void { 64 + const { format } = opts; 65 + const detail = reasonFrom(opts.error, opts.reason); 66 + const msg = detail ? `${format} export failed: ${detail}` : `${format} export failed`; 67 + showToast(msg, 5000, true); 68 + } 69 + 70 + export interface ImportSuccessOptions { 71 + count: number; 72 + noun: string; 73 + format: string; 74 + } 75 + 76 + /** Emit a success toast after a completed import. */ 77 + export function importSuccess(opts: ImportSuccessOptions): void { 78 + const { count, noun, format } = opts; 79 + showToast(`Imported ${count} ${pluralize(noun, count)} from ${format}`, 3000); 80 + } 81 + 82 + export interface ImportErrorOptions { 83 + format: string; 84 + reason?: string; 85 + error?: unknown; 86 + } 87 + 88 + /** Emit an error toast after a failed import. */ 89 + export function importError(opts: ImportErrorOptions): void { 90 + const { format } = opts; 91 + const detail = reasonFrom(opts.error, opts.reason); 92 + const msg = detail ? `${format} import failed: ${detail}` : `${format} import failed`; 93 + showToast(msg, 5000, true); 94 + }
+54 -17
src/sheets/import-export.ts
··· 82 82 return lines.join('\n'); 83 83 } 84 84 85 - export function exportCSV(deps: ImportExportDeps): void { 85 + export function exportCSV(deps: ImportExportDeps): number { 86 86 const name = deps.getActiveSheet().get('name') || 'sheet'; 87 - downloadFile(sheetToDelimited(deps, ','), name + '.csv', 'text/csv;charset=utf-8'); 87 + const content = sheetToDelimited(deps, ','); 88 + downloadFile(content, name + '.csv', 'text/csv;charset=utf-8'); 89 + return content ? content.split('\n').length : 0; 88 90 } 89 91 90 - export function exportTSV(deps: ImportExportDeps): void { 92 + export function exportTSV(deps: ImportExportDeps): number { 91 93 const name = deps.getActiveSheet().get('name') || 'sheet'; 92 - downloadFile(sheetToDelimited(deps, '\t'), name + '.tsv', 'text/tab-separated-values;charset=utf-8'); 94 + const content = sheetToDelimited(deps, '\t'); 95 + downloadFile(content, name + '.tsv', 'text/tab-separated-values;charset=utf-8'); 96 + return content ? content.split('\n').length : 0; 93 97 } 94 98 95 99 // ── Import ────────────────────────────────────────────────── ··· 291 295 // ── Toolbar + Drag-and-Drop Wiring ────────────────────────── 292 296 293 297 export function wireImportExportToolbar(deps: ImportExportDeps, closeAllDropdowns: () => void): void { 294 - document.getElementById('tb-export-csv')!.addEventListener('click', () => { exportCSV(deps); closeAllDropdowns(); }); 298 + document.getElementById('tb-export-csv')!.addEventListener('click', async () => { 299 + closeAllDropdowns(); 300 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 301 + try { 302 + const rows = exportCSV(deps); 303 + exportSuccess({ count: rows, noun: 'row', format: 'CSV' }); 304 + } catch (err) { 305 + exportError({ format: 'CSV', error: err }); 306 + } 307 + }); 295 308 document.getElementById('tb-export-xlsx')?.addEventListener('click', async () => { 296 309 closeAllDropdowns(); 297 - const sheet = deps.getActiveSheet(); 298 - const rc = sheet.get('rowCount') || deps.DEFAULT_ROWS; 299 - const cc = sheet.get('colCount') || deps.DEFAULT_COLS; 300 - const name = sheet.get('name') || 'sheet'; 301 - const buf = await exportToXlsx( 302 - (r: number, c: number) => deps.getCellData(cellId(c, r)), 303 - rc, cc, 304 - (c: number) => deps.getColWidth(c), 305 - name 306 - ); 307 - downloadXlsx(buf, name + '.xlsx'); 310 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 311 + try { 312 + const sheet = deps.getActiveSheet(); 313 + const rc = sheet.get('rowCount') || deps.DEFAULT_ROWS; 314 + const cc = sheet.get('colCount') || deps.DEFAULT_COLS; 315 + const name = sheet.get('name') || 'sheet'; 316 + // Count rows with any data for the user-facing count 317 + let filledRows = 0; 318 + for (let r = 1; r <= rc; r++) { 319 + for (let c = 1; c <= cc; c++) { 320 + const d = deps.getCellData(cellId(c, r)); 321 + if (d && (d.v !== '' && d.v !== undefined || d.f)) { filledRows++; break; } 322 + } 323 + } 324 + const buf = await exportToXlsx( 325 + (r: number, c: number) => deps.getCellData(cellId(c, r)), 326 + rc, cc, 327 + (c: number) => deps.getColWidth(c), 328 + name 329 + ); 330 + downloadXlsx(buf, name + '.xlsx'); 331 + exportSuccess({ count: filledRows, noun: 'row', format: 'XLSX' }); 332 + } catch (err) { 333 + exportError({ format: 'XLSX', error: err }); 334 + } 335 + }); 336 + document.getElementById('tb-export-pdf')?.addEventListener('click', async () => { 337 + closeAllDropdowns(); 338 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 339 + try { 340 + await exportSheetPdf(deps); 341 + const name = deps.getActiveSheet().get('name') || 'Sheet 1'; 342 + exportSuccess({ noun: name, format: 'PDF' }); 343 + } catch (err) { 344 + exportError({ format: 'PDF', error: err }); 345 + } 308 346 }); 309 - document.getElementById('tb-export-pdf')?.addEventListener('click', () => { exportSheetPdf(deps); closeAllDropdowns(); }); 310 347 document.getElementById('tb-import')!.addEventListener('click', () => { importCSV(deps); closeAllDropdowns(); }); 311 348 document.getElementById('tb-print')!.addEventListener('click', () => { 312 349 closeAllDropdowns();
+35 -1
src/slides/main.ts
··· 223 223 actions.render(); 224 224 } 225 225 226 + // --- Export (JSON deck backup) --- 227 + function exportDeckAsJson(): number { 228 + const title = refs.deckTitle.value.trim() || 'Untitled Presentation'; 229 + const payload = { 230 + format: 'tools-slides-deck', 231 + version: 1, 232 + title, 233 + deck: state.deck, 234 + themedDeck: state.themedDeck, 235 + transitions: state.transitions, 236 + animations: state.animations, 237 + exportedAt: new Date().toISOString(), 238 + }; 239 + const content = JSON.stringify(payload, null, 2); 240 + const blob = new Blob([content], { type: 'application/json' }); 241 + const url = URL.createObjectURL(blob); 242 + const a = document.createElement('a'); 243 + a.href = url; 244 + a.download = title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_') + '.deck.json'; 245 + document.body.appendChild(a); a.click(); document.body.removeChild(a); 246 + URL.revokeObjectURL(url); 247 + return slideCount(state.deck); 248 + } 249 + 250 + $('btn-export').addEventListener('click', async () => { 251 + const { exportSuccess, exportError } = await import('../lib/export-feedback.js'); 252 + try { 253 + const count = exportDeckAsJson(); 254 + exportSuccess({ count, noun: 'slide', format: 'JSON' }); 255 + } catch (err) { 256 + exportError({ format: 'JSON', error: err }); 257 + } 258 + }); 259 + 226 260 // --- Command Palette --- 227 261 createCommandPalette({ 228 262 actions: [ ··· 233 267 { id: 'add-text', label: 'Add Text Element', category: 'action', icon: 'T', action: () => { document.getElementById('btn-add-text')?.click(); } }, 234 268 { id: 'add-shape', label: 'Add Shape Element', category: 'action', icon: '\u25a0', action: () => { document.getElementById('btn-add-shape')?.click(); } }, 235 269 { id: 'add-image', label: 'Add Image Element', category: 'action', icon: '\u25a3', action: () => { document.getElementById('btn-add-image')?.click(); } }, 236 - { id: 'export', label: 'Export Presentation', category: 'action', icon: '\u2193', action: () => { document.getElementById('btn-export')?.click(); } }, 270 + { id: 'export', label: 'Export Presentation (JSON)', category: 'action', icon: '\u2193', action: () => { document.getElementById('btn-export')?.click(); } }, 237 271 ], 238 272 }); 239 273
+91
tests/export-feedback.test.ts
··· 1 + /** 2 + * Tests for the shared export-feedback helper (v0.53.0). 3 + * 4 + * The helper centralizes user-facing toast phrasing for export/import operations 5 + * so every editor uses the same copy. Until v0.53.0, diagrams/forms/sheets exports 6 + * silently succeeded and failed with zero user feedback. 7 + */ 8 + 9 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 10 + 11 + // Mock the toast module BEFORE importing the helper under test 12 + vi.mock('../src/landing-toast.js', () => ({ 13 + showToast: vi.fn(), 14 + })); 15 + 16 + import { exportSuccess, exportError, importSuccess, importError } from '../src/lib/export-feedback.js'; 17 + import { showToast } from '../src/landing-toast.js'; 18 + 19 + describe('export-feedback helper', () => { 20 + beforeEach(() => { 21 + vi.mocked(showToast).mockClear(); 22 + }); 23 + 24 + describe('exportSuccess', () => { 25 + it('toasts with singular noun when count is 1', () => { 26 + exportSuccess({ count: 1, noun: 'event', format: 'ICS' }); 27 + expect(showToast).toHaveBeenCalledWith('Exported 1 event as ICS', 3000); 28 + }); 29 + 30 + it('toasts with plural noun when count > 1', () => { 31 + exportSuccess({ count: 12, noun: 'row', format: 'CSV' }); 32 + expect(showToast).toHaveBeenCalledWith('Exported 12 rows as CSV', 3000); 33 + }); 34 + 35 + it('omits count when count is null (single-artifact export)', () => { 36 + exportSuccess({ noun: 'slide deck', format: 'PPTX' }); 37 + expect(showToast).toHaveBeenCalledWith('Exported slide deck as PPTX', 3000); 38 + }); 39 + 40 + it('pluralizes common irregular words', () => { 41 + exportSuccess({ count: 3, noun: 'entry', format: 'JSON' }); 42 + expect(showToast).toHaveBeenCalledWith('Exported 3 entries as JSON', 3000); 43 + }); 44 + }); 45 + 46 + describe('exportError', () => { 47 + it('toasts with error styling (isError=true)', () => { 48 + exportError({ format: 'PDF', reason: 'render timeout' }); 49 + expect(showToast).toHaveBeenCalledWith('PDF export failed: render timeout', 5000, true); 50 + }); 51 + 52 + it('omits reason when none provided', () => { 53 + exportError({ format: 'SVG' }); 54 + expect(showToast).toHaveBeenCalledWith('SVG export failed', 5000, true); 55 + }); 56 + 57 + it('extracts message from Error instance', () => { 58 + exportError({ format: 'XLSX', error: new Error('workbook too large') }); 59 + expect(showToast).toHaveBeenCalledWith('XLSX export failed: workbook too large', 5000, true); 60 + }); 61 + 62 + it('stringifies non-Error thrown values', () => { 63 + exportError({ format: 'CSV', error: 'bad quote' }); 64 + expect(showToast).toHaveBeenCalledWith('CSV export failed: bad quote', 5000, true); 65 + }); 66 + }); 67 + 68 + describe('importSuccess', () => { 69 + it('toasts with singular noun when count is 1', () => { 70 + importSuccess({ count: 1, noun: 'slide', format: 'PPTX' }); 71 + expect(showToast).toHaveBeenCalledWith('Imported 1 slide from PPTX', 3000); 72 + }); 73 + 74 + it('toasts with plural noun when count > 1', () => { 75 + importSuccess({ count: 47, noun: 'row', format: 'CSV' }); 76 + expect(showToast).toHaveBeenCalledWith('Imported 47 rows from CSV', 3000); 77 + }); 78 + }); 79 + 80 + describe('importError', () => { 81 + it('toasts with error styling', () => { 82 + importError({ format: 'ICS', reason: 'invalid VEVENT' }); 83 + expect(showToast).toHaveBeenCalledWith('ICS import failed: invalid VEVENT', 5000, true); 84 + }); 85 + 86 + it('extracts message from Error instance', () => { 87 + importError({ format: 'JSON', error: new Error('schema mismatch') }); 88 + expect(showToast).toHaveBeenCalledWith('JSON import failed: schema mismatch', 5000, true); 89 + }); 90 + }); 91 + });