Signal-Desktop/ts/components/fun/keyboard/GridKeyboardDelegate.tsx

324 lines
8.7 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import { findLast, sortBy } from 'lodash';
import { strictAssert } from '../../../util/assert';
import type {
CellKey,
Layout,
RowKey,
SectionKey,
} from '../virtual/useFunVirtualGrid';
import { KeyboardDelegate } from './FunKeyboard';
const PAGE_MARGIN = 0.25; // % of scroll height
type Cell = Readonly<{
sectionKey: SectionKey;
rowKey: RowKey;
cellKey: CellKey;
sectionIndex: number;
rowIndex: number;
colIndex: number;
item: VirtualItem;
}>;
type State = Readonly<{
cell: Cell | null;
}>;
export type { State as GridKeyboardState };
function toState(state: State, cell: Cell | null): State {
return cell != null ? { cell } : state;
}
export class GridKeyboardDelegate extends KeyboardDelegate<State> {
#virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
#layout: Layout;
constructor(
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>,
layout: Layout
) {
super();
this.#virtualizer = virtualizer;
this.#layout = layout;
}
override scrollToState(state: State): void {
if (state.cell == null) {
return;
}
this.#virtualizer.scrollToIndex(state.cell.item.index);
}
override getInitialState(): State {
return { cell: null };
}
override getKeyFromState(state: State): string | null {
return state.cell?.cellKey ?? null;
}
override onFocusChange(_state: State, key: string | null): State {
return { cell: key != null ? this.#get(key as CellKey) : null };
}
override onFocusLeave(_state: State): State {
return { cell: null };
}
override onArrowLeft(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex, colIndex } = state.cell;
const cells = this.#getCellsInRowOrder();
const prevCell = this.#findLast(cells, cell => {
return (
// Prev cell in the same row
(cell.sectionIndex === sectionIndex &&
cell.rowIndex === rowIndex &&
cell.colIndex === colIndex - 1) ||
// Last cell in the prev row
(cell.sectionIndex === sectionIndex &&
cell.rowIndex === rowIndex - 1) ||
// Last cell in prev section
cell.sectionIndex === sectionIndex - 1
);
});
return toState(state, prevCell);
}
override onArrowRight(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex, colIndex } = state.cell;
const cells = this.#getCellsInRowOrder();
const match = this.#findFirst(cells, cell => {
return (
// Next cell in the same row
(cell.sectionIndex === sectionIndex &&
cell.rowIndex === rowIndex &&
cell.colIndex === colIndex + 1) ||
// First cell in the next row
(cell.sectionIndex === sectionIndex &&
cell.rowIndex === rowIndex + 1) ||
// First cell in next section
cell.sectionIndex === sectionIndex + 1
);
});
return toState(state, match);
}
override onArrowUp(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex, colIndex } = state.cell;
const cells = this.#getCellsInColOrder();
const match = this.#findLast(cells, cell => {
return (
// Same column in prev row in the same section
(cell.sectionIndex === sectionIndex &&
cell.colIndex === colIndex &&
cell.rowIndex === rowIndex - 1) ||
// Same column in last row in prev section
(cell.sectionIndex === sectionIndex - 1 && cell.colIndex === colIndex)
);
});
return toState(state, match);
}
override onArrowDown(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex, colIndex } = state.cell;
const cells = this.#getCellsInColOrder();
const match = this.#findFirst(cells, cell => {
return (
// Next row
(cell.sectionIndex === sectionIndex &&
cell.colIndex === colIndex &&
cell.rowIndex === rowIndex + 1) ||
// Same column in first row in next section
(cell.sectionIndex === sectionIndex + 1 && cell.colIndex === colIndex)
);
});
return toState(state, match);
}
override onPageUp(state: State): State {
if (state.cell == null) {
return state;
}
const { item, colIndex } = state.cell;
const scrollTop = this.#scrollOffset;
const margin = this.#scrollHeight * PAGE_MARGIN;
const nearTop = item.start <= scrollTop + this.#scrollPaddingStart + margin;
const padding = this.#scrollPaddingStart + this.#scrollPaddingEnd;
const prevPage = nearTop
? item.end - this.#scrollHeight + padding
: scrollTop + padding;
const cells = this.#getCellsInRowOrder();
const match = this.#findFirst(cells, cell => {
return (
cell.item.index <= item.index &&
cell.colIndex === colIndex &&
cell.item.end >= prevPage
);
});
return toState(state, match);
}
override onPageDown(state: State): State {
if (state.cell == null) {
return state;
}
const { item, colIndex } = state.cell;
const scrollBottom = this.#scrollOffset + this.#scrollHeight;
const margin = this.#scrollHeight * PAGE_MARGIN;
const nearBottom =
item.end >= scrollBottom - this.#scrollPaddingEnd - margin;
const padding = this.#scrollPaddingStart + this.#scrollPaddingEnd;
const nextPage = nearBottom
? item.start + this.#scrollHeight - padding
: scrollBottom - padding;
const cells = this.#getCellsInRowOrder();
const match = this.#findLast(cells, cell => {
return (
cell.item.index >= item.index &&
cell.colIndex === colIndex &&
cell.item.start <= nextPage
);
});
return toState(state, match);
}
override onHome(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex } = state.cell;
const cells = this.#getCellsInRowOrder();
const match = this.#findFirst(cells, cell => {
return cell.sectionIndex === sectionIndex && cell.rowIndex === rowIndex;
});
return toState(state, match);
}
override onEnd(state: State): State {
if (state.cell == null) {
return state;
}
const { sectionIndex, rowIndex } = state.cell;
const cells = this.#getCellsInRowOrder();
const match = this.#findLast(cells, cell => {
return cell.sectionIndex === sectionIndex && cell.rowIndex === rowIndex;
});
return toState(state, match);
}
override onModHome(state: State): State {
if (state.cell == null) {
return state;
}
const cells = this.#getCellsInRowOrder();
const match = this.#findFirst(cells, () => true);
return toState(state, match);
}
override onModEnd(state: State): State {
if (state.cell == null) {
return state;
}
const cells = this.#getCellsInRowOrder();
const match = this.#findLast(cells, () => true);
return toState(state, match);
}
get #scrollPaddingStart() {
return this.#virtualizer.options.scrollPaddingStart ?? 0;
}
get #scrollPaddingEnd() {
return this.#virtualizer.options.scrollPaddingEnd ?? 0;
}
get #scrollOffset() {
return this.#virtualizer.scrollOffset ?? 0;
}
get #scrollHeight() {
return this.#virtualizer.scrollRect?.height ?? 0;
}
#getCells(): ReadonlyArray<Cell> {
return this.#layout.sections.flatMap(section => {
return section.rowGroup.rows.flatMap(row => {
return row.cells.map((cell): Cell => {
return {
sectionKey: section.key,
rowKey: row.key,
cellKey: cell.key,
sectionIndex: section.sectionIndex,
rowIndex: row.rowIndex,
colIndex: cell.colIndex,
item: row.item,
};
});
});
});
}
#getCellsInRowOrder() {
return sortBy(this.#getCells(), [
cell => cell.sectionIndex,
cell => cell.rowIndex,
cell => cell.colIndex,
]);
}
#getCellsInColOrder() {
return sortBy(this.#getCells(), [
cell => cell.sectionIndex,
cell => cell.colIndex,
cell => cell.rowIndex,
]);
}
#get(key: CellKey): Cell {
const cells = this.#getCells();
const found = this.#findFirst(cells, cell => cell.cellKey === key);
strictAssert(found != null, `Cell not found for key ${key}`);
return found;
}
#findFirst(
cells: ReadonlyArray<Cell>,
predicate: (cell: Cell) => boolean
): Cell | null {
return cells.find(predicate) ?? null;
}
#findLast(
cells: ReadonlyArray<Cell>,
predicate: (cell: Cell) => boolean
): Cell | null {
return findLast(cells, predicate) ?? null;
}
}