// 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);