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 };
}