296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
// 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);
|