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