cell-selection.ts15.4 KBView on GitHub
'use client';

/**
 * The grid's selection: which cell is active, which rectangle is selected, and which cell is
 * open for editing.
 *
 * ── Why this is an external store and not React state ──
 *
 * Selection changes on every arrow key and every drag pixel. Holding it in `TableGrid` state
 * would re-render the grid — and therefore reconsider every visible row — at that rate, which
 * is exactly the granularity `useYTable` and the memoized `TableRowView` exist to protect (see
 * apps/mail/docs/wiki/table-grid.md). So selection lives in a plain store, cells subscribe to
 * it individually through `useSyncExternalStore`, and each cell's snapshot is a NUMBER: a
 * bitmask of "am I selected / active / editing / on which edge of the range". Moving the
 * selection from B2 to B3 changes the snapshot of exactly two cells; every other subscriber
 * reads back an identical number and React bails out without rendering it.
 *
 * That is also why the snapshot is a bitmask rather than an object — an object would be a new
 * identity on every notification, which would re-render every cell in the grid and quietly
 * undo the whole design.
 *
 * ── Positions are indices, not ids ──
 *
 * A cell is addressed by `{ row, col }` — the row's index in the Y.Array and the column's
 * index in the VISIBLE column list. Indices rather than `rowId:columnKey` because the whole
 * job here is geometry: "the cell below", "the rectangle between these two", "the last column".
 * The consequence is the spreadsheet one: inserting a row above the selection shifts what is
 * selected, exactly as it does in Sheets.
 */

import { createContext, useCallback, useContext, useSyncExternalStore } from 'react';

export interface CellPosition {
  /** Index into the row list, including the trailing blank run. */
  row: number;
  /** Index into the VISIBLE columns — hidden columns are not addressable. */
  col: number;
}

/** Inclusive rectangle between the anchor and the focus, normalized. */
export interface CellRect {
  top: number;
  left: number;
  bottom: number;
  right: number;
}

export interface GridBounds {
  rowCount: number;
  colCount: number;
  /**
   * True for a row that is ONE cell spanning the whole width — a section heading (see
   * `RESERVED_COLUMN.SECTION`).
   *
   * The selection is a rectangle of indices, and a spanning row is the one thing that is not
   * rectangular. Rather than carve it out of the geometry — which would leave ArrowDown from
   * the row above landing nowhere — the row keeps its place and its column is PINNED to 0 by
   * `clamp`. Every movement path goes through `clamp`, so this one predicate is the whole of
   * it: arrowing right along a heading stays put instead of walking into columns that have no
   * DOM node, and arrowing down out of one lands in column 0 of the next row.
   *
   * Optional because a table with no headings should not have to say so.
   */
  isSpanRow?: (row: number) => boolean;
}

/** One cell's snapshot. See the module comment for why this is a number. */
export const CELL_STATE = {
  NONE: 0,
  /** Inside the selected rectangle. */
  SELECTED: 1 << 0,
  /** The one cell keyboard movement extends from, and the one an edit opens on. */
  ACTIVE: 1 << 1,
  /** Open for editing. Implies ACTIVE. */
  EDITING: 1 << 2,
  EDGE_TOP: 1 << 3,
  EDGE_RIGHT: 1 << 4,
  EDGE_BOTTOM: 1 << 5,
  EDGE_LEFT: 1 << 6,
} as const;

/** Where focus goes when an edit commits. Mirrors `CellCommitMove`. */
export type EditExit = 'down' | 'right' | 'up' | 'left' | null;

export class CellSelectionStore {
  private listeners = new Set<() => void>();
  private anchor: CellPosition | null = null;
  private focus: CellPosition | null = null;
  private editing: CellPosition | null = null;
  /**
   * The character that OPENED the editor, when an edit began by typing rather than by Enter or
   * a click. Spreadsheets replace the cell with what you typed; without carrying the seed the
   * keystroke that started the edit would be swallowed by the mount.
   */
  private editSeed: string | null = null;
  private bounds: GridBounds = { rowCount: 0, colCount: 0 };
  private dragging = false;

  // ── Subscription ──

  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  };

  private notify(): void {
    for (const listener of this.listeners) listener();
  }

  // ── Reads ──

  /** The bitmask for one cell. The per-cell `getSnapshot`. */
  stateAt = (row: number, col: number): number => {
    const rect = this.getRect();
    if (!rect) return CELL_STATE.NONE;
    if (row < rect.top || row > rect.bottom || col < rect.left || col > rect.right) {
      return CELL_STATE.NONE;
    }
    let state = CELL_STATE.SELECTED;
    if (this.focus && this.focus.row === row && this.focus.col === col) {
      state |= CELL_STATE.ACTIVE;
      if (this.editing) state |= CELL_STATE.EDITING;
    }
    if (row === rect.top) state |= CELL_STATE.EDGE_TOP;
    if (row === rect.bottom) state |= CELL_STATE.EDGE_BOTTOM;
    if (col === rect.left) state |= CELL_STATE.EDGE_LEFT;
    if (col === rect.right) state |= CELL_STATE.EDGE_RIGHT;
    return state;
  };

  /** Row index of the cell being edited, or -1. A row uses it to lift itself above its siblings. */
  editingRow = (): number => this.editing?.row ?? -1;

  /** `row:col` of the active cell, for `aria-activedescendant`. Empty when nothing is selected. */
  activeDescendant = (): string => (this.focus ? `${this.focus.row}:${this.focus.col}` : '');

  getRect(): CellRect | null {
    if (!this.anchor || !this.focus) return null;
    return {
      top: Math.min(this.anchor.row, this.focus.row),
      bottom: Math.max(this.anchor.row, this.focus.row),
      left: Math.min(this.anchor.col, this.focus.col),
      right: Math.max(this.anchor.col, this.focus.col),
    };
  }

  getActive(): CellPosition | null {
    return this.focus;
  }

  getEditing(): CellPosition | null {
    return this.editing;
  }

  /** True while more than one cell is selected — the test for "this is a range operation". */
  hasRange(): boolean {
    const rect = this.getRect();
    return !!rect && (rect.top !== rect.bottom || rect.left !== rect.right);
  }

  /**
   * The character this edit was opened by, if any.
   *
   * Read, never consumed: an editor takes it as its INITIAL state, so it is applied exactly
   * once by construction. Clearing it on read instead would make the store mutate during a
   * render, which under StrictMode's double render means the second one sees no seed and the
   * keystroke that opened the cell disappears.
   */
  getEditSeed(): string | null {
    return this.editSeed;
  }

  isDragging(): boolean {
    return this.dragging;
  }

  // ── Writes ──

  setBounds(bounds: GridBounds): void {
    this.bounds = bounds;
    // A selection that now points past the end (a column deleted, rows removed) is clamped
    // rather than dropped: the user's place in the table survives the edit.
    if (!this.focus || !this.anchor) return;
    const isRange = !samePosition(this.focus, this.anchor);
    const clampedFocus = this.clamp(this.focus, { range: isRange });
    const clampedAnchor = this.clamp(this.anchor, { range: isRange });
    if (samePosition(clampedFocus, this.focus) && samePosition(clampedAnchor, this.anchor)) return;
    this.focus = clampedFocus;
    this.anchor = clampedAnchor;
    if (this.editing) this.editing = clampedFocus;
    this.notify();
  }

  /** Click, or a programmatic jump. `extend` is the shift-click / shift-arrow behaviour. */
  select(position: CellPosition, options: { extend?: boolean } = {}): void {
    const next = this.clamp(position, { range: options.extend === true && this.anchor !== null });
    this.editing = null;
    this.editSeed = null;
    if (options.extend && this.anchor) {
      this.focus = next;
    } else {
      this.anchor = next;
      this.focus = next;
    }
    this.notify();
  }

  /** Move the active cell by a delta, clamped to the grid. `extend` keeps the anchor put. */
  move(deltaRow: number, deltaCol: number, options: { extend?: boolean } = {}): void {
    const from = this.focus ?? { row: 0, col: 0 };
    this.select({ row: from.row + deltaRow, col: from.col + deltaCol }, options);
  }

  /** Cmd+arrow: jump to the far edge in that direction. */
  moveToEdge(deltaRow: number, deltaCol: number, options: { extend?: boolean } = {}): void {
    const from = this.focus ?? { row: 0, col: 0 };
    this.select(
      {
        row: deltaRow === 0 ? from.row : deltaRow > 0 ? this.bounds.rowCount - 1 : 0,
        col: deltaCol === 0 ? from.col : deltaCol > 0 ? this.bounds.colCount - 1 : 0,
      },
      options,
    );
  }

  selectAll(): void {
    if (this.bounds.rowCount === 0 || this.bounds.colCount === 0) return;
    this.editing = null;
    this.anchor = { row: 0, col: 0 };
    this.focus = { row: this.bounds.rowCount - 1, col: this.bounds.colCount - 1 };
    this.notify();
  }

  clear(): void {
    if (!this.anchor && !this.focus && !this.editing) return;
    this.anchor = null;
    this.focus = null;
    this.editing = null;
    this.editSeed = null;
    this.notify();
  }

  /**
   * Open a cell for editing. Collapses any range to that one cell — an edit has exactly one
   * target, and leaving a rectangle highlighted around it would say otherwise.
   */
  beginEdit(position?: CellPosition, seed?: string): void {
    const target = position ? this.clamp(position) : this.focus;
    if (!target) return;
    this.anchor = target;
    this.focus = target;
    this.editing = target;
    this.editSeed = seed ?? null;
    this.notify();
  }

  /** Close the editor, optionally moving the selection the way a commit key implies. */
  endEdit(exit: EditExit = null): void {
    if (!this.editing) return;
    this.editing = null;
    this.editSeed = null;
    const delta = EXIT_DELTA[exit ?? 'none'];
    if (delta && this.focus) {
      const next = this.clamp({ row: this.focus.row + delta.row, col: this.focus.col + delta.col });
      this.anchor = next;
      this.focus = next;
    }
    this.notify();
  }

  // ── Drag-select ──

  startDrag(position: CellPosition): void {
    this.dragging = true;
    this.select(position);
  }

  dragTo(position: CellPosition): void {
    if (!this.dragging) return;
    // A drag is a rectangle just as shift-extend is.
    const next = this.clamp(position, { range: this.anchor !== null });
    if (this.focus && samePosition(next, this.focus)) return;
    this.focus = next;
    this.notify();
  }

  endDrag(): void {
    this.dragging = false;
  }

  /**
   * `spanning` pins a span row's column, because such a row has exactly one cell whatever
   * column the caller aimed at. That is right for a single-cell selection and WRONG while a
   * rectangle is being extended: pinning the focus to column 0 widens the rect to
   * `0..anchor.col`, so shift-extending from Amount onto a heading silently selects the three
   * columns to its left — and the next Delete clears all of them. When the selection is a
   * range, leave the column alone and let the renderer draw the span row as one cell.
   */
  private clamp(position: CellPosition, options: { range?: boolean } = {}): CellPosition {
    const row = clampIndex(position.row, this.bounds.rowCount);
    const col = clampIndex(position.col, this.bounds.colCount);
    if (!options.range && this.bounds.isSpanRow?.(row)) return { row, col: 0 };
    return { row, col };
  }
}

const EXIT_DELTA: Record<string, { row: number; col: number } | undefined> = {
  down: { row: 1, col: 0 },
  up: { row: -1, col: 0 },
  right: { row: 0, col: 1 },
  left: { row: 0, col: -1 },
  none: undefined,
};

function clampIndex(value: number, count: number): number {
  if (count <= 0) return 0;
  return Math.max(0, Math.min(value, count - 1));
}

function samePosition(a: CellPosition, b: CellPosition): boolean {
  return a.row === b.row && a.col === b.col;
}

/**
 * The selection outline, as a box-shadow.
 *
 * Drawn with insets rather than borders because a border would change the cell's box and shift
 * its text by two pixels the moment it was selected. Only the EDGES of the rectangle are
 * stroked, so a multi-cell range reads as one region instead of a grid of little boxes.
 */
export function selectionBoxShadow(state: number): string | undefined {
  if (state & CELL_STATE.ACTIVE) return 'inset 0 0 0 2px var(--action)';
  if (!(state & CELL_STATE.SELECTED)) return undefined;
  const edges: string[] = [];
  if (state & CELL_STATE.EDGE_TOP) edges.push('inset 0 2px 0 0 var(--action)');
  if (state & CELL_STATE.EDGE_BOTTOM) edges.push('inset 0 -2px 0 0 var(--action)');
  if (state & CELL_STATE.EDGE_LEFT) edges.push('inset 2px 0 0 0 var(--action)');
  if (state & CELL_STATE.EDGE_RIGHT) edges.push('inset -2px 0 0 0 var(--action)');
  return edges.length > 0 ? edges.join(', ') : undefined;
}

// ── React wiring ──

const CellSelectionContext = createContext<CellSelectionStore | null>(null);

export const CellSelectionProvider = CellSelectionContext.Provider;

/**
 * The grid's store. Returns a detached store outside a provider rather than throwing, so a
 * cell can be rendered in isolation (a test, a preview) without a grid around it.
 */
export function useCellSelectionStore(): CellSelectionStore {
  return useContext(CellSelectionContext) ?? DETACHED_STORE;
}

const DETACHED_STORE = new CellSelectionStore();

/** One cell's selection bitmask. The subscription that keeps selection out of React state. */
export function useCellState(row: number, col: number): number {
  const store = useCellSelectionStore();
  const getSnapshot = useCallback(() => store.stateAt(row, col), [store, row, col]);
  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

/**
 * True when the editing cell is in this row. A row uses it to raise its stacking order, so the
 * popover editor of a clipped cell paints over the rows BELOW it — which are later siblings in
 * the absolutely-positioned virtual window, and would otherwise cover it.
 */
export function useRowHasEditingCell(row: number): boolean {
  const store = useCellSelectionStore();
  const getSnapshot = useCallback(() => store.editingRow() === row, [store, row]);
  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

/**
 * `aria-activedescendant` for the grid root.
 *
 * Keyboard focus stays on the grid container instead of roving between cells, which is what
 * lets a cell be a plain div rather than a tab stop — so this is the pointer a screen reader
 * follows instead. Undefined when nothing is selected, so the attribute is absent rather than
 * pointing at an element that does not exist.
 */
export function useActiveDescendant(gridId: string, store: CellSelectionStore): string | undefined {
  // The store is passed in rather than read from context because the ONE caller is the grid
  // itself — the component that renders the provider, and therefore the one component that
  // cannot see it. Read from context this hook silently subscribed to the detached fallback
  // store and the attribute never moved off the first cell.
  const getSnapshot = useCallback(() => store.activeDescendant(), [store]);
  const raw = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
  if (!raw) return undefined;
  const [row = '0', col = '0'] = raw.split(':');
  return cellDomId(gridId, Number(row), Number(col));
}

/** The DOM id of a cell, shared by the cell and by `aria-activedescendant`. */
export function cellDomId(gridId: string, row: number, col: number): string {
  return `${gridId}-cell-${row}-${col}`;
}