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>