ZDDC/tables/js/clipboard.js
ZDDC 8e703dc61a feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets
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>
2026-05-09 10:30:05 -05:00

277 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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