Bidirectional clipboard interop with Excel, Google Sheets, and any
other spreadsheet that uses RFC-4180-ish TSV on the text/plain
clipboard mime. Pasted cells write straight into the draft buffer
the same way per-key edits do; row-level save (Phase 3) picks them
up on the next row-blur with the same If-Match optimistic-
concurrency flow.
TSV parser (clipboard.js parseTSV):
- Tabs separate columns, \\n / \\r\\n separate rows.
- Quoted fields ("...") may contain tabs and newlines verbatim.
- Doubled \\"\\" inside a quoted field escapes a literal \\".
- Trailing empty row from a final \\n is dropped (Excel sends
this; matching the convention avoids a phantom blank row at
the end of every paste).
Apply-paste (clipboard.js applyPaste):
- Anchor = currently selected cell.
- 1×1 clipboard into selection → writes that one cell.
- N×M clipboard → SPILLS from the anchor down/right to
(anchor.row + N - 1, anchor.col + M - 1). Cells past the end
of either axis are silently dropped with a toast count.
- Each pasted value goes through coerceCell, which checks the
column's row-schema property type:
* number / integer → Number()
* boolean → "true"|"yes"|"1" → true; "false"|
"no"|"0"|"" → false
* everything else → raw string
Drafts hold the right JS type so the row-PUT body matches the
JSON Schema the server validates against.
Copy (clipboard.js onCopy):
- Single-cell selection: Ctrl/Cmd+C writes the cell's
effectiveCellValue (draft if dirty, else stored) as text/plain
via formatCell (RFC-4180 quoting on tab/newline/quote).
- Range copy is Phase 5 (depends on range-selection landing).
Event wiring:
- document.addEventListener('paste'/'copy') so events bubble
from any cell with focus. Phase 1's roving tabindex moves
focus around; per-cell binding would have to be re-applied
after every paint.
- onPaste bails when an editor input is mounted (the input
owns its own paste — typing into a cell editor that was just
populated with a chunk of TSV would be a footgun).
Toast for partial pastes:
When applyPaste skipped any cells, a small message in
#table-status: "Pasted N cells; M dropped (out of bounds)".
Auto-clears after 4s. Coexists with Phase 3's stale-row prompt
(toast doesn't fire if a prompt is already up; prompt outranks
toast).
Tests (6 new Phase 4 specs, total 37 in tests/tables.spec.js):
- parseTSV handles tabs, newlines, and quoted fields — covers
the parser edge cases including embedded \\n inside "..." and
doubled "" escapes.
- paste single value into selected cell — the 1×1 path; verifies
the draft buffer entry.
- paste 2×2 grid spills from anchor — the N×M spill semantic.
- paste coerces numeric/boolean values via row schema —
verifies the draft holds typeof===number for an integer column
and === true for a boolean column.
- paste out-of-bounds drops cells silently with toast — drives
via dispatched ClipboardEvent('paste') (the only way to
exercise onPaste end-to-end including the toast).
- copy single cell writes value to clipboard — synthesizes a
ClipboardEvent('copy') with a writable DataTransfer payload
and asserts the cell value lands in text/plain.
Bundle size: 134 KB → 138 KB.
Files:
- tables/js/clipboard.js (new) — parseTSV, formatTSV,
applyPaste, onPaste/onCopy, toast helper.
- tables/build.sh — clipboard.js in concat list.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
// 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);
|