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:
parent
cd751eb604
commit
8e703dc61a
4 changed files with 736 additions and 1 deletions
|
|
@ -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
277
tables/js/clipboard.js
Normal 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);
|
||||||
|
|
@ -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' })];
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue