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>
This commit is contained in:
ZDDC 2026-05-09 10:30:05 -05:00
parent cd751eb604
commit 8e703dc61a
4 changed files with 736 additions and 1 deletions

View file

@ -42,6 +42,7 @@ concat_files \
"js/sort.js" \ "js/sort.js" \
"js/editor.js" \ "js/editor.js" \
"js/save.js" \ "js/save.js" \
"js/clipboard.js" \
"js/render.js" \ "js/render.js" \
"js/main.js" \ "js/main.js" \
"../form/js/app.js" \ "../form/js/app.js" \

277
tables/js/clipboard.js Normal file
View file

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

View file

@ -716,6 +716,185 @@ test.describe('tables/ — directory-of-YAML table view', () => {
expect(invalidCells).toBe(2); 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 }) => { test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => {
await setupSaveCapture(page); await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })]; const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];

View file

@ -932,7 +932,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:24:57 · e5bb7f2-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:29:08 · cd751eb-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3616,6 +3616,284 @@ body.help-open .app-header {
}; };
})(window.tablesApp); })(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) { (function (app) {
'use strict'; 'use strict';