mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Rename files
This commit is contained in:
358
ts/components/fun/keyboard/WaterfallKeyboardDelegate.dom.tsx
Normal file
358
ts/components/fun/keyboard/WaterfallKeyboardDelegate.dom.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
|
||||
import lodash from 'lodash';
|
||||
import { strictAssert } from '../../../util/assert.std.js';
|
||||
import { KeyboardDelegate } from './FunKeyboard.dom.js';
|
||||
|
||||
const { findLast, maxBy } = lodash;
|
||||
|
||||
const PAGE_MARGIN = 0.25; // % of scroll height
|
||||
|
||||
enum Mode {
|
||||
Grid = 'Grid', // (default)
|
||||
List = 'List',
|
||||
}
|
||||
|
||||
type Key = string & { Key: never };
|
||||
|
||||
type Stack = ReadonlyArray<Key>;
|
||||
|
||||
type State = Readonly<{
|
||||
mode: Mode;
|
||||
key: Key | null;
|
||||
stack: Stack;
|
||||
}>;
|
||||
|
||||
export type { State as WaterfallKeyboardState };
|
||||
|
||||
const initialState: State = {
|
||||
mode: Mode.Grid,
|
||||
key: null,
|
||||
stack: [],
|
||||
};
|
||||
|
||||
function toGridState(state: State, key: Key | null, stack: Stack): State {
|
||||
return key != null ? { mode: Mode.Grid, key, stack } : state;
|
||||
}
|
||||
|
||||
function toListState(state: State, key: Key | null, stack: Stack): State {
|
||||
return key != null ? { mode: Mode.List, key, stack } : state;
|
||||
}
|
||||
|
||||
function toKey(item: VirtualItem | null): Key | null {
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
return String(item.key) as Key;
|
||||
}
|
||||
|
||||
export class WaterfallKeyboardDelegate extends KeyboardDelegate<State> {
|
||||
#virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
|
||||
|
||||
constructor(virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>) {
|
||||
super();
|
||||
this.#virtualizer = virtualizer;
|
||||
}
|
||||
|
||||
override getInitialState(): State {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
override getKeyFromState(state: State): Key | null {
|
||||
return state.key;
|
||||
}
|
||||
|
||||
override scrollToState(state: State): void {
|
||||
if (state.key == null) {
|
||||
return;
|
||||
}
|
||||
const item = this.#get(state.key);
|
||||
this.#virtualizer.scrollToIndex(item.index);
|
||||
}
|
||||
|
||||
override onFocusChange(state: State, key: Key | null): State {
|
||||
return toGridState(state, key, []);
|
||||
}
|
||||
|
||||
override onFocusLeave(_state: State): State {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
override onArrowLeft(state: State): State {
|
||||
const fromKey = state.key;
|
||||
if (fromKey == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.mode === Mode.Grid) {
|
||||
// First try to go back in the stack if we have one
|
||||
const stackLastIndex = state.stack.at(-1);
|
||||
if (stackLastIndex != null) {
|
||||
return toGridState(state, stackLastIndex, state.stack.slice(0, -1));
|
||||
}
|
||||
|
||||
const rowPrevIndex = this.#getPrevKeyInRow(fromKey);
|
||||
if (rowPrevIndex != null) {
|
||||
return toGridState(state, rowPrevIndex, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Grid mode falls back to list mode
|
||||
const listPrevIndex = this.#getPrevKeyInList(fromKey);
|
||||
if (listPrevIndex != null) {
|
||||
return toListState(state, listPrevIndex, []);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
override onArrowRight(state: State): State {
|
||||
const fromKey = state.key;
|
||||
if (fromKey == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.mode === Mode.Grid) {
|
||||
const rowNextKey = this.#getNextKeyInRow(fromKey);
|
||||
if (rowNextKey != null) {
|
||||
return toGridState(
|
||||
state,
|
||||
rowNextKey,
|
||||
state.key != null ? [...state.stack, state.key] : []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Grid mode falls back to list mode
|
||||
const listNextKey = this.#getNextKeyInList(fromKey);
|
||||
return toListState(state, listNextKey, []);
|
||||
}
|
||||
|
||||
override onArrowUp(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
const fromItem = this.#get(state.key);
|
||||
const prevItem = this.#findLast(item => {
|
||||
return item.index < fromItem.index && item.lane === fromItem.lane;
|
||||
});
|
||||
return toGridState(state, toKey(prevItem), []);
|
||||
}
|
||||
|
||||
override onArrowDown(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
const fromItem = this.#get(state.key);
|
||||
const nextItem = this.#findFirst(item => {
|
||||
return item.index > fromItem.index && item.lane === fromItem.lane;
|
||||
});
|
||||
return toGridState(state, toKey(nextItem), []);
|
||||
}
|
||||
|
||||
override onPageUp(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const fromItem = this.#get(state.key);
|
||||
|
||||
const scrollTop = this.#scrollOffset;
|
||||
const margin = this.#scrollHeight * PAGE_MARGIN;
|
||||
const nearTop =
|
||||
fromItem.start <= scrollTop + this.#scrollPaddingStart + margin;
|
||||
const padding = this.#scrollPaddingStart + this.#scrollPaddingEnd;
|
||||
const prevPage = nearTop
|
||||
? fromItem.end - this.#scrollHeight + padding
|
||||
: scrollTop + padding;
|
||||
|
||||
const match = this.#findFirst(item => {
|
||||
return (
|
||||
item.index <= fromItem.index &&
|
||||
item.lane === fromItem.lane &&
|
||||
item.end >= prevPage
|
||||
);
|
||||
});
|
||||
|
||||
return toGridState(state, toKey(match), []);
|
||||
}
|
||||
|
||||
override onPageDown(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
const fromItem = this.#get(state.key);
|
||||
|
||||
const scrollBottom = this.#scrollOffset + this.#scrollHeight;
|
||||
const margin = this.#scrollHeight * PAGE_MARGIN;
|
||||
const nearBottom =
|
||||
fromItem.end >= scrollBottom - this.#scrollPaddingEnd - margin;
|
||||
const padding = this.#scrollPaddingStart + this.#scrollPaddingEnd;
|
||||
const nextPage = nearBottom
|
||||
? fromItem.start + this.#scrollHeight - padding
|
||||
: scrollBottom - padding;
|
||||
|
||||
const match = this.#findLast(item => {
|
||||
return (
|
||||
item.index >= fromItem.index &&
|
||||
item.lane === fromItem.lane &&
|
||||
item.start <= nextPage
|
||||
);
|
||||
});
|
||||
|
||||
return toGridState(state, toKey(match), []);
|
||||
}
|
||||
|
||||
override onHome(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
const fromItem = this.#get(state.key);
|
||||
const firstLane = 0;
|
||||
if (fromItem.lane === firstLane) {
|
||||
return state;
|
||||
}
|
||||
const firstItems = this.#filter(item => {
|
||||
return item.lane === firstLane && this.#isIntersectingRow(fromItem, item);
|
||||
});
|
||||
const maxItem = maxBy(firstItems, item => {
|
||||
return this.#getRowVisibleIntersectionRatio(fromItem, item);
|
||||
});
|
||||
return toGridState(state, toKey(maxItem ?? null), []);
|
||||
}
|
||||
|
||||
override onEnd(state: State): State {
|
||||
if (state.key == null) {
|
||||
return state;
|
||||
}
|
||||
const fromItem = this.#get(state.key);
|
||||
const lastLane = this.#lanes - 1;
|
||||
if (fromItem.lane === lastLane) {
|
||||
return state;
|
||||
}
|
||||
const lastItems = this.#filter(item => {
|
||||
return item.lane === lastLane && this.#isIntersectingRow(fromItem, item);
|
||||
});
|
||||
const maxItem = maxBy(lastItems, item => {
|
||||
return this.#getRowVisibleIntersectionRatio(fromItem, item);
|
||||
});
|
||||
return toGridState(state, toKey(maxItem ?? null), []);
|
||||
}
|
||||
|
||||
override onModHome(state: State): State {
|
||||
const firstItem = this.#findFirst(() => true);
|
||||
return toGridState(state, toKey(firstItem), []);
|
||||
}
|
||||
|
||||
override onModEnd(state: State): State {
|
||||
const lastItem = this.#findLast(() => true);
|
||||
return toGridState(state, toKey(lastItem), []);
|
||||
}
|
||||
|
||||
get #lanes() {
|
||||
return this.#virtualizer.options.lanes;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#get(key: string): VirtualItem {
|
||||
const match = this.#findFirst(item => String(item.key) === key);
|
||||
strictAssert(match != null, `Missing item for key ${key}`);
|
||||
return match;
|
||||
}
|
||||
|
||||
#findFirst(predicate: (item: VirtualItem) => boolean): VirtualItem | null {
|
||||
return this.#virtualizer.getVirtualItems().find(predicate) ?? null;
|
||||
}
|
||||
|
||||
#findLast(predicate: (item: VirtualItem) => boolean): VirtualItem | null {
|
||||
return findLast(this.#virtualizer.getVirtualItems(), predicate) ?? null;
|
||||
}
|
||||
|
||||
#filter(predicate: (item: VirtualItem) => boolean): Array<VirtualItem> {
|
||||
return this.#virtualizer.getVirtualItems().filter(predicate);
|
||||
}
|
||||
|
||||
#isIntersectingRow(from: VirtualItem, to: VirtualItem): boolean {
|
||||
return !(to.start > from.end || to.end < from.start);
|
||||
}
|
||||
|
||||
#getRowVisibleIntersectionRatio(from: VirtualItem, to: VirtualItem) {
|
||||
const scrollTop = this.#scrollOffset;
|
||||
const scrollBottom = scrollTop + this.#scrollHeight;
|
||||
|
||||
const fromStart = Math.max(from.start, scrollTop);
|
||||
const fromEnd = Math.min(from.end, scrollBottom);
|
||||
|
||||
const toStart = Math.max(to.start, scrollTop);
|
||||
const toEnd = Math.min(to.end, scrollBottom);
|
||||
|
||||
const visibleOverlapStart = Math.max(fromStart, toStart);
|
||||
const visibleOverlapEnd = Math.min(fromEnd, toEnd);
|
||||
|
||||
if (visibleOverlapStart >= visibleOverlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const visibleOverlap = visibleOverlapEnd - visibleOverlapStart;
|
||||
const visibleSize = toEnd - toStart;
|
||||
return visibleOverlap / visibleSize;
|
||||
}
|
||||
|
||||
#getNextKeyInList(fromKey: Key): Key | null {
|
||||
const fromItem = this.#get(fromKey);
|
||||
const nextItem = this.#findFirst(item => item.index > fromItem.index);
|
||||
return toKey(nextItem ?? null);
|
||||
}
|
||||
|
||||
#getPrevKeyInList(fromKey: Key): Key | null {
|
||||
const fromItem = this.#get(fromKey);
|
||||
const prevItem = this.#findLast(item => item.index < fromItem.index);
|
||||
return toKey(prevItem ?? null);
|
||||
}
|
||||
|
||||
#getNextKeyInRow(fromKey: Key): Key | null {
|
||||
const fromItem = this.#get(fromKey);
|
||||
const nextLane = fromItem.lane + 1;
|
||||
if (nextLane >= this.#lanes) {
|
||||
return null;
|
||||
}
|
||||
const nextItems = this.#filter(item => {
|
||||
return item.lane === nextLane && this.#isIntersectingRow(fromItem, item);
|
||||
});
|
||||
const maxItem = maxBy(nextItems, item => {
|
||||
return this.#getRowVisibleIntersectionRatio(fromItem, item);
|
||||
});
|
||||
return toKey(maxItem ?? null);
|
||||
}
|
||||
|
||||
#getPrevKeyInRow(fromKey: Key): Key | null {
|
||||
const fromItem = this.#get(fromKey);
|
||||
const prevLane = fromItem.lane - 1;
|
||||
if (prevLane < 0) {
|
||||
return null;
|
||||
}
|
||||
const prevItems = this.#filter(item => {
|
||||
return item.lane === prevLane && this.#isIntersectingRow(fromItem, item);
|
||||
});
|
||||
const maxItem = maxBy(prevItems, item => {
|
||||
return this.#getRowVisibleIntersectionRatio(fromItem, item);
|
||||
});
|
||||
return toKey(maxItem ?? null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user