use-grid-commands.ts16.3 KBView on GitHub 'use client';
/**
* Everything that turns a GESTURE on the grid into a WRITE: the keyboard model, the clipboard,
* and the position→cell translation both of them need.
*
* Split out of `TableGrid` because it is a different job from laying the grid out. The grid
* renders columns, rows and a virtual window; this decides what ArrowDown, Cmd+C, Delete and a
* pasted block mean. They meet at exactly two places — the selection store and the row list —
* and keeping the seam that narrow is what makes either half readable.
*
* ── Positions in, rowIds out ──
*
* The selection addresses cells by `{ row, col }` (see `cell-selection.ts`) while the Y layer
* addresses them by `{ rowId, columnKey }`. `applyEdits` is the ONLY translation between the
* two, which makes it the one place three invariants can be enforced at once:
* - a LINKED cell is written through to its deal, and a merely DERIVED one is not written at
* all (its value is recomputed from a source nothing here can change, so an edit would
* survive only until the next refresh — a silent discard wearing the costume of an edit).
* Which it is comes from `resolveBoundWrite`, and the rule holds for every gesture: a
* paste, a Delete over a range and a double-click all mean the same thing to a linked cell,
* because "linked" is a property of the column rather than of how you touched it.
* - a write past the last materialized row VIVIFIES the rows below it, so a block lands where
* it was aimed rather than sliding up to the end of the data
* - every cell of one gesture lands in ONE transaction, so a 50×5 paste is one undo step
* - the linked writes of one gesture are ORDERED PER DEAL — see `sendGroupedWrites`
*/
import { useCallback } from 'react';
import type { ClipboardEvent, KeyboardEvent } from 'react';
import { TABLE_COLUMN_TYPE, type TableColumn } from '@zero/server/table';
import { rowMapToRow } from '@zero/server/table/ydoc';
import type { BoundWriteTarget } from './bound-writes';
import type { CellSelectionStore } from './cell-selection';
import { isCheckedValue } from './cell-values';
import type { BoundCellWriter } from './use-bound-cell-write';
import { fromTsv, toTsv } from './table-clipboard';
import type { TableCellEdit, TableRowHandle } from './useYTable';
/** One cell write addressed by POSITION — the form every gesture below produces. */
interface PositionedEdit {
row: number;
col: number;
value: string;
}
const ARROW_DELTA: Record<string, { row: number; col: number } | undefined> = {
ArrowUp: { row: -1, col: 0 },
ArrowDown: { row: 1, col: 0 },
ArrowLeft: { row: 0, col: -1 },
ArrowRight: { row: 0, col: 1 },
};
/** Rows an unmodified PageUp/PageDown travels. */
const PAGE_ROWS = 20;
/** One linked cell's write, as `applyEdits` collects it before any request goes out. */
interface LinkedWrite {
target: BoundWriteTarget;
value: string;
revert: () => void;
}
/**
* Send one gesture's linked writes: in ORDER within a deal, in PARALLEL across deals.
*
* One gesture can touch several bound cells of the SAME conversation — Stage and Priority on
* one row, or a fill-down over a column whose rows point at the same deal. Each cell is its own
* `crm.updateConversation`, and that route holds an optimistic lock on the conversation's
* `updated_at` (`updateConversationFieldsAndWorkingMemory`): fired together, both calls read the
* same timestamp, the first commits, and the second matches no row and comes back CONFLICT —
* "please retry" on an edit nobody was competing for. The user then watches part of their paste
* revert.
*
* Awaiting within a deal is what fixes it: each call re-reads the timestamp the previous one
* wrote. Deals are independent, so they are not made to wait on each other — a 50-row paste
* across 50 deals still goes out at once.
*/
async function sendGroupedWrites(linked: LinkedWrite[], onWriteBound: BoundCellWriter): Promise<void> {
const byConversation = new Map<string, LinkedWrite[]>();
for (const write of linked) {
const group = byConversation.get(write.target.conversationId);
if (group) group.push(write);
else byConversation.set(write.target.conversationId, [write]);
}
await Promise.all(
[...byConversation.values()].map(async (group) => {
for (const write of group) await onWriteBound(write.target, write.value, write.revert);
}),
);
}
export interface GridCommandsOptions {
selection: CellSelectionStore;
/** VISIBLE columns, in order — the same list the selection's `col` indexes. */
columns: TableColumn[];
rows: TableRowHandle[];
setCells: (edits: TableCellEdit[]) => void;
addRows: (rows: Array<Record<string, string>>) => string[];
undo: () => void;
redo: () => void;
/** Scroll the active cell into view. Owned by the grid, which has the virtualizer. */
revealActive: () => void;
/** Where a bound column's edit goes for one row, or null if nowhere. See `bound-writes.ts`. */
resolveBoundWrite: (column: TableColumn, cells: Record<string, string>) => BoundWriteTarget | null;
onWriteBound: BoundCellWriter;
}
export interface GridCommands {
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
onCopy: (event: ClipboardEvent<HTMLDivElement>) => void;
onCut: (event: ClipboardEvent<HTMLDivElement>) => void;
onPaste: (event: ClipboardEvent<HTMLDivElement>) => void;
}
export function useGridCommands({
selection,
columns,
rows,
setCells,
addRows,
undo,
redo,
revealActive,
resolveBoundWrite,
onWriteBound,
}: GridCommandsOptions): GridCommands {
const readCellAt = useCallback(
(row: number, col: number): string => {
const handle = rows[row];
const column = columns[col];
if (!handle || !column) return '';
return rowMapToRow(handle.map).cells[column.key] ?? '';
},
[rows, columns],
);
/** The write target for a bound cell at a POSITION, or null when it has none. */
const boundTargetAt = useCallback(
(row: number, col: number): BoundWriteTarget | null => {
const handle = rows[row];
const column = columns[col];
if (!handle || !column?.binding) return null;
return resolveBoundWrite(column, rowMapToRow(handle.map).cells);
},
[rows, columns, resolveBoundWrite],
);
const applyEdits = useCallback(
(edits: PositionedEdit[]) => {
const existing: TableCellEdit[] = [];
/** Cells landing past the last materialized row, keyed by the row index they land in. */
const vivified = new Map<number, Record<string, string>>();
/** Linked cells, sent on after the local transaction — one mutation each. */
const linked: LinkedWrite[] = [];
for (const edit of edits) {
const column = columns[edit.col];
if (!column) continue;
const handle = rows[edit.row];
// A heading row holds ONLY `_section`. Writing a declared column into one builds exactly
// the hybrid row `assertSectionExclusivity` refuses on the server — and because this
// writes the Y.Doc directly, that guard never sees it. The grid keeps rendering the
// heading, so the pasted values are invisible here while being real in the markdown
// mirror, the Excel export and every `read`. Skip the row rather than corrupt it.
if (handle?.isSection) continue;
if (column.binding) {
// A blank row references nothing, so a bound cell in one can never be linked.
if (!handle) continue;
const cells = rowMapToRow(handle.map).cells;
const target = resolveBoundWrite(column, cells);
if (!target) continue;
const previous = cells[column.key] ?? '';
existing.push({ rowId: handle.rowId, columnKey=[redacted], value: edit.value });
linked.push({
target,
value: edit.value,
revert: () =>
setCells([{ rowId: handle.rowId, columnKey=[redacted], value: previous }]),
});
continue;
}
if (handle) {
existing.push({ rowId: handle.rowId, columnKey=[redacted], value: edit.value });
continue;
}
const bucket = vivified.get(edit.row) ?? {};
bucket[column.key] = edit.value;
vivified.set(edit.row, bucket);
}
if (existing.length > 0) setCells(existing);
if (vivified.size > 0) {
const lastRow = Math.max(...vivified.keys());
const fresh: Array<Record<string, string>> = [];
for (let row = rows.length; row <= lastRow; row++) fresh.push(vivified.get(row) ?? {});
addRows(fresh);
}
// After the local write, so the grid has already painted every cell of the gesture
// before the first request goes out.
void sendGroupedWrites(linked, onWriteBound);
},
[rows, columns, setCells, addRows, resolveBoundWrite, onWriteBound],
);
/**
* Open the active cell. A checkbox has nothing to type into, so Enter toggles it in place
* rather than mounting an editor that would only ever hold `true` or `false`.
*/
const openActiveCell = useCallback(
(seed?: string) => {
const active = selection.getActive();
const column = active ? columns[active.col] : undefined;
if (!active || !column) return;
// A linked cell opens like any other; a merely derived one has nothing to open into.
if (column.binding && !boundTargetAt(active.row, active.col)) return;
if (column.type === TABLE_COLUMN_TYPE.CHECKBOX) {
const checked = isCheckedValue(readCellAt(active.row, active.col));
applyEdits([{ ...active, value: checked ? 'false' : 'true' }]);
return;
}
selection.beginEdit(active, seed);
},
[selection, columns, applyEdits, readCellAt, boundTargetAt],
);
const clearSelectedCells = useCallback(() => {
const rect = selection.getRect();
if (!rect) return;
const edits: PositionedEdit[] = [];
for (let row = rect.top; row <= rect.bottom; row++) {
// Only materialized rows hold anything to clear; a blank row is already empty, and
// vivifying one to write `''` into it would create a row out of a delete.
if (!rows[row]) continue;
for (let col = rect.left; col <= rect.right; col++) edits.push({ row, col, value: '' });
}
applyEdits(edits);
}, [selection, rows, applyEdits]);
// ── Keyboard ──
//
// Bound to the grid root rather than the window: the grid has no other editor to compete
// with, but hijacking these globally would reach the chat composer and every other input on
// the page. While a cell is OPEN the editor owns the keyboard entirely, and this steps aside
// — the editors also stop propagation, because the DOM order otherwise lets a committing
// Enter reach this handler after the edit has already closed.
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (selection.getEditing()) return;
const extend = event.shiftKey;
if (event.metaKey || event.ctrlKey) {
const key=[redacted];
if (key === 'z') {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
return;
}
if (key === 'a') {
event.preventDefault();
selection.selectAll();
return;
}
const jump = ARROW_DELTA[event.key];
if (jump) {
event.preventDefault();
selection.moveToEdge(jump.row, jump.col, { extend });
revealActive();
}
// Copy, cut and paste deliberately fall through to their native events, which carry
// the `clipboardData` this grid reads and writes.
return;
}
const delta = ARROW_DELTA[event.key];
if (delta) {
event.preventDefault();
selection.move(delta.row, delta.col, { extend });
revealActive();
return;
}
switch (event.key) {
case 'Tab':
event.preventDefault();
selection.move(0, extend ? -1 : 1);
revealActive();
return;
case 'Enter':
case 'F2':
event.preventDefault();
openActiveCell();
return;
case 'Escape':
event.preventDefault();
selection.clear();
return;
case 'Backspace':
case 'Delete':
event.preventDefault();
clearSelectedCells();
return;
case 'Home':
event.preventDefault();
selection.moveToEdge(0, -1, { extend });
revealActive();
return;
case 'End':
event.preventDefault();
selection.moveToEdge(0, 1, { extend });
revealActive();
return;
case 'PageUp':
event.preventDefault();
selection.move(-PAGE_ROWS, 0, { extend });
revealActive();
return;
case 'PageDown':
event.preventDefault();
selection.move(PAGE_ROWS, 0, { extend });
revealActive();
return;
default:
break;
}
// Typing over a selected cell opens it holding what was typed, which is how every
// spreadsheet behaves and the only reason `beginEdit` carries a seed at all.
if (event.key.length === 1 && !event.altKey) {
event.preventDefault();
openActiveCell(event.key);
}
},
[selection, redo, undo, revealActive, openActiveCell, clearSelectedCells],
);
// ── Clipboard ──
/** The selected rectangle as TSV — the grammar Excel and Sheets exchange. */
const selectionAsTsv = useCallback((): string => {
const rect = selection.getRect();
if (!rect) return '';
const grid: string[][] = [];
for (let row = rect.top; row <= rect.bottom; row++) {
const line: string[] = [];
for (let col = rect.left; col <= rect.right; col++) line.push(readCellAt(row, col));
grid.push(line);
}
return toTsv(grid);
}, [selection, readCellAt]);
const onCopy = useCallback(
(event: ClipboardEvent<HTMLDivElement>) => {
if (selection.getEditing()) return;
const tsv = selectionAsTsv();
if (!tsv) return;
event.preventDefault();
// The RAW values, tokens included, so pasting between two Cedar tables preserves the
// references rather than flattening them to the text of an id.
event.clipboardData.setData('text/plain', tsv);
},
[selection, selectionAsTsv],
);
const onCut = useCallback(
(event: ClipboardEvent<HTMLDivElement>) => {
onCopy(event);
// Only cut what was actually copied: a refused copy (nothing selected, a cell open)
// must not still delete.
if (event.defaultPrevented) clearSelectedCells();
},
[onCopy, clearSelectedCells],
);
const onPaste = useCallback(
(event: ClipboardEvent<HTMLDivElement>) => {
if (selection.getEditing()) return;
const rect = selection.getRect();
const active = selection.getActive();
if (!rect || !active) return;
const text = event.clipboardData.getData('text/plain');
if (!text) return;
event.preventDefault();
const pasted = fromTsv(text);
const edits: PositionedEdit[] = [];
if (pasted.length === 1 && pasted[0]?.length === 1) {
// One value into a range fills the range — the "apply this to all of them" gesture.
const value = pasted[0]?.[0] ?? '';
for (let row = rect.top; row <= rect.bottom; row++) {
for (let col = rect.left; col <= rect.right; col++) edits.push({ row, col, value });
}
applyEdits(edits);
return;
}
let widest = 0;
pasted.forEach((line, rowOffset) => {
widest = Math.max(widest, line.length);
line.forEach((value, colOffset) => {
const col = active.col + colOffset;
// Columns past the last one are dropped rather than created: a paste should not
// silently reshape the schema.
if (col >= columns.length) return;
edits.push({ row: active.row + rowOffset, col, value });
});
});
applyEdits(edits);
// Leave the pasted block selected, so it is obvious what landed and Cmd+Z has a target
// the eye can already see.
selection.select(active);
selection.select(
{
row: active.row + pasted.length - 1,
col: Math.min(active.col + widest - 1, columns.length - 1),
},
{ extend: true },
);
},
[selection, columns.length, applyEdits],
);
return { onKeyDown, onCopy, onCut, onPaste };
}