ZDDC/tables/js/clipboard.js
2026-06-11 13:32:31 -05:00

296 lines
11 KiB
JavaScript
Raw Permalink 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, created: int}.
// When the paste extends past the last existing row, the
// add-row module creates new draft rows on the fly so an Excel
// copy lands as a complete data set, not a clipped one. Each
// new row will save on its own row-blur (POST to form-create).
const ed = editor();
const totalRows = visibleRowCount();
const cols = (app.context && app.context.columns) || [];
const totalCols = cols.length;
const addRow = app.modules.addRow;
let applied = 0, skipped = 0, created = 0;
for (let r = 0; r < grid.length; r++) {
const dstR = anchorRowIdx + r;
let row = null;
if (dstR < totalRows) {
row = rowDataAtIndex(dstR);
} else if (addRow && typeof addRow.createSilent === 'function') {
addRow.createSilent();
created++;
// After createSilent the new row is at the end of
// state.rows but the DOM hasn't repainted yet — pull
// straight from state.rows to address it.
const all = (app.state && app.state.rows) || [];
row = all[all.length - 1];
}
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, created: created };
}
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();
let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
if (result.created > 0) {
msg += ' into ' + result.created + ' new row' + plural(result.created);
}
if (result.skipped > 0) {
msg += '; ' + result.skipped + ' dropped (out of bounds)';
}
if (result.created > 0 || result.skipped > 0) {
notifyToast(msg);
}
}
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);