import { action, computed, makeObservable, observable } from 'mobx'; import type { Cell, CellBorder } from './types'; import type { FiltersMap } from '@wix/bex-core'; import type { EditableTableState } from './EditableTableState'; import { cellKey, parseCellKey, noBorder } from './utils'; import { EditTrigger } from '../../components/EditableTable/types'; type Direction = 'up' | 'down' | 'left' | 'right'; type CommitHandler = (editingValue: any, cell: Cell) => void; export class CellInteractionState { focusedCell: Cell | null = null; editingCell: Cell | null = null; editingValue: any = undefined; editTrigger: EditTrigger | null = null; selectedCells = new Set(); selectionAnchor: Cell | null = null; focusedCellElement: HTMLElement | null | undefined = null; /** True while the user is performing a mouse sweep selection. */ isSelecting = false; private readonly _parent: EditableTableState; private readonly _commitHandler: CommitHandler; constructor(commitHandler: CommitHandler, parent: EditableTableState) { this._commitHandler = commitHandler; this._parent = parent; makeObservable(this, { focusedCell: observable.ref, editingCell: observable.ref, editingValue: observable.ref, editTrigger: observable.ref, selectedCells: observable, selectionAnchor: observable.ref, isSelecting: observable.ref, isEditing: computed, selectedCellsList: computed, focusCell: action, clearFocus: action, startEdit: action, commitEdit: action, cancelEdit: action, setEditingValue: action, selectRange: action, selectAll: action, moveFocus: action, moveToNextCell: action, moveToPreviousCell: action, startSweep: action, sweepTo: action, stopSweep: action, }); } get isEditing(): boolean { return this.editingCell !== null; } focusCell(rowKey: string, columnId: string) { if (this.editingCell) { this.commitEdit(); } this.focusedCell = { rowKey, columnId }; this.selectedCells.clear(); this.selectedCells.add(cellKey(rowKey, columnId)); this.selectionAnchor = { rowKey, columnId }; } clearFocus() { this.focusedCell = null; this.selectedCells.clear(); this.selectionAnchor = null; this.cancelEdit(); } startEdit( rowKey?: string, columnId?: string, trigger: EditTrigger = 'enter', ) { const rk = rowKey ?? this.focusedCell?.rowKey; const cid = columnId ?? this.focusedCell?.columnId; if (rk != null && cid != null) { this.editingValue = undefined; this.editTrigger = trigger; this.editingCell = { rowKey: rk, columnId: cid }; this.focusedCell = { rowKey: rk, columnId: cid }; // Collapse any multi-cell range selection down to the editing cell. // Without this, range-selected cells remain visually selected alongside // the editing cell, producing an invalid "two cells selected" state. this.selectedCells.clear(); this.selectedCells.add(cellKey(rk, cid)); this.selectionAnchor = { rowKey: rk, columnId: cid }; } } setEditingValue(value: any) { this.editingValue = value; } commitEdit() { try { if (this.editingCell) { this._commitHandler(this.editingValue, this.editingCell); } } finally { this.editingValue = undefined; this.editTrigger = null; this.editingCell = null; } } cancelEdit() { this.editingValue = undefined; this.editTrigger = null; this.editingCell = null; } selectRange(from: Cell, to: Cell) { const fromPos = this._parent._toIndices(from); const toPos = this._parent._toIndices(to); if (!fromPos || !toPos) { return; } const { keyedItems, editableColumns } = this._parent; const minRow = Math.min(fromPos.row, toPos.row); const maxRow = Math.max(fromPos.row, toPos.row); const minCol = Math.min(fromPos.col, toPos.col); const maxCol = Math.max(fromPos.col, toPos.col); const newKeys = new Set(); for (let r = minRow; r <= maxRow; r++) { for (let c = minCol; c <= maxCol; c++) { newKeys.add(cellKey(keyedItems[r].key, editableColumns[c].id)); } } this._diffSelection(newKeys); this.focusedCell = to; } selectAll() { const { keyedItems, editableColumns } = this._parent; const newKeys = new Set(); for (const ki of keyedItems) { for (const col of editableColumns) { newKeys.add(cellKey(ki.key, col.id)); } } this._diffSelection(newKeys); if (keyedItems.length > 0 && editableColumns.length > 0) { this.selectionAnchor = this._parent._toPosition(0, 0)!; this.focusedCell = this._parent._toPosition( keyedItems.length - 1, editableColumns.length - 1, )!; } } moveFocus(direction: Direction, extend = false) { const pos = this.focusedCell && this._parent._toIndices(this.focusedCell); if (!pos) { return; } const { keyedItems, editableColumns } = this._parent; let { row, col } = pos; switch (direction) { case 'up': row = Math.max(0, row - 1); break; case 'down': row = Math.min(keyedItems.length - 1, row + 1); break; case 'left': col = Math.max(0, col - 1); break; case 'right': col = Math.min(editableColumns.length - 1, col + 1); break; } const target = this._parent._toPosition(row, col)!; if (extend && this.selectionAnchor) { this.selectRange(this.selectionAnchor, target); } else { this.focusCell(target.rowKey, target.columnId); } } moveToNextCell() { const pos = this.focusedCell && this._parent._toIndices(this.focusedCell); if (!pos) { return; } const { keyedItems, editableColumns } = this._parent; let { row, col } = pos; col++; if (col >= editableColumns.length) { col = 0; row++; if (row >= keyedItems.length) { row = keyedItems.length - 1; col = editableColumns.length - 1; } } this.focusCell(keyedItems[row].key, editableColumns[col].id); } moveToPreviousCell() { const pos = this.focusedCell && this._parent._toIndices(this.focusedCell); if (!pos) { return; } const { keyedItems, editableColumns } = this._parent; let { row, col } = pos; col--; if (col < 0) { col = editableColumns.length - 1; row--; if (row < 0) { row = 0; col = 0; } } this.focusCell(keyedItems[row].key, editableColumns[col].id); } startSweep(rowKey: string, columnId: string) { this.isSelecting = true; this.focusCell(rowKey, columnId); } sweepTo(rowKey: string, columnId: string) { if (!this.isSelecting || !this.selectionAnchor) { return; } this.selectRange(this.selectionAnchor, { rowKey, columnId }); } stopSweep() { this.isSelecting = false; } private _diffSelection(newKeys: Set) { for (const key of this.selectedCells) { if (!newKeys.has(key)) { this.selectedCells.delete(key); } } for (const key of newKeys) { if (!this.selectedCells.has(key)) { this.selectedCells.add(key); } } } isCellFocused(rowKey: string, columnId: string): boolean { return ( this.focusedCell?.rowKey === rowKey && this.focusedCell?.columnId === columnId ); } isCellEditing(rowKey: string, columnId: string): boolean { return ( this.editingCell?.rowKey === rowKey && this.editingCell?.columnId === columnId ); } isCellSelected(rowKey: string, columnId: string): boolean { return this.selectedCells.has(cellKey(rowKey, columnId)); } get selectedCellsList(): Cell[] { const result: Cell[] = []; for (const key of this.selectedCells) { const pos = parseCellKey(key); if (pos) { result.push(pos); } } return result; } scrollFocusedCellIntoView() { if (typeof requestAnimationFrame === 'undefined') { return; } requestAnimationFrame(() => { const cellEl = this.focusedCellElement; if (!cellEl) { return; } cellEl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); this._adjustScrollForStickyElements(cellEl); }); } /** * After scrollIntoView, the cell may be hidden behind sticky columns * or the sticky header. If so, scroll a bit more to fully reveal it. */ private _adjustScrollForStickyElements(cellEl: HTMLElement) { const td = cellEl.parentElement; if (!td) { return; } // Horizontal: check if cell is behind a sticky column. // Sticky columns stay in place while other columns scroll, so their // bounding rect's right edge can overlap the focused cell's left edge. const row = td.parentElement; const cellLeft = td.getBoundingClientRect().left; if (row) { let maxPrevRight = 0; for (const child of Array.from(row.children)) { if (child === td) { break; } const right = child.getBoundingClientRect().right; if (right > maxPrevRight) { maxPrevRight = right; } } if (cellLeft < maxPrevRight) { const scrollParent = cellEl.closest('table')?.parentElement; if (scrollParent) { scrollParent.scrollLeft -= maxPrevRight - cellLeft; } } } // Vertical: same pattern — check if cell is behind the sticky header // or below the visible area of the page-level scroll container. const headerEl = this._parent.tableState.headerElement; const scrollEl = this._parent.collection.scrollableContent; if (scrollEl) { const cellRect = td.getBoundingClientRect(); const scrollRect = scrollEl.getBoundingClientRect(); const effectiveTop = headerEl ? headerEl.getBoundingClientRect().bottom : scrollRect.top; if (cellRect.top < effectiveTop) { scrollEl.scrollTop -= effectiveTop - cellRect.top + 2; } else if (cellRect.bottom > scrollRect.bottom) { scrollEl.scrollTop += cellRect.bottom - scrollRect.bottom + 2; } } } getSelectionBorder(rowKey: string, columnId: string): CellBorder { if (!this.isCellSelected(rowKey, columnId)) { return noBorder; } const pos = this._parent._toIndices({ rowKey, columnId }); if (!pos) { return noBorder; } const isNeighborSelected = (row: number, col: number) => { const neighbor = this._parent._toPosition(row, col); return ( neighbor !== null && this.selectedCells.has(cellKey(neighbor.rowKey, neighbor.columnId)) ); }; return { top: !isNeighborSelected(pos.row - 1, pos.col), bottom: !isNeighborSelected(pos.row + 1, pos.col), left: !isNeighborSelected(pos.row, pos.col - 1), right: !isNeighborSelected(pos.row, pos.col + 1), }; } }