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

Configure Feed

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

feat: eliminate silent failures + consolidate showToast (#685) (#398)

scott e5e88453 f05c5746

+221 -84
+5
CHANGELOG.md
··· 12 12 13 13 ### Changed 14 14 - Replaced 12 blocking `prompt()`/`confirm()` call sites with the new modal helpers: trash permanent-delete and empty-trash, version-panel name-and-restore, calendar event delete, sheets sheet-delete, landing folder delete, landing identity (Tailscale signed-in, change display name), docs toolbar (insert link, insert image, add comment), and the Forge Note button (#681). 15 + - Eliminated remaining 11 blocking `prompt()`/`confirm()` call sites: doclist tag editor, docs comment-reply, docs link-preview edit, docs slash-commands (image, link, footnote), docs command-palette footnote, slides add-image, slides thumbnail right-click (now a proper Duplicate/Delete context menu — previously a `confirm("...? Cancel to duplicate")` with inverted semantics), forms link-to-sheet, sheets column and row resize. All now go through `modalPrompt`/`modalConfirm` or `createContextMenu`. (#110) 16 + - Consolidated three drifted `showToast` implementations: the canonical queue-based helper in `src/landing-toast.ts` now serves as the single source of truth. `src/docs/export-import.ts` and `src/sheets/import-export.ts` now re-export from it instead of each maintaining their own basic version. Also replaced 6 blocking `alert()` calls — version restore failure, diagram toolbar errors (text-only mode, palette validation), sheets chart validation, sheets pivot row-field validation — with error-styled toasts. (#110) 17 + 18 + ### Fixed 19 + - `insertFootnote` TipTap command previously called `prompt()` internally when no content was supplied, which froze the main thread. The command now requires the `content` argument; the slash-command handler and command palette gather footnote text via the async modal helper before invoking it. (#110) 15 20 16 21 ### Removed 17 22 - 9 orphaned context-menu builder helpers (`buildDocsTextItems`, `buildDocsLinkItems`, `buildDocsImageItems`, `buildDocsTableItems`, `buildSheetsCellItems`, `buildSheetsColumnHeaderItems`, `buildSheetsRowHeaderItems`, `buildSheetsContextItems`, plus the `SheetsContextTarget` type): returned menu arrays with 45 no-op `() => {}` handlers and were only referenced by tests — never wired into a real editor. (#109)
+19 -5
src/diagrams/main.ts
··· 477 477 if (visible) renderLayersPanel(); 478 478 }); 479 479 480 - btnAddLayer.addEventListener('click', () => { 481 - const name = prompt('Layer name:'); 480 + btnAddLayer.addEventListener('click', async () => { 481 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 482 + const name = await modalPrompt({ 483 + title: 'New layer', 484 + message: 'Name this layer:', 485 + placeholder: 'e.g. Annotations', 486 + okLabel: 'Create', 487 + }); 482 488 if (!name || !name.trim()) return; 483 489 layerState = addLayer(layerState, name.trim()); 484 490 syncToYjs(); 485 491 renderLayersPanel(); 486 492 }); 487 493 488 - layersList.addEventListener('click', (e) => { 494 + layersList.addEventListener('click', async (e) => { 489 495 const target = e.target as HTMLElement; 490 496 const action = target.dataset.action; 491 497 const item = target.closest('.layer-item') as HTMLElement | null; ··· 504 510 syncToYjs(); 505 511 renderLayersPanel(); 506 512 break; 507 - case 'remove': 508 - if (confirm('Delete this layer? Shapes will move to Default.')) { 513 + case 'remove': { 514 + const { modalConfirm } = await import('../lib/modal-dialog.js'); 515 + const ok = await modalConfirm({ 516 + title: 'Delete layer?', 517 + message: 'Shapes in this layer will move to Default.', 518 + okLabel: 'Delete', 519 + destructive: true, 520 + }); 521 + if (ok) { 509 522 layerState = removeLayer(layerState, layerId); 510 523 syncToYjs(); 511 524 render(); 512 525 renderLayersPanel(); 513 526 } 514 527 break; 528 + } 515 529 case 'select': 516 530 // Assign selected shapes to this layer 517 531 if (selectedShapeIds.size > 0) {
+4 -2
src/diagrams/toolbar-wiring.ts
··· 361 361 const text = await file.text(); 362 362 const { shapes, skippedCount } = parseSvgToShapes(text); 363 363 if (shapes.length === 0) { 364 - alert('No supported shapes found in SVG. Only rect, circle, ellipse, line, polygon, and text elements are imported.'); 364 + const { showToast } = await import('../landing-toast.js'); 365 + showToast('No supported shapes found in SVG. Only rect, circle, ellipse, line, polygon, and text are imported.', 5000, true); 365 366 return; 366 367 } 367 368 deps.pushHistory(); ··· 377 378 } 378 379 } catch (err) { 379 380 console.error('SVG import failed:', err); 380 - alert('Failed to import SVG file.'); 381 + const { showToast } = await import('../landing-toast.js'); 382 + showToast('Failed to import SVG file.', 4000, true); 381 383 } 382 384 }); 383 385
+13 -6
src/docs/comments-sidebar-ui.ts
··· 82 82 renderCommentThreads(); 83 83 syncCommentsToYjs(); 84 84 } else if (action === 'reply') { 85 - const text = prompt('Reply:'); 86 - if (text) { 87 - commentThreads[idx] = addReply(commentThreads[idx], userName, text); 88 - renderCommentThreads(); 89 - syncCommentsToYjs(); 90 - } 85 + (async () => { 86 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 87 + const text = await modalPrompt({ 88 + title: 'Reply', 89 + placeholder: 'Type your reply…', 90 + okLabel: 'Reply', 91 + }); 92 + if (text) { 93 + commentThreads[idx] = addReply(commentThreads[idx], userName, text); 94 + renderCommentThreads(); 95 + syncCommentsToYjs(); 96 + } 97 + })(); 91 98 } 92 99 }); 93 100 });
+3 -12
src/docs/export-import.ts
··· 37 37 } 38 38 39 39 // ── Toast notification ────────────────────────────────────── 40 - 41 - export function showToast(message: string, duration = 3000): void { 42 - const existing = document.querySelector('.toast-notification'); 43 - if (existing) existing.remove(); 44 - const toast = document.createElement('div'); 45 - toast.className = 'toast-notification'; 46 - toast.textContent = message; 47 - document.body.appendChild(toast); 48 - toast.offsetHeight; // force reflow 49 - toast.classList.add('toast-visible'); 50 - setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 51 - } 40 + // Re-exported from the canonical helper in landing-toast.ts so all editors 41 + // share a single implementation (queue, error styling, undo actions). 42 + export { showToast } from '../landing-toast.js'; 52 43 53 44 // ── Filename helper ───────────────────────────────────────── 54 45
+6 -5
src/docs/extensions/footnote.ts
··· 119 119 120 120 addCommands() { 121 121 return { 122 + // Caller must supply `content`. The UI (slash-command, toolbar) is 123 + // responsible for any async text-gathering before invoking this, since 124 + // TipTap commands must be synchronous. 122 125 insertFootnote: 123 - (content?: string) => 126 + (content: string) => 124 127 ({ commands }) => { 125 - const footnoteContent = content || prompt('Footnote text:'); 126 - if (!footnoteContent) return false; 127 - 128 + if (!content) return false; 128 129 return commands.insertContent({ 129 130 type: this.name, 130 131 attrs: { 131 132 footnoteId: generateFootnoteId(), 132 - content: footnoteContent, 133 + content, 133 134 }, 134 135 }); 135 136 },
+35 -9
src/docs/extensions/slash-commands.ts
··· 99 99 editor.chain().focus().toggleTaskList().run(); 100 100 }, 101 101 image: (editor: Editor) => { 102 - const url = prompt('Image URL:'); 103 - if (url) { 104 - editor.chain().focus().setImage({ src: url }).run(); 105 - } 102 + (async () => { 103 + const { modalPrompt } = await import('../../lib/modal-dialog.js'); 104 + const url = await modalPrompt({ 105 + title: 'Insert image', 106 + message: 'Paste an image URL (JPEG, PNG, GIF, WebP, SVG).', 107 + placeholder: 'https://example.com/image.png', 108 + okLabel: 'Insert', 109 + }); 110 + if (url) { 111 + editor.chain().focus().setImage({ src: url }).run(); 112 + } 113 + })(); 106 114 }, 107 115 table: (editor: Editor) => { 108 116 editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); ··· 130 138 editor.chain().focus().toggleBlockquote().run(); 131 139 }, 132 140 footnote: (editor: Editor) => { 133 - editor.chain().focus().insertFootnote().run(); 141 + (async () => { 142 + const { modalPrompt } = await import('../../lib/modal-dialog.js'); 143 + const content = await modalPrompt({ 144 + title: 'Insert footnote', 145 + message: 'Text of the footnote. It will appear as a superscript number that reveals this text.', 146 + placeholder: 'Footnote text…', 147 + okLabel: 'Insert', 148 + }); 149 + if (!content) return; 150 + editor.chain().focus().insertFootnote(content).run(); 151 + })(); 134 152 }, 135 153 pageBreak: (editor: Editor) => { 136 154 editor.chain().focus().insertPageBreak().run(); ··· 142 160 editor.chain().focus().insertToggleBlock().run(); 143 161 }, 144 162 link: (editor: Editor) => { 145 - const url = prompt('Link URL:'); 146 - if (url) { 147 - editor.chain().focus().setLink({ href: url }).run(); 148 - } 163 + (async () => { 164 + const { modalPrompt } = await import('../../lib/modal-dialog.js'); 165 + const url = await modalPrompt({ 166 + title: 'Insert link', 167 + message: 'URL for the selected text (or the text you type next).', 168 + placeholder: 'https://example.com', 169 + okLabel: 'Insert', 170 + }); 171 + if (url) { 172 + editor.chain().focus().setLink({ href: url }).run(); 173 + } 174 + })(); 149 175 }, 150 176 }; 151 177
+11 -1
src/docs/main.ts
··· 716 716 { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => exportImport.importFile() }, 717 717 { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); findBarResult.updateFindBar(); } }, 718 718 { id: 'toggle', label: 'Insert Toggle Block', category: 'action', icon: '\u25B6', action: () => editor.chain().focus().insertToggleBlock().run() }, 719 - { id: 'footnote', label: 'Insert Footnote', category: 'action', icon: '\u2020', action: () => editor.chain().focus().insertFootnote().run() }, 719 + { id: 'footnote', label: 'Insert Footnote', category: 'action', icon: '\u2020', action: async () => { 720 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 721 + const content = await modalPrompt({ 722 + title: 'Insert footnote', 723 + message: 'Text of the footnote. It will appear as a superscript number that reveals this text.', 724 + placeholder: 'Footnote text…', 725 + okLabel: 'Insert', 726 + }); 727 + if (!content) return; 728 + editor.chain().focus().insertFootnote(content).run(); 729 + } }, 720 730 ], 721 731 fetchDocuments: async (): Promise<PaletteAction[]> => { 722 732 try {
+9 -2
src/docs/sidebar-wiring.ts
··· 239 239 hideLinkPreview(); 240 240 }); 241 241 242 - $('link-preview-edit').addEventListener('click', () => { 242 + $('link-preview-edit').addEventListener('click', async () => { 243 243 const currentHref = linkPreviewState.href; 244 244 hideLinkPreview(); 245 - const newHref = prompt('Edit URL:', currentHref || ''); 245 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 246 + const newHref = await modalPrompt({ 247 + title: 'Edit link', 248 + message: 'Update the URL for the selected link.', 249 + initial: currentHref || '', 250 + placeholder: 'https://example.com', 251 + okLabel: 'Save', 252 + }); 246 253 if (newHref !== null && newHref !== currentHref) { 247 254 editor.chain().focus().extendMarkRange('link').setLink({ href: newHref }).run(); 248 255 }
+11 -3
src/docs/version-history-ui.ts
··· 172 172 173 173 $('version-restore').addEventListener('click', async () => { 174 174 if (!selectedVersionId) return; 175 - if (!confirm('Restore this version? Current changes will be replaced.')) return; 175 + const { modalConfirm } = await import('../lib/modal-dialog.js'); 176 + const ok = await modalConfirm({ 177 + title: 'Restore this version?', 178 + message: 'Current changes will be replaced. This cannot be undone.', 179 + okLabel: 'Restore', 180 + destructive: true, 181 + }); 182 + if (!ok) return; 176 183 try { 177 184 const res = await fetch(`/api/documents/${docId}/versions/${selectedVersionId}`); 178 185 if (!res.ok) throw new Error('Not found'); ··· 183 190 versionPreview.style.display = 'none'; 184 191 versionSidebar.style.display = 'none'; 185 192 selectedVersionId = null; 186 - } catch (err) { 187 - alert('Failed to restore version'); 193 + } catch { 194 + const { showToast } = await import('../landing-toast.js'); 195 + showToast('Failed to restore version', 4000, true); 188 196 } 189 197 }); 190 198 }
+9 -2
src/forms/main.ts
··· 140 140 else renderBuilder(); 141 141 }); 142 142 143 - document.getElementById('btn-settings')!.addEventListener('click', () => { 144 - const sheetId = prompt('Link to sheet (enter sheet document ID):', form.targetSheetId ?? ''); 143 + document.getElementById('btn-settings')!.addEventListener('click', async () => { 144 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 145 + const sheetId = await modalPrompt({ 146 + title: 'Link form to sheet', 147 + message: 'Enter the sheet document ID to send responses to. Leave empty to unlink.', 148 + initial: form.targetSheetId ?? '', 149 + placeholder: 'sheet-xxxxxxx', 150 + okLabel: 'Save', 151 + }); 145 152 if (sheetId !== null) { 146 153 form = setTargetSheet(form, sheetId || null); 147 154 syncFormToYjs();
+15 -6
src/landing-events-doclist.ts
··· 110 110 const doc = deps.getAllDocs().find(d => d.id === id); 111 111 if (!doc) return; 112 112 const current = parseTags(doc.tags); 113 - const input = prompt('Tags (comma-separated):', current.join(', ')); 114 - if (input === null) return; 115 - const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 116 - doc.tags = JSON.stringify(newTags); 117 - if (id) saveDocumentTags(id, newTags); 118 - deps.renderDocuments(); 113 + (async () => { 114 + const { modalPrompt } = await import('./lib/modal-dialog.js'); 115 + const input = await modalPrompt({ 116 + title: 'Edit tags', 117 + message: 'Comma-separated list of tags. Leave empty to clear.', 118 + initial: current.join(', '), 119 + placeholder: 'work, research, draft', 120 + okLabel: 'Save', 121 + }); 122 + if (input === null) return; 123 + const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 124 + doc.tags = JSON.stringify(newTags); 125 + if (id) saveDocumentTags(id, newTags); 126 + deps.renderDocuments(); 127 + })(); 119 128 return; 120 129 } 121 130
+2 -1
src/sheets/charts-ui.ts
··· 8 8 import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 9 9 import { cellId, colToLetter } from './formulas.js'; 10 10 import { normalizeRange } from './selection-utils.js'; 11 + import { showToast } from '../landing-toast.js'; 11 12 12 13 // ── Types ─────────────────────────────────────────────────── 13 14 ··· 119 120 }; 120 121 const validation = validateChartConfig(config); 121 122 if (!validation.valid) { 122 - alert(validation.errors.join('\n')); 123 + showToast(validation.errors.join(' • '), 5000, true); 123 124 return; 124 125 } 125 126 const charts = deps.getCharts();
+26 -2
src/sheets/context-menu-handler.ts
··· 100 100 SEPARATOR, 101 101 ...(deps.getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { deps.setFreezeCols(col); deps.renderGrid(); } }] : []), 102 102 ...(deps.getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { deps.setFreezeCols(0); deps.renderGrid(); } }] : []), 103 - { label: 'Resize Column\u2026', action: () => { const w = prompt('Column width (px):', String(deps.getColWidth(col))); if (w && !isNaN(Number(w))) { deps.setColWidth(col, Number(w)); deps.renderGrid(); } } }, 103 + { label: 'Resize Column\u2026', action: () => { 104 + (async () => { 105 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 106 + const w = await modalPrompt({ 107 + title: 'Resize column', 108 + message: `Width in pixels for column ${colToLetter(col)}.`, 109 + initial: String(deps.getColWidth(col)), 110 + placeholder: 'e.g. 120', 111 + okLabel: 'Resize', 112 + }); 113 + if (w && !isNaN(Number(w))) { deps.setColWidth(col, Number(w)); deps.renderGrid(); } 114 + })(); 115 + } }, 104 116 ]; 105 117 } else if (rowHeader) { 106 118 const row = parseInt(rowHeader.dataset.row!); ··· 115 127 SEPARATOR, 116 128 ...(deps.getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { deps.setFreezeRows(row); deps.renderGrid(); } }] : []), 117 129 ...(deps.getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { deps.setFreezeRows(0); deps.renderGrid(); } }] : []), 118 - { label: 'Resize Row\u2026', action: () => { const h = prompt('Row height (px):', String(deps.getRowHeight(row))); if (h && !isNaN(Number(h))) { deps.setRowHeight(row, Number(h)); deps.renderGrid(); } } }, 130 + { label: 'Resize Row\u2026', action: () => { 131 + (async () => { 132 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 133 + const h = await modalPrompt({ 134 + title: 'Resize row', 135 + message: `Height in pixels for row ${row}.`, 136 + initial: String(deps.getRowHeight(row)), 137 + placeholder: 'e.g. 24', 138 + okLabel: 'Resize', 139 + }); 140 + if (h && !isNaN(Number(h))) { deps.setRowHeight(row, Number(h)); deps.renderGrid(); } 141 + })(); 142 + } }, 119 143 ]; 120 144 } else if (td) { 121 145 const col = parseInt(td.dataset.col!);
+3 -11
src/sheets/import-export.ts
··· 49 49 URL.revokeObjectURL(url); 50 50 } 51 51 52 - export function showToast(message: string, duration = 3000): void { 53 - const existing = document.querySelector('.toast-notification'); 54 - if (existing) existing.remove(); 55 - const toast = document.createElement('div'); 56 - toast.className = 'toast-notification'; 57 - toast.textContent = message; 58 - document.body.appendChild(toast); 59 - toast.offsetHeight; 60 - toast.classList.add('toast-visible'); 61 - setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 62 - } 52 + // Re-exported from the canonical helper in landing-toast.ts so all editors 53 + // share a single implementation (queue, error styling, undo actions). 54 + export { showToast } from '../landing-toast.js'; 63 55 64 56 // ── Export ─────────────────────────────────────────────────── 65 57
+2 -1
src/sheets/pivot-ui.ts
··· 7 7 import { colToLetter, cellId } from './formulas.js'; 8 8 import { computePivot, formatAggregateValue } from './pivot-table.js'; 9 9 import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 10 + import { showToast } from '../landing-toast.js'; 10 11 11 12 // ── Types ─────────────────────────────────────────────────── 12 13 ··· 81 82 const title = (overlay.querySelector('#pivot-title') as HTMLInputElement).value.trim(); 82 83 83 84 if (rowFields.length === 0) { 84 - alert('Select at least one row field.'); 85 + showToast('Select at least one row field.', 4000, true); 85 86 return; 86 87 } 87 88
+8 -2
src/slides/event-handlers.ts
··· 70 70 actions.renderCanvas(); 71 71 }); 72 72 73 - $('btn-add-image').addEventListener('click', () => { 74 - const url = prompt('Image URL:'); 73 + $('btn-add-image').addEventListener('click', async () => { 74 + const { modalPrompt } = await import('../lib/modal-dialog.js'); 75 + const url = await modalPrompt({ 76 + title: 'Add image', 77 + message: 'Paste an image URL (JPEG, PNG, GIF, WebP, SVG).', 78 + placeholder: 'https://example.com/image.png', 79 + okLabel: 'Add', 80 + }); 75 81 if (url) { 76 82 const s = actions.getState(); 77 83 actions.setState({ deck: addElement(s.deck, 'image', 150, 100, 300, 200, url) });
+38 -13
src/slides/rendering.ts
··· 51 51 thumb.addEventListener('contextmenu', (e) => { 52 52 e.preventDefault(); 53 53 const s = actions.getState(); 54 - if (s.deck.slides.length > 1) { 55 - const shouldDelete = confirm('Delete this slide? (Cancel to duplicate)'); 56 - if (shouldDelete) { 57 - actions.setState({ 58 - deck: removeSlide(s.deck, i), 59 - themedDeck: { ...s.themedDeck, layouts: s.themedDeck.layouts.filter((_, idx) => idx !== i) }, 60 - }); 61 - } else { 62 - actions.setState({ deck: duplicateSlide(s.deck, i) }); 63 - } 64 - actions.syncDeckToYjs(); 65 - actions.render(); 66 - } 54 + if (s.deck.slides.length <= 1) return; 55 + (async () => { 56 + const { createContextMenu } = await import('../lib/context-menu.js'); 57 + const menu = createContextMenu([ 58 + { 59 + label: 'Duplicate slide', 60 + icon: '\u29C9', 61 + action: () => { 62 + const cur = actions.getState(); 63 + actions.setState({ deck: duplicateSlide(cur.deck, i) }); 64 + actions.syncDeckToYjs(); 65 + actions.render(); 66 + }, 67 + }, 68 + { 69 + label: 'Delete slide', 70 + icon: '\u2715', 71 + action: () => { 72 + const cur = actions.getState(); 73 + actions.setState({ 74 + deck: removeSlide(cur.deck, i), 75 + themedDeck: { ...cur.themedDeck, layouts: cur.themedDeck.layouts.filter((_, idx) => idx !== i) }, 76 + }); 77 + actions.syncDeckToYjs(); 78 + actions.render(); 79 + }, 80 + }, 81 + ]); 82 + document.body.appendChild(menu.el); 83 + menu.show(e.clientX, e.clientY); 84 + const closeHandler = (ev: MouseEvent): void => { 85 + if (!menu.el.contains(ev.target as Node)) { 86 + menu.destroy(); 87 + document.removeEventListener('mousedown', closeHandler); 88 + } 89 + }; 90 + setTimeout(() => document.addEventListener('mousedown', closeHandler), 0); 91 + })(); 67 92 }); 68 93 69 94 // Drag-to-reorder
+2 -1
src/version-panel.ts
··· 391 391 392 392 close(); 393 393 } catch { 394 - alert('Failed to restore version'); 394 + const { showToast } = await import('./landing-toast.js'); 395 + showToast('Failed to restore version', 4000, true); 395 396 } 396 397 } 397 398