diff --git a/tables/build.sh b/tables/build.sh index feb4b9d..6ddec90 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -42,6 +42,7 @@ concat_files \ "js/sort.js" \ "js/editor.js" \ "js/save.js" \ + "js/clipboard.js" \ "js/render.js" \ "js/main.js" \ "../form/js/app.js" \ diff --git a/tables/js/clipboard.js b/tables/js/clipboard.js new file mode 100644 index 0000000..ae5b528 --- /dev/null +++ b/tables/js/clipboard.js @@ -0,0 +1,277 @@ +// clipboard.js — Phase 4 of editable-cell mode. +// +// Bidirectional clipboard interop with Excel / Google Sheets / any +// other spreadsheet that uses RFC-4180-ish TSV on the text/plain +// clipboard mime. +// +// Copy: when a single cell is selected, Ctrl/Cmd+C writes that +// cell's value as plain text. Range selection (Phase 5) extends +// this to a TSV rectangle. +// +// Paste: Ctrl/Cmd+V on the focused cell parses text/plain as TSV +// (tabs between columns, newlines between rows; embedded newlines +// or tabs are quoted with double-quotes; doubled "" escapes). +// +// - 1×1 clipboard into selected cell → writes that one cell. +// - N×M clipboard into selected cell → SPILLS from the anchor +// cell down/right to (anchor.row + N - 1, anchor.col + M - 1). +// Out-of-bounds cells are silently dropped (Excel convention). +// +// Each pasted cell goes through the same draft-buffer write path +// as a normal edit — the row-blur save trigger picks them up, +// and the per-cell schema-driven coercion (Phase 2) applies. +// Per-cell validation runs on the next save attempt; invalid +// cells get the red-corner mark. +(function (app) { + 'use strict'; + + function editor() { return app.modules.editor; } + + // --- TSV parsing -------------------------------------------------- + + // parseTSV(text) → string[][]. Honors RFC-4180-ish quoting: + // - A field surrounded by " can contain tabs, newlines, and + // literal " characters escaped as "". + // - An unquoted field ends at the next tab, newline, or end. + // - Bare \r is treated as part of \r\n (Windows line endings). + function parseTSV(text) { + const rows = []; + let row = []; + let field = ''; + let inQuotes = false; + const s = String(text == null ? '' : text); + + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuotes) { + if (ch === '"') { + if (s[i + 1] === '"') { + // Escaped quote inside a quoted field. + field += '"'; + i++; + } else { + // End of quoted field. + inQuotes = false; + } + } else { + field += ch; + } + continue; + } + if (ch === '"' && field === '') { + // Open quote — only at start of field. + inQuotes = true; + continue; + } + if (ch === '\t') { + row.push(field); + field = ''; + continue; + } + if (ch === '\n' || ch === '\r') { + // \r\n — consume the \n too. + if (ch === '\r' && s[i + 1] === '\n') i++; + row.push(field); + field = ''; + rows.push(row); + row = []; + continue; + } + field += ch; + } + // Trailing field (no terminator). + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + // Excel often appends a trailing empty row from the final \n; + // drop one trailing all-empty row to match that convention. + if (rows.length > 0) { + const last = rows[rows.length - 1]; + if (last.length === 1 && last[0] === '') rows.pop(); + } + return rows; + } + + // formatTSV(grid) → string. Reverse of parseTSV. Quotes any + // field containing tab, newline, or double-quote. + function formatTSV(grid) { + const lines = []; + for (let r = 0; r < grid.length; r++) { + const row = grid[r]; + const cells = []; + for (let c = 0; c < row.length; c++) { + cells.push(formatCell(row[c])); + } + lines.push(cells.join('\t')); + } + return lines.join('\n'); + } + + function formatCell(v) { + const s = (v == null) ? '' : String(v); + if (/[\t\n\r"]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + + // --- Apply paste -------------------------------------------------- + + function applyPaste(anchorRowIdx, anchorColIdx, grid) { + // grid is string[][]. Returns {applied: int, skipped: int}. + const ed = editor(); + const totalRows = visibleRowCount(); + const cols = (app.context && app.context.columns) || []; + const totalCols = cols.length; + let applied = 0, skipped = 0; + + for (let r = 0; r < grid.length; r++) { + const dstR = anchorRowIdx + r; + if (dstR >= totalRows) { skipped += grid[r].length; continue; } + const row = rowDataAtIndex(dstR); + if (!row) { skipped += grid[r].length; continue; } + for (let c = 0; c < grid[r].length; c++) { + const dstC = anchorColIdx + c; + if (dstC >= totalCols) { skipped++; continue; } + const col = cols[dstC]; + if (!col) { skipped++; continue; } + const newValue = coerceCell(grid[r][c], col, row); + ed.setDraft(ed.rowKey(row), col.field, newValue); + applied++; + } + } + return { applied: applied, skipped: skipped }; + } + + function visibleRowCount() { + return document.querySelectorAll('#table-root tbody > tr').length; + } + + function rowDataAtIndex(r) { + const tr = document.querySelectorAll('#table-root tbody > tr')[r]; + if (!tr) return null; + const rowId = tr.getAttribute('data-row-id'); + if (rowId == null) return null; + const all = (app.state && app.state.rows) || []; + for (let i = 0; i < all.length; i++) { + if (editor().rowKey(all[i]) === rowId) return all[i]; + } + return null; + } + + function coerceCell(raw, col, _row) { + // Phase 2's editor coerces values typed into a number/checkbox/ + // select widget. Pasted cells arrive as raw strings; coerce + // here so the draft holds the right JS type. Falls back to the + // raw string when coercion is ambiguous. + const fmt = col.format; + if (fmt === 'number' || fmt === 'integer' || isNumericSchema(col)) { + const n = Number(raw); + if (raw.trim() !== '' && !Number.isNaN(n)) return n; + } + if (isBooleanSchema(col)) { + const t = String(raw).trim().toLowerCase(); + if (t === 'true' || t === 'yes' || t === '1') return true; + if (t === 'false' || t === 'no' || t === '0' || t === '') return false; + } + return raw; + } + + function isNumericSchema(col) { + const s = propSchema(col); + return !!(s && (s.type === 'number' || s.type === 'integer')); + } + + function isBooleanSchema(col) { + const s = propSchema(col); + return !!(s && s.type === 'boolean'); + } + + function propSchema(col) { + const ctx = app.context || {}; + if (!ctx.rowSchema || !ctx.rowSchema.properties) return null; + return ctx.rowSchema.properties[col.field] || null; + } + + // --- Event handlers ---------------------------------------------- + + function onPaste(ev) { + if (!app.state || !app.state.selected) return; + if (app.state.editing) return; // input owns its own paste + const text = ev.clipboardData && ev.clipboardData.getData('text/plain'); + if (!text) return; + ev.preventDefault(); + const grid = parseTSV(text); + if (!grid.length) return; + const { row: r, col: c } = app.state.selected; + const result = applyPaste(r, c, grid); + // Trigger a re-paint so draft values display. + if (typeof app.repaint === 'function') app.repaint(); + if (result.skipped > 0) { + notifyToast( + 'Pasted ' + result.applied + ' cell' + plural(result.applied) + + '; ' + result.skipped + ' dropped (out of bounds)' + ); + } + } + + function onCopy(ev) { + if (!app.state || !app.state.selected) return; + if (app.state.editing) return; // input owns its own copy + const { row: r, col: c } = app.state.selected; + const row = rowDataAtIndex(r); + const cols = (app.context && app.context.columns) || []; + const col = cols[c]; + if (!row || !col) return; + const value = editor().effectiveCellValue(row, col); + ev.preventDefault(); + if (ev.clipboardData) { + ev.clipboardData.setData('text/plain', formatCell(value)); + } + } + + function plural(n) { return n === 1 ? '' : 's'; } + + function notifyToast(msg) { + // Cheap toast: write to #table-status, auto-clear after 4s. + // Coexists with save.js's stale-row prompt — just don't fire + // if a prompt is currently up. + const el = document.getElementById('table-status'); + if (!el) return; + if (el.classList.contains('table-status--prompt')) return; + el.textContent = msg; + el.hidden = false; + clearTimeout(notifyToast._t); + notifyToast._t = setTimeout(() => { + if (el.textContent === msg) { + el.hidden = true; + el.textContent = ''; + } + }, 4000); + } + + function attach() { + // Listen at the document level so paste events bubble from + // any cell with focus. No element-specific binding because + // Phase 1's roving tabindex moves focus around. + document.addEventListener('paste', onPaste); + document.addEventListener('copy', onCopy); + } + + // Auto-wire on bootstrap. table-mode only — the dispatcher hides + // form-mode in this bundle, but be defensive if both modes ever + // coexist on a page (test fixtures): attach unconditionally; the + // handler bails when there's no selected cell. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach, { once: true }); + } else { + attach(); + } + + app.modules.clipboard = { + parseTSV: parseTSV, + formatTSV: formatTSV, + applyPaste: applyPaste, + }; +})(window.tablesApp); diff --git a/tests/tables.spec.js b/tests/tables.spec.js index 79e90b6..ca2ece4 100644 --- a/tests/tables.spec.js +++ b/tests/tables.spec.js @@ -716,6 +716,185 @@ test.describe('tables/ — directory-of-YAML table view', () => { expect(invalidCells).toBe(2); }); + // --- Phase 4: copy/paste from Excel/Sheets (TSV) --------------------- + + test('Phase 4: parseTSV handles tabs, newlines, and quoted fields', async ({ page }) => { + // Unit-style test of the parser via the exposed clipboard module. + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + const result = await page.evaluate(() => { + const p = window.tablesApp.modules.clipboard.parseTSV; + return [ + p('a\tb\tc'), + p('a\tb\nc\td'), + p('a\t"line1\nline2"\tc'), + p('"with ""quotes"""\tplain'), + p('a\r\nb\r\nc'), + p(''), + ]; + }); + expect(result[0]).toEqual([['a', 'b', 'c']]); + expect(result[1]).toEqual([['a', 'b'], ['c', 'd']]); + expect(result[2]).toEqual([['a', 'line1\nline2', 'c']]); + expect(result[3]).toEqual([['with "quotes"', 'plain']]); + expect(result[4]).toEqual([['a'], ['b'], ['c']]); + expect(result[5]).toEqual([]); + }); + + test('Phase 4: paste single value into selected cell', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const titleCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + await titleCell.click(); + + // Drive applyPaste directly (Playwright's clipboard event + // simulation is browser-flag-gated; calling the module is the + // honest unit-test path). + await page.evaluate(() => { + const sel = window.tablesApp.state.selected; + window.tablesApp.modules.clipboard.applyPaste( + sel.row, sel.col, + [['Pasted single value']], + ); + window.tablesApp.repaint(); + }); + + await expect(titleCell).toContainText('Pasted single value'); + + // Draft buffer holds it under the right field. + const draftValue = await page.evaluate(() => { + const drafts = window.tablesApp.state.drafts; + const rowId = Object.keys(drafts)[0]; + return drafts[rowId].title; + }); + expect(draftValue).toBe('Pasted single value'); + }); + + test('Phase 4: paste 2x2 grid spills from anchor', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + // Anchor at row 0, title column. + const anchor = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + await anchor.click(); + + await page.evaluate(() => { + const sel = window.tablesApp.state.selected; + window.tablesApp.modules.clipboard.applyPaste( + sel.row, sel.col, + [ + ['t-r0', 'Acme'], + ['t-r1', 'Beta'], + ], + ); + window.tablesApp.repaint(); + }); + + // Row 0: title=t-r0, party=Acme. + await expect( + page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('title')) + ).toContainText('t-r0'); + await expect( + page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('party')) + ).toContainText('Acme'); + // Row 1: title=t-r1, party=Beta. + await expect( + page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('title')) + ).toContainText('t-r1'); + await expect( + page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('party')) + ).toContainText('Beta'); + }); + + test('Phase 4: paste coerces numeric/boolean values via row schema', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const priorityCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('priority')); + await priorityCell.click(); + await page.evaluate(() => { + const sel = window.tablesApp.state.selected; + window.tablesApp.modules.clipboard.applyPaste( + sel.row, sel.col, + [['4', 'true']], // priority + done + ); + window.tablesApp.repaint(); + }); + + const drafts = await page.evaluate(() => { + const d = window.tablesApp.state.drafts; + const rowId = Object.keys(d)[0]; + return d[rowId]; + }); + expect(drafts.priority).toBe(4); + expect(typeof drafts.priority).toBe('number'); + expect(drafts.done).toBe(true); + }); + + test('Phase 4: paste out-of-bounds drops cells silently with toast', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const lastRowLastCol = page.locator('#table-root tbody tr').nth(1) + .locator('[role="gridcell"]').last(); + await lastRowLastCol.click(); + + // Dispatch a real paste event so onPaste runs (which writes + // the toast). 3-row × 2-col TSV anchored at the last cell — + // every cell spills past the end of either rows or columns. + await page.evaluate(() => { + const dt = new DataTransfer(); + dt.setData('text/plain', 'x1\ty1\nx2\ty2\nx3\ty3'); + const ev = new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(ev); + }); + await expect(page.locator('#table-status')).toContainText('dropped'); + }); + + test('Phase 4: copy single cell writes value to clipboard', async ({ page, context }) => { + // Granting clipboard-read makes Chromium permit synthetic copy. + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const titleCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + await titleCell.click(); + + // Synthesize a copy event with a writable DataTransfer-like + // payload; assert the handler wrote the cell value into it. + const written = await page.evaluate(() => { + const ev = new ClipboardEvent('copy', { + clipboardData: new DataTransfer(), + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(ev); + return ev.clipboardData.getData('text/plain'); + }); + expect(written).toBe('Sample'); + }); + test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => { await setupSaveCapture(page); const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })]; diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 373baf3..c2d033b 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -932,7 +932,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-09 15:24:57 · e5bb7f2-dirty + v0.0.17-alpha · 2026-05-09 15:29:08 · cd751eb-dirty
@@ -3616,6 +3616,284 @@ body.help-open .app-header { }; })(window.tablesApp); +// clipboard.js — Phase 4 of editable-cell mode. +// +// Bidirectional clipboard interop with Excel / Google Sheets / any +// other spreadsheet that uses RFC-4180-ish TSV on the text/plain +// clipboard mime. +// +// Copy: when a single cell is selected, Ctrl/Cmd+C writes that +// cell's value as plain text. Range selection (Phase 5) extends +// this to a TSV rectangle. +// +// Paste: Ctrl/Cmd+V on the focused cell parses text/plain as TSV +// (tabs between columns, newlines between rows; embedded newlines +// or tabs are quoted with double-quotes; doubled "" escapes). +// +// - 1×1 clipboard into selected cell → writes that one cell. +// - N×M clipboard into selected cell → SPILLS from the anchor +// cell down/right to (anchor.row + N - 1, anchor.col + M - 1). +// Out-of-bounds cells are silently dropped (Excel convention). +// +// Each pasted cell goes through the same draft-buffer write path +// as a normal edit — the row-blur save trigger picks them up, +// and the per-cell schema-driven coercion (Phase 2) applies. +// Per-cell validation runs on the next save attempt; invalid +// cells get the red-corner mark. +(function (app) { + 'use strict'; + + function editor() { return app.modules.editor; } + + // --- TSV parsing -------------------------------------------------- + + // parseTSV(text) → string[][]. Honors RFC-4180-ish quoting: + // - A field surrounded by " can contain tabs, newlines, and + // literal " characters escaped as "". + // - An unquoted field ends at the next tab, newline, or end. + // - Bare \r is treated as part of \r\n (Windows line endings). + function parseTSV(text) { + const rows = []; + let row = []; + let field = ''; + let inQuotes = false; + const s = String(text == null ? '' : text); + + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuotes) { + if (ch === '"') { + if (s[i + 1] === '"') { + // Escaped quote inside a quoted field. + field += '"'; + i++; + } else { + // End of quoted field. + inQuotes = false; + } + } else { + field += ch; + } + continue; + } + if (ch === '"' && field === '') { + // Open quote — only at start of field. + inQuotes = true; + continue; + } + if (ch === '\t') { + row.push(field); + field = ''; + continue; + } + if (ch === '\n' || ch === '\r') { + // \r\n — consume the \n too. + if (ch === '\r' && s[i + 1] === '\n') i++; + row.push(field); + field = ''; + rows.push(row); + row = []; + continue; + } + field += ch; + } + // Trailing field (no terminator). + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + // Excel often appends a trailing empty row from the final \n; + // drop one trailing all-empty row to match that convention. + if (rows.length > 0) { + const last = rows[rows.length - 1]; + if (last.length === 1 && last[0] === '') rows.pop(); + } + return rows; + } + + // formatTSV(grid) → string. Reverse of parseTSV. Quotes any + // field containing tab, newline, or double-quote. + function formatTSV(grid) { + const lines = []; + for (let r = 0; r < grid.length; r++) { + const row = grid[r]; + const cells = []; + for (let c = 0; c < row.length; c++) { + cells.push(formatCell(row[c])); + } + lines.push(cells.join('\t')); + } + return lines.join('\n'); + } + + function formatCell(v) { + const s = (v == null) ? '' : String(v); + if (/[\t\n\r"]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + + // --- Apply paste -------------------------------------------------- + + function applyPaste(anchorRowIdx, anchorColIdx, grid) { + // grid is string[][]. Returns {applied: int, skipped: int}. + const ed = editor(); + const totalRows = visibleRowCount(); + const cols = (app.context && app.context.columns) || []; + const totalCols = cols.length; + let applied = 0, skipped = 0; + + for (let r = 0; r < grid.length; r++) { + const dstR = anchorRowIdx + r; + if (dstR >= totalRows) { skipped += grid[r].length; continue; } + const row = rowDataAtIndex(dstR); + if (!row) { skipped += grid[r].length; continue; } + for (let c = 0; c < grid[r].length; c++) { + const dstC = anchorColIdx + c; + if (dstC >= totalCols) { skipped++; continue; } + const col = cols[dstC]; + if (!col) { skipped++; continue; } + const newValue = coerceCell(grid[r][c], col, row); + ed.setDraft(ed.rowKey(row), col.field, newValue); + applied++; + } + } + return { applied: applied, skipped: skipped }; + } + + function visibleRowCount() { + return document.querySelectorAll('#table-root tbody > tr').length; + } + + function rowDataAtIndex(r) { + const tr = document.querySelectorAll('#table-root tbody > tr')[r]; + if (!tr) return null; + const rowId = tr.getAttribute('data-row-id'); + if (rowId == null) return null; + const all = (app.state && app.state.rows) || []; + for (let i = 0; i < all.length; i++) { + if (editor().rowKey(all[i]) === rowId) return all[i]; + } + return null; + } + + function coerceCell(raw, col, _row) { + // Phase 2's editor coerces values typed into a number/checkbox/ + // select widget. Pasted cells arrive as raw strings; coerce + // here so the draft holds the right JS type. Falls back to the + // raw string when coercion is ambiguous. + const fmt = col.format; + if (fmt === 'number' || fmt === 'integer' || isNumericSchema(col)) { + const n = Number(raw); + if (raw.trim() !== '' && !Number.isNaN(n)) return n; + } + if (isBooleanSchema(col)) { + const t = String(raw).trim().toLowerCase(); + if (t === 'true' || t === 'yes' || t === '1') return true; + if (t === 'false' || t === 'no' || t === '0' || t === '') return false; + } + return raw; + } + + function isNumericSchema(col) { + const s = propSchema(col); + return !!(s && (s.type === 'number' || s.type === 'integer')); + } + + function isBooleanSchema(col) { + const s = propSchema(col); + return !!(s && s.type === 'boolean'); + } + + function propSchema(col) { + const ctx = app.context || {}; + if (!ctx.rowSchema || !ctx.rowSchema.properties) return null; + return ctx.rowSchema.properties[col.field] || null; + } + + // --- Event handlers ---------------------------------------------- + + function onPaste(ev) { + if (!app.state || !app.state.selected) return; + if (app.state.editing) return; // input owns its own paste + const text = ev.clipboardData && ev.clipboardData.getData('text/plain'); + if (!text) return; + ev.preventDefault(); + const grid = parseTSV(text); + if (!grid.length) return; + const { row: r, col: c } = app.state.selected; + const result = applyPaste(r, c, grid); + // Trigger a re-paint so draft values display. + if (typeof app.repaint === 'function') app.repaint(); + if (result.skipped > 0) { + notifyToast( + 'Pasted ' + result.applied + ' cell' + plural(result.applied) + + '; ' + result.skipped + ' dropped (out of bounds)' + ); + } + } + + function onCopy(ev) { + if (!app.state || !app.state.selected) return; + if (app.state.editing) return; // input owns its own copy + const { row: r, col: c } = app.state.selected; + const row = rowDataAtIndex(r); + const cols = (app.context && app.context.columns) || []; + const col = cols[c]; + if (!row || !col) return; + const value = editor().effectiveCellValue(row, col); + ev.preventDefault(); + if (ev.clipboardData) { + ev.clipboardData.setData('text/plain', formatCell(value)); + } + } + + function plural(n) { return n === 1 ? '' : 's'; } + + function notifyToast(msg) { + // Cheap toast: write to #table-status, auto-clear after 4s. + // Coexists with save.js's stale-row prompt — just don't fire + // if a prompt is currently up. + const el = document.getElementById('table-status'); + if (!el) return; + if (el.classList.contains('table-status--prompt')) return; + el.textContent = msg; + el.hidden = false; + clearTimeout(notifyToast._t); + notifyToast._t = setTimeout(() => { + if (el.textContent === msg) { + el.hidden = true; + el.textContent = ''; + } + }, 4000); + } + + function attach() { + // Listen at the document level so paste events bubble from + // any cell with focus. No element-specific binding because + // Phase 1's roving tabindex moves focus around. + document.addEventListener('paste', onPaste); + document.addEventListener('copy', onCopy); + } + + // Auto-wire on bootstrap. table-mode only — the dispatcher hides + // form-mode in this bundle, but be defensive if both modes ever + // coexist on a page (test fixtures): attach unconditionally; the + // handler bails when there's no selected cell. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach, { once: true }); + } else { + attach(); + } + + app.modules.clipboard = { + parseTSV: parseTSV, + formatTSV: formatTSV, + applyPaste: applyPaste, + }; +})(window.tablesApp); + (function (app) { 'use strict';