perf(app): recompute picker grid layout in scroll chunks (#1947)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:51:13 +02:00
1 parent 21438b2d8f
commit dceb9061d9
10 files changed
+478 -481

No files matched your search

+2 -2
View File
@@ -18,8 +18,8 @@
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 cargo run --manifest-path ../tools/ci/Cargo.toml -- app-dev-server",
"test": "vitest run",
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberSidebar.bench.ts",
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts",
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts",
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/TileFlowSolver.bench.ts",
"bench:messages": "vitest bench --run src/features/messaging/utils/MessageGroupingUtils.bench.ts src/features/channel/components/ChannelMessageStreamUtils.bench.ts src/features/messaging/components/markdown/MarkdownRendering.bench.ts src/features/channel/components/MessageAttachmentUtils.bench.ts src/features/messaging/utils/MessagePaginationUtils.bench.ts src/features/platform/utils/ScrollPosition.bench.ts src/features/messaging/state/ReactionStateMachine.bench.ts",
"test:watch": "vitest",
"typecheck": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && tsgo --noEmit",
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasonryListComputer} from '@app/features/channel/components/MasonryListComputer';
import type {TileFlowSolver} from '@app/features/channel/components/TileFlowSolver';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useCallback, useEffect, useRef, useState} from 'react';
interface UseMasonryGridNavigationOptions {
gridData: MasonryListComputer['gridData'] | null;
navGrid: TileFlowSolver['navGrid'] | null;
itemKeys: ReadonlyArray<string>;
columns: number;
onSelect?: (itemKey: string) => void;
@@ -15,15 +15,7 @@ interface UseMasonryGridNavigationOptions {
}
export const useMasonryGridNavigation = (options: UseMasonryGridNavigationOptions) => {
const {
gridData,
itemKeys,
columns,
onSelect,
containerRef,
isEnabled = true,
checkSuspension = () => false,
} = options;
const {navGrid, itemKeys, columns, onSelect, containerRef, isEnabled = true, checkSuspension = () => false} = options;
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null);
const focusedItemRef = useRef<string | null>(null);
useEffect(() => {
@@ -47,31 +39,31 @@ export const useMasonryGridNavigation = (options: UseMasonryGridNavigationOption
);
const getItemCoordinates = useCallback(
(itemKey: string) => {
if (!gridData) return null;
return gridData.coordinates[itemKey] || null;
if (!navGrid) return null;
return navGrid.tileCells[itemKey] || null;
},
[gridData],
[navGrid],
);
const findItemByCoordinates = useCallback(
(row: number, column: number): string | null => {
if (!gridData) return null;
if (!navGrid) return null;
for (const itemKey of itemKeys) {
const coords = gridData.coordinates[itemKey];
const coords = navGrid.tileCells[itemKey];
if (coords && coords.row === row && coords.column === column) {
return itemKey;
}
}
return null;
},
[gridData, itemKeys],
[navGrid, itemKeys],
);
const findNearestItemInColumn = useCallback(
(targetRow: number, targetColumn: number, direction: 1 | -1): string | null => {
if (!gridData) return null;
if (!navGrid) return null;
let bestItem: string | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const itemKey of itemKeys) {
const coords = gridData.coordinates[itemKey];
const coords = navGrid.tileCells[itemKey];
if (!coords || coords.column !== targetColumn) continue;
const rowDiff = coords.row - targetRow;
if ((direction > 0 && rowDiff > 0) || (direction < 0 && rowDiff < 0)) {
@@ -84,12 +76,12 @@ export const useMasonryGridNavigation = (options: UseMasonryGridNavigationOption
}
return bestItem;
},
[gridData, itemKeys],
[navGrid, itemKeys],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!isEnabled || checkSuspension()) return;
if (!gridData || itemKeys.length === 0) return;
if (!navGrid || itemKeys.length === 0) return;
const target = event.target as HTMLElement;
if (
target &&
@@ -172,7 +164,7 @@ export const useMasonryGridNavigation = (options: UseMasonryGridNavigationOption
[
isEnabled,
checkSuspension,
gridData,
navGrid,
itemKeys,
columns,
onSelect,
@@ -1,362 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
type VisibleSection = Array<[string, number, number]>;
interface GridCoordinates {
section: number;
row: number;
column: number;
}
interface GridData {
coordinates: Record<string, GridCoordinates>;
boundaries: Array<number>;
}
interface CoordsStyle {
position: 'absolute' | 'sticky';
left?: number;
right?: number;
width: number;
top: number;
height: number;
}
type Padding =
| number
| {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
function defaultGetSectionHeight(_section: number): number {
return 0;
}
function findMinColumnIndex(columnHeights: Array<number>): [number, number] {
let minHeight = columnHeights[0];
let minIndex = 0;
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < minHeight) {
minHeight = columnHeights[i];
minIndex = i;
}
}
return [minHeight, minIndex];
}
function getSectionHeaderKey(sectionIndex: number): string {
return `section-header-${sectionIndex}`;
}
function getSectionKey(sectionIndex: number): string {
return `section-${sectionIndex}`;
}
export class MasonryListComputer {
public visibleSections: Record<string, VisibleSection> = {};
public gridData: GridData = {coordinates: {}, boundaries: []};
public coordsMap: Record<string, CoordsStyle> = {};
public itemGrid: Array<Array<string>> = [];
public totalHeight: number = 0;
private columnHeights: Array<number> = [];
private columnWidth: number = 0;
private currentRow: number = 0;
private lastColumnIndex: number = 0;
private needsFullCompute: boolean = true;
private bufferWidth: number = 0;
private sections: Array<number> = [];
private columns: number = 0;
private itemGutter: number = 0;
private removeEdgeItemGutters: boolean = false;
private sectionGutter: number | null = null;
private padding: Padding | null = null;
private paddingVertical: number | null = null;
private paddingHorizontal: number | null = null;
private marginLeft: number | null = null;
private dir: 'ltr' | 'rtl' = 'ltr';
private version: number | string | null = null;
private getItemKey: (section: number, item: number) => string | null = () => {
throw new Error('MasonryListComputer: getItemKey has not been implemented');
};
private getItemHeight: (section: number, item: number, width: number) => number = () => {
throw new Error('MasonryListComputer: getItemHeight has not been implemented');
};
private getSectionHeight: (section: number) => number = defaultGetSectionHeight;
private getPadding(key: 'top' | 'bottom' | 'left' | 'right'): number {
if (this.padding == null) {
return this.itemGutter;
}
if (typeof this.padding === 'number') {
return this.padding;
}
return this.padding[key] ?? this.itemGutter;
}
private getPaddingLeft(): number {
return this.paddingHorizontal != null ? this.paddingHorizontal : this.getPadding('left');
}
private getPaddingRight(): number {
return this.paddingHorizontal != null ? this.paddingHorizontal : this.getPadding('right');
}
private getPaddingTop(): number {
return this.paddingVertical != null ? this.paddingVertical : this.getPadding('top');
}
private getPaddingBottom(): number {
return this.paddingVertical != null ? this.paddingVertical : this.getPadding('bottom');
}
private getSectionGutter(): number {
return this.sectionGutter != null ? this.sectionGutter : this.itemGutter;
}
mergeProps(props: {
sections?: Array<number>;
columns?: number;
itemGutter?: number;
removeEdgeItemGutters?: boolean;
getItemKey?: (section: number, item: number) => string | null;
getItemHeight?: (section: number, item: number, width: number) => number;
getSectionHeight?: (section: number) => number;
bufferWidth?: number;
padding?: Padding;
paddingVertical?: number;
paddingHorizontal?: number;
marginLeft?: number;
sectionGutter?: number;
dir?: 'ltr' | 'rtl';
version?: number | string | null;
}): void {
const {
sections = this.sections,
columns = this.columns,
itemGutter = this.itemGutter,
removeEdgeItemGutters = this.removeEdgeItemGutters,
getItemKey = this.getItemKey,
getItemHeight = this.getItemHeight,
getSectionHeight = this.getSectionHeight,
bufferWidth = this.bufferWidth,
padding = this.padding,
paddingVertical = this.paddingVertical,
paddingHorizontal = this.paddingHorizontal,
marginLeft = this.marginLeft,
sectionGutter = this.sectionGutter,
dir = this.dir,
version = this.version,
} = props;
if (
this.sections !== sections ||
this.columns !== columns ||
this.itemGutter !== itemGutter ||
this.removeEdgeItemGutters !== removeEdgeItemGutters ||
this.getItemKey !== getItemKey ||
this.getSectionHeight !== getSectionHeight ||
this.getItemHeight !== getItemHeight ||
this.bufferWidth !== bufferWidth ||
this.padding !== padding ||
this.paddingVertical !== paddingVertical ||
this.paddingHorizontal !== paddingHorizontal ||
this.marginLeft !== marginLeft ||
this.sectionGutter !== sectionGutter ||
this.dir !== dir ||
this.version !== version
) {
this.needsFullCompute = true;
this.sections = sections;
this.columns = columns;
this.itemGutter = itemGutter;
this.removeEdgeItemGutters = removeEdgeItemGutters;
this.getItemKey = getItemKey;
this.getSectionHeight = getSectionHeight;
this.getItemHeight = getItemHeight;
this.bufferWidth = bufferWidth;
this.padding = padding;
this.paddingVertical = paddingVertical;
this.paddingHorizontal = paddingHorizontal;
this.marginLeft = marginLeft;
this.sectionGutter = sectionGutter;
this.dir = dir;
this.version = version;
}
}
private computeFullCoords(): void {
if (!this.needsFullCompute) return;
const {columns, getItemKey, getItemHeight, itemGutter, getSectionHeight, bufferWidth, removeEdgeItemGutters} = this;
const horizontalKey = this.dir === 'rtl' ? 'right' : 'left';
this.coordsMap = {};
this.gridData = {boundaries: [], coordinates: {}};
this.currentRow = 0;
this.lastColumnIndex = 0;
const paddingTop = this.getPaddingTop();
const paddingBottom = this.getPaddingBottom();
const paddingLeft = this.getPaddingLeft();
const paddingRight = this.getPaddingRight();
const marginLeft = this.marginLeft ?? 0;
this.columnHeights = Array(columns).fill(paddingTop);
this.columnWidth =
(bufferWidth -
paddingRight -
paddingLeft -
itemGutter * (columns - 1) -
(removeEdgeItemGutters ? itemGutter : 0)) /
columns;
this.itemGrid = [];
let sectionIndex = 0;
while (sectionIndex < this.sections.length) {
this.gridData.boundaries[sectionIndex] = this.currentRow;
this.currentRow = 0;
this.lastColumnIndex = 0;
const sectionLength = this.sections[sectionIndex];
let itemIndex = 0;
let minItemTop = Number.POSITIVE_INFINITY;
let maxItemBottom = Number.NEGATIVE_INFINITY;
const sectionHeight = getSectionHeight(sectionIndex);
let maxColumnHeight = this.getMaxColumnHeight(this.columnHeights);
if (sectionIndex > 0) {
maxColumnHeight = maxColumnHeight - itemGutter + this.getSectionGutter();
}
const sectionHeaderHeight = sectionHeight > 0 ? sectionHeight + itemGutter : 0;
for (let col = 0; col < this.columnHeights.length; col++) {
this.columnHeights[col] = maxColumnHeight + sectionHeaderHeight;
}
while (itemIndex < sectionLength) {
const itemKey = getItemKey(sectionIndex, itemIndex);
if (itemKey == null) {
itemIndex++;
continue;
}
const [minHeight, minColumnIndex] = findMinColumnIndex(this.columnHeights);
if (minColumnIndex < this.lastColumnIndex) {
this.currentRow++;
}
this.lastColumnIndex = minColumnIndex;
const itemHeight = getItemHeight(sectionIndex, itemIndex, this.columnWidth);
const coords: CoordsStyle = {
position: 'absolute',
[horizontalKey]:
this.columnWidth * minColumnIndex + itemGutter * (minColumnIndex + 1) - itemGutter + paddingLeft,
width: this.columnWidth,
top: minHeight - maxColumnHeight,
height: itemHeight,
};
minItemTop = Math.min(minItemTop, coords.top);
maxItemBottom = Math.max(maxItemBottom, coords.top + coords.height);
const gridCoords: GridCoordinates = {
section: sectionIndex,
row: this.currentRow,
column: minColumnIndex,
};
this.coordsMap[itemKey] = coords;
this.gridData.coordinates[itemKey] = gridCoords;
this.columnHeights[minColumnIndex] = minHeight + itemHeight + itemGutter;
this.itemGrid[minColumnIndex] = this.itemGrid[minColumnIndex] ?? [];
this.itemGrid[minColumnIndex].push(itemKey);
itemIndex++;
}
if (sectionHeight > 0) {
this.coordsMap[getSectionHeaderKey(sectionIndex)] = {
position: 'sticky',
[horizontalKey]: paddingLeft,
width: this.columnWidth * columns + itemGutter * columns,
top: 0,
height: sectionHeight,
};
this.coordsMap[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: marginLeft,
width: this.columnWidth * columns + itemGutter * (columns - 1) + paddingLeft + paddingRight,
top: maxColumnHeight,
height: this.getMaxColumnHeight(this.columnHeights) - maxColumnHeight,
};
} else if (Number.isFinite(minItemTop) && Number.isFinite(maxItemBottom)) {
this.coordsMap[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: marginLeft,
width: this.columnWidth * columns + itemGutter * (columns - 1) + paddingLeft + paddingRight,
top: minItemTop,
height: maxItemBottom - minItemTop,
};
}
sectionIndex++;
}
this.columnHeights = this.columnHeights.map((height) => height - itemGutter + paddingBottom);
this.totalHeight = this.getMaxColumnHeight();
this.visibleSections = {};
this.needsFullCompute = false;
}
computeVisibleSections(start: number, end: number): void {
this.computeFullCoords();
const {getItemKey, coordsMap} = this;
this.visibleSections = {};
let sectionIndex = 0;
while (sectionIndex < this.sections.length) {
const sectionLength = this.sections[sectionIndex];
const sectionKey = getSectionKey(sectionIndex);
const sectionCoords = coordsMap[sectionKey];
if (sectionCoords == null) {
sectionIndex++;
continue;
}
const {top} = sectionCoords;
const bottom = top + sectionCoords.height;
if (top > end) break;
if (bottom < start) {
sectionIndex++;
continue;
}
let itemIndex = 0;
let direction = 1;
if (bottom < end && bottom > start) {
itemIndex = sectionLength - 1;
direction = -1;
}
this.visibleSections[sectionKey] = [];
while (itemIndex >= 0 && itemIndex < sectionLength) {
const itemKey = getItemKey(sectionIndex, itemIndex);
const itemCoords = itemKey != null ? coordsMap[itemKey] : null;
if (itemKey == null || itemCoords == null) {
itemIndex += direction;
continue;
}
const {top: itemTop, height: itemHeight} = itemCoords;
const itemAbsoluteTop = itemTop + top;
if (itemAbsoluteTop > start - itemHeight && itemAbsoluteTop < end) {
if (direction === -1) {
this.visibleSections[sectionKey].unshift([itemKey, sectionIndex, itemIndex]);
} else {
this.visibleSections[sectionKey].push([itemKey, sectionIndex, itemIndex]);
}
}
itemIndex += direction;
}
if (top < start && bottom > end) break;
sectionIndex++;
}
}
private getMaxColumnHeight(columnHeights: Array<number> = this.columnHeights): number {
return columnHeights.reduce((max, height) => Math.max(max, height), 0);
}
getState(): {
coordsMap: Record<string, CoordsStyle>;
gridData: GridData;
visibleSections: Record<string, VisibleSection>;
totalHeight: number;
} {
return {
coordsMap: this.coordsMap,
gridData: this.gridData,
visibleSections: this.visibleSections,
totalHeight: this.totalHeight,
};
}
}
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
type SectionWindow = Array<[string, number, number]>;
interface GridCell {
section: number;
row: number;
column: number;
}
interface NavGrid {
tileCells: Record<string, GridCell>;
rowBase: Array<number>;
}
interface TileBox {
position: 'absolute' | 'sticky';
left?: number;
right?: number;
width: number;
top: number;
height: number;
}
type LayoutPadding =
| number
| {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
function noSectionHeight(_section: number): number {
return 0;
}
function shortestLane(laneFill: Array<number>): [number, number] {
let minHeight = laneFill[0];
let minIndex = 0;
for (let i = 1; i < laneFill.length; i++) {
if (laneFill[i] < minHeight) {
minHeight = laneFill[i];
minIndex = i;
}
}
return [minHeight, minIndex];
}
function getSectionHeaderKey(sectionIndex: number): string {
return `section-header-${sectionIndex}`;
}
function getSectionKey(sectionIndex: number): string {
return `section-${sectionIndex}`;
}
export class TileFlowSolver {
public windowedTiles: Record<string, SectionWindow> = {};
public navGrid: NavGrid = {tileCells: {}, rowBase: []};
public tileBoxes: Record<string, TileBox> = {};
public laneOrder: Array<Array<string>> = [];
public contentHeight: number = 0;
private laneFill: Array<number> = [];
private laneWidth: number = 0;
private rowCursor: number = 0;
private prevLaneIndex: number = 0;
private layoutDirty: boolean = true;
private viewportSpan: number = 0;
private sectionSizes: Array<number> = [];
private laneCount: number = 0;
private tileSpacing: number = 0;
private trimOuterSpacing: boolean = false;
private sectionSpacing: number | null = null;
private padBox: LayoutPadding | null = null;
private padAxisY: number | null = null;
private padAxisX: number | null = null;
private originShift: number | null = null;
private flowDirection: 'ltr' | 'rtl' = 'ltr';
private layoutStamp: number | string | null = null;
private tileKeyOf: (section: number, item: number) => string | null = () => {
throw new Error('TileFlowSolver requires a tile key resolver before layout can run');
};
private tileHeightOf: (section: number, item: number, width: number) => number = () => {
throw new Error('TileFlowSolver requires a tile height measurer before layout can run');
};
private sectionHeightOf: (section: number) => number = noSectionHeight;
private padFor(key: 'top' | 'bottom' | 'left' | 'right'): number {
if (this.padBox == null) {
return this.tileSpacing;
}
if (typeof this.padBox === 'number') {
return this.padBox;
}
return this.padBox[key] ?? this.tileSpacing;
}
private padLeading(): number {
return this.padAxisX != null ? this.padAxisX : this.padFor('left');
}
private padTrailing(): number {
return this.padAxisX != null ? this.padAxisX : this.padFor('right');
}
private padTop(): number {
return this.padAxisY != null ? this.padAxisY : this.padFor('top');
}
private padBottom(): number {
return this.padAxisY != null ? this.padAxisY : this.padFor('bottom');
}
private spacingBetweenSections(): number {
return this.sectionSpacing != null ? this.sectionSpacing : this.tileSpacing;
}
applySettings(settings: {
sectionSizes?: Array<number>;
laneCount?: number;
tileSpacing?: number;
trimOuterSpacing?: boolean;
tileKeyOf?: (section: number, item: number) => string | null;
tileHeightOf?: (section: number, item: number, width: number) => number;
sectionHeightOf?: (section: number) => number;
viewportSpan?: number;
padBox?: LayoutPadding;
padAxisY?: number;
padAxisX?: number;
originShift?: number;
sectionSpacing?: number;
flowDirection?: 'ltr' | 'rtl';
layoutStamp?: number | string | null;
}): void {
const {
sectionSizes = this.sectionSizes,
laneCount = this.laneCount,
tileSpacing = this.tileSpacing,
trimOuterSpacing = this.trimOuterSpacing,
tileKeyOf = this.tileKeyOf,
tileHeightOf = this.tileHeightOf,
sectionHeightOf = this.sectionHeightOf,
viewportSpan = this.viewportSpan,
padBox = this.padBox,
padAxisY = this.padAxisY,
padAxisX = this.padAxisX,
originShift = this.originShift,
sectionSpacing = this.sectionSpacing,
flowDirection = this.flowDirection,
layoutStamp = this.layoutStamp,
} = settings;
if (
this.sectionSizes !== sectionSizes ||
this.laneCount !== laneCount ||
this.tileSpacing !== tileSpacing ||
this.trimOuterSpacing !== trimOuterSpacing ||
this.tileKeyOf !== tileKeyOf ||
this.sectionHeightOf !== sectionHeightOf ||
this.tileHeightOf !== tileHeightOf ||
this.viewportSpan !== viewportSpan ||
this.padBox !== padBox ||
this.padAxisY !== padAxisY ||
this.padAxisX !== padAxisX ||
this.originShift !== originShift ||
this.sectionSpacing !== sectionSpacing ||
this.flowDirection !== flowDirection ||
this.layoutStamp !== layoutStamp
) {
this.layoutDirty = true;
this.sectionSizes = sectionSizes;
this.laneCount = laneCount;
this.tileSpacing = tileSpacing;
this.trimOuterSpacing = trimOuterSpacing;
this.tileKeyOf = tileKeyOf;
this.sectionHeightOf = sectionHeightOf;
this.tileHeightOf = tileHeightOf;
this.viewportSpan = viewportSpan;
this.padBox = padBox;
this.padAxisY = padAxisY;
this.padAxisX = padAxisX;
this.originShift = originShift;
this.sectionSpacing = sectionSpacing;
this.flowDirection = flowDirection;
this.layoutStamp = layoutStamp;
}
}
private rebuildLayout(): void {
if (!this.layoutDirty) return;
const {laneCount, tileKeyOf, tileHeightOf, tileSpacing, sectionHeightOf, viewportSpan, trimOuterSpacing} = this;
const horizontalKey = this.flowDirection === 'rtl' ? 'right' : 'left';
this.tileBoxes = {};
this.navGrid = {rowBase: [], tileCells: {}};
this.rowCursor = 0;
this.prevLaneIndex = 0;
const padTop = this.padTop();
const padBottom = this.padBottom();
const padLeading = this.padLeading();
const padTrailing = this.padTrailing();
const originShift = this.originShift ?? 0;
this.laneFill = Array(laneCount).fill(padTop);
this.laneWidth =
(viewportSpan - padTrailing - padLeading - tileSpacing * (laneCount - 1) - (trimOuterSpacing ? tileSpacing : 0)) /
laneCount;
this.laneOrder = [];
let sectionIndex = 0;
while (sectionIndex < this.sectionSizes.length) {
this.navGrid.rowBase[sectionIndex] = this.rowCursor;
this.rowCursor = 0;
this.prevLaneIndex = 0;
const sectionLength = this.sectionSizes[sectionIndex];
let itemIndex = 0;
let minItemTop = Number.POSITIVE_INFINITY;
let maxItemBottom = Number.NEGATIVE_INFINITY;
const sectionHeight = sectionHeightOf(sectionIndex);
let laneCeiling = this.tallestLane(this.laneFill);
if (sectionIndex > 0) {
laneCeiling = laneCeiling - tileSpacing + this.spacingBetweenSections();
}
const headerBand = sectionHeight > 0 ? sectionHeight + tileSpacing : 0;
for (let lane = 0; lane < this.laneFill.length; lane++) {
this.laneFill[lane] = laneCeiling + headerBand;
}
while (itemIndex < sectionLength) {
const tileKey = tileKeyOf(sectionIndex, itemIndex);
if (tileKey == null) {
itemIndex++;
continue;
}
const [minHeight, laneIndex] = shortestLane(this.laneFill);
if (laneIndex < this.prevLaneIndex) {
this.rowCursor++;
}
this.prevLaneIndex = laneIndex;
const tileHeight = tileHeightOf(sectionIndex, itemIndex, this.laneWidth);
const box: TileBox = {
position: 'absolute',
[horizontalKey]: this.laneWidth * laneIndex + tileSpacing * (laneIndex + 1) - tileSpacing + padLeading,
width: this.laneWidth,
top: minHeight - laneCeiling,
height: tileHeight,
};
minItemTop = Math.min(minItemTop, box.top);
maxItemBottom = Math.max(maxItemBottom, box.top + box.height);
const cell: GridCell = {
section: sectionIndex,
row: this.rowCursor,
column: laneIndex,
};
this.tileBoxes[tileKey] = box;
this.navGrid.tileCells[tileKey] = cell;
this.laneFill[laneIndex] = minHeight + tileHeight + tileSpacing;
this.laneOrder[laneIndex] = this.laneOrder[laneIndex] ?? [];
this.laneOrder[laneIndex].push(tileKey);
itemIndex++;
}
if (sectionHeight > 0) {
this.tileBoxes[getSectionHeaderKey(sectionIndex)] = {
position: 'sticky',
[horizontalKey]: padLeading,
width: this.laneWidth * laneCount + tileSpacing * laneCount,
top: 0,
height: sectionHeight,
};
this.tileBoxes[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: originShift,
width: this.laneWidth * laneCount + tileSpacing * (laneCount - 1) + padLeading + padTrailing,
top: laneCeiling,
height: this.tallestLane(this.laneFill) - laneCeiling,
};
} else if (Number.isFinite(minItemTop) && Number.isFinite(maxItemBottom)) {
this.tileBoxes[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: originShift,
width: this.laneWidth * laneCount + tileSpacing * (laneCount - 1) + padLeading + padTrailing,
top: minItemTop,
height: maxItemBottom - minItemTop,
};
}
sectionIndex++;
}
this.laneFill = this.laneFill.map((height) => height - tileSpacing + padBottom);
this.contentHeight = this.tallestLane();
this.windowedTiles = {};
this.layoutDirty = false;
}
selectWindow(start: number, end: number): void {
this.rebuildLayout();
const {tileKeyOf, tileBoxes} = this;
this.windowedTiles = {};
let sectionIndex = 0;
while (sectionIndex < this.sectionSizes.length) {
const sectionLength = this.sectionSizes[sectionIndex];
const sectionKey = getSectionKey(sectionIndex);
const sectionExtent = tileBoxes[sectionKey];
if (sectionExtent == null) {
sectionIndex++;
continue;
}
const {top} = sectionExtent;
const bottom = top + sectionExtent.height;
if (top > end) break;
if (bottom < start) {
sectionIndex++;
continue;
}
let itemIndex = 0;
let direction = 1;
if (bottom < end && bottom > start) {
itemIndex = sectionLength - 1;
direction = -1;
}
this.windowedTiles[sectionKey] = [];
while (itemIndex >= 0 && itemIndex < sectionLength) {
const tileKey = tileKeyOf(sectionIndex, itemIndex);
const tileBox = tileKey != null ? tileBoxes[tileKey] : null;
if (tileKey == null || tileBox == null) {
itemIndex += direction;
continue;
}
const {top: tileTop, height: tileHeight} = tileBox;
const tileAbsoluteTop = tileTop + top;
if (tileAbsoluteTop > start - tileHeight && tileAbsoluteTop < end) {
if (direction === -1) {
this.windowedTiles[sectionKey].unshift([tileKey, sectionIndex, itemIndex]);
} else {
this.windowedTiles[sectionKey].push([tileKey, sectionIndex, itemIndex]);
}
}
itemIndex += direction;
}
if (top < start && bottom > end) break;
sectionIndex++;
}
}
private tallestLane(laneFill: Array<number> = this.laneFill): number {
return laneFill.reduce((max, height) => Math.max(max, height), 0);
}
readLayout(): {
tileBoxes: Record<string, TileBox>;
navGrid: NavGrid;
windowedTiles: Record<string, SectionWindow>;
contentHeight: number;
} {
return {
tileBoxes: this.tileBoxes,
navGrid: this.navGrid,
windowedTiles: this.windowedTiles,
contentHeight: this.contentHeight,
};
}
}
@@ -50,8 +50,8 @@ export const GifPickerGrid = observer(
onContentSizeChange?: (contentSize: number) => void;
}) => {
const {i18n} = useLingui();
const itemGutter = 8;
const columns = computeMasonryColumns(viewportWidth, itemGutter, {minColumns: 2});
const tileSpacing = 8;
const columns = computeMasonryColumns(viewportWidth, tileSpacing, {minColumns: 2});
const favoriteMemes = FavoriteMemes.memes;
const favoriteMemesVersion = favoriteMemes.length;
const favoriteGifs = FavoriteGif.favoriteGifs;
@@ -131,7 +131,7 @@ export const GifPickerGrid = observer(
data={data}
itemKeys={itemKeys}
columns={columns}
itemGutter={itemGutter}
tileSpacing={tileSpacing}
viewportWidth={viewportWidth}
viewportHeight={viewportHeight}
scrollTop={scrollTop}
@@ -141,13 +141,13 @@ export const GifPickerGrid = observer(
onSelectItemKey={(key) => {
handleSelectByKey(key);
}}
getItemKey={(item) => item.key}
getItemHeight={(item, _index, columnWidth) => {
tileKeyOf={(item) => item.key}
tileHeightOf={(item, _index, laneWidth) => {
if (item.type === 'gif') {
const g = item.gif;
return columnWidth * (g.height / g.width);
return laneWidth * (g.height / g.width);
}
return columnWidth * (item.height / item.width);
return laneWidth * (item.height / item.width);
}}
extraSections={[
{
@@ -22,7 +22,7 @@ export interface GifPickerLoadingSkeletonLayoutItem extends GifPickerLoadingSkel
export interface GifPickerLoadingSkeletonLayoutInput {
viewportWidth: number;
viewportHeight: number;
itemGutter?: number;
tileSpacing?: number;
paddingPx?: number;
overscanPx?: number;
}
@@ -61,19 +61,19 @@ export const GIF_PICKER_LOADING_SKELETON_GIF_SPECS: ReadonlyArray<GifPickerLoadi
export function buildGifPickerLoadingSkeletonLayout({
viewportWidth,
viewportHeight,
itemGutter = 8,
tileSpacing = 8,
paddingPx = MASONRY_PADDING_PX,
overscanPx = MASONRY_PADDING_PX * 2,
}: GifPickerLoadingSkeletonLayoutInput): Array<GifPickerLoadingSkeletonLayoutItem> {
if (viewportWidth <= 0 || viewportHeight <= 0) {
return [];
}
const columns = computeMasonryColumns(viewportWidth, itemGutter, {minColumns: 2});
const columnWidth = (viewportWidth - paddingPx * 2 - itemGutter * Math.max(0, columns - 1)) / Math.max(1, columns);
if (columnWidth <= 0) {
const columns = computeMasonryColumns(viewportWidth, tileSpacing, {minColumns: 2});
const laneWidth = (viewportWidth - paddingPx * 2 - tileSpacing * Math.max(0, columns - 1)) / Math.max(1, columns);
if (laneWidth <= 0) {
return [];
}
const columnHeights = Array.from({length: columns}, () => paddingPx);
const laneFill = Array.from({length: columns}, () => paddingPx);
const visibleBottom = viewportHeight + overscanPx;
const layout: Array<GifPickerLoadingSkeletonLayoutItem> = [];
for (let index = 0; index < GIF_PICKER_LOADING_SKELETON_GIF_SPECS.length; index += 1) {
@@ -81,11 +81,11 @@ export function buildGifPickerLoadingSkeletonLayout({
if (!spec) {
continue;
}
const column = findShortestColumnIndex(columnHeights);
const left = paddingPx + column * (columnWidth + itemGutter);
const top = columnHeights[column] ?? paddingPx;
const renderedHeight = columnWidth * (spec.height / spec.width);
columnHeights[column] = top + renderedHeight + itemGutter;
const column = findShortestColumnIndex(laneFill);
const left = paddingPx + column * (laneWidth + tileSpacing);
const top = laneFill[column] ?? paddingPx;
const renderedHeight = laneWidth * (spec.height / spec.width);
laneFill[column] = top + renderedHeight + tileSpacing;
if (top > visibleBottom) {
continue;
}
@@ -95,18 +95,18 @@ export function buildGifPickerLoadingSkeletonLayout({
column,
left,
top,
renderedWidth: columnWidth,
renderedWidth: laneWidth,
renderedHeight,
});
}
return layout;
}
function findShortestColumnIndex(columnHeights: ReadonlyArray<number>): number {
function findShortestColumnIndex(laneFill: ReadonlyArray<number>): number {
let shortestColumn = 0;
let shortestHeight = columnHeights[0] ?? 0;
for (let column = 1; column < columnHeights.length; column += 1) {
const height = columnHeights[column];
let shortestHeight = laneFill[0] ?? 0;
for (let column = 1; column < laneFill.length; column += 1) {
const height = laneFill[column];
if (height < shortestHeight) {
shortestColumn = column;
shortestHeight = height;
@@ -25,8 +25,8 @@ export const MemesGrid = observer(
scrollTop: number;
onContentSizeChange?: (contentSize: number) => void;
}) => {
const itemGutter = 8;
const columns = computeMasonryColumns(viewportWidth, itemGutter);
const tileSpacing = 8;
const columns = computeMasonryColumns(viewportWidth, tileSpacing);
const data = useMemo(
() =>
memes.map((meme) => ({
@@ -46,15 +46,15 @@ export const MemesGrid = observer(
data={data}
itemKeys={itemKeys}
columns={columns}
itemGutter={itemGutter}
tileSpacing={tileSpacing}
viewportWidth={viewportWidth}
viewportHeight={viewportHeight}
scrollTop={scrollTop}
onContentSizeChange={onContentSizeChange}
checkSuspension={() => QuickSwitcher.isOpen}
onSelectItemKey={handleSelectKey}
getItemKey={(item) => item.id}
getItemHeight={(item, _index, columnWidth) => columnWidth * (item.height / item.width)}
tileKeyOf={(item) => item.id}
tileHeightOf={(item, _index, laneWidth) => laneWidth * (item.height / item.width)}
renderItem={({item, itemKey, coords, isFocused}) => (
<MemeGridItem
key={itemKey}
@@ -1,20 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ComputeColumnsOptions {
desiredItemWidth?: number;
maxColumns?: number;
targetTileWidth?: number;
maxLanes?: number;
minColumns?: number;
}
export function computeMasonryColumns(
containerWidth: number,
itemGutter: number,
tileSpacing: number,
options: ComputeColumnsOptions = {},
): number {
const desiredItemWidth = options.desiredItemWidth ?? 200;
const maxColumns = options.maxColumns ?? 8;
const targetTileWidth = options.targetTileWidth ?? 200;
const maxLanes = options.maxLanes ?? 8;
const minColumns = options.minColumns ?? 1;
if (containerWidth <= 0) return minColumns;
const columns = Math.floor((containerWidth + itemGutter) / (desiredItemWidth + itemGutter));
return Math.max(minColumns, Math.min(columns, maxColumns));
const columns = Math.floor((containerWidth + tileSpacing) / (targetTileWidth + tileSpacing));
return Math.max(minColumns, Math.min(columns, maxLanes));
}
@@ -1,14 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useMasonryGridNavigation} from '@app/features/app/hooks/useMasonryGridNavigation';
import {MasonryListComputer} from '@app/features/channel/components/MasonryListComputer';
import {MASONRY_OVERSCAN_PX, MASONRY_PADDING_PX} from '@app/features/channel/components/pickers/shared/PickerConstants';
import {
MASONRY_OVERSCAN_PX,
MASONRY_PADDING_PX,
MASONRY_SCROLL_CHUNK_PX,
} from '@app/features/channel/components/pickers/shared/PickerConstants';
import {TileFlowSolver} from '@app/features/channel/components/TileFlowSolver';
import type * as React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
type VisibleItemTuple = [itemKey: string, sectionIndex: number, itemIndex: number];
interface Coords {
interface TileBox {
position: 'absolute' | 'sticky';
left?: number;
right?: number;
@@ -17,18 +21,18 @@ interface Coords {
height: number;
}
type CoordsMap = Record<string, Coords>;
type VisibleSections = Record<string, Array<VisibleItemTuple>>;
type TileBoxMap = Record<string, TileBox>;
type WindowedTiles = Record<string, Array<VisibleItemTuple>>;
interface GridCoordinates {
interface GridCell {
section: number;
row: number;
column: number;
}
interface GridData {
boundaries: Array<number>;
coordinates: Record<string, GridCoordinates>;
interface NavGrid {
rowBase: Array<number>;
tileCells: Record<string, GridCell>;
}
export interface MasonryExtraSection {
@@ -43,12 +47,12 @@ export function MasonryVirtualGrid<T>({
data,
itemKeys,
columns,
itemGutter,
tileSpacing,
viewportWidth,
viewportHeight,
scrollTop,
getItemKey,
getItemHeight,
tileKeyOf,
tileHeightOf,
onSelectItemKey,
checkSuspension,
renderItem,
@@ -61,15 +65,15 @@ export function MasonryVirtualGrid<T>({
data: ReadonlyArray<T>;
itemKeys: ReadonlyArray<string>;
columns: number;
itemGutter: number;
tileSpacing: number;
viewportWidth: number;
viewportHeight: number;
scrollTop: number;
getItemKey: (item: T, index: number) => string;
getItemHeight: (item: T, index: number, columnWidth: number) => number;
tileKeyOf: (item: T, index: number) => string;
tileHeightOf: (item: T, index: number, laneWidth: number) => number;
onSelectItemKey: (itemKey: string) => void;
checkSuspension: () => boolean;
renderItem: (args: {item: T; itemKey: string; coords: Coords; isFocused: boolean; index: number}) => React.ReactNode;
renderItem: (args: {item: T; itemKey: string; coords: TileBox; isFocused: boolean; index: number}) => React.ReactNode;
onContentSizeChange?: (contentSize: number) => void;
extraSections?: ReadonlyArray<MasonryExtraSection>;
overscanPx?: number;
@@ -77,31 +81,31 @@ export function MasonryVirtualGrid<T>({
bottomPaddingPx?: number;
}) {
const stableExtraSections = extraSections ?? EMPTY_EXTRA_SECTIONS;
const [masonryComputer] = useState(() => new MasonryListComputer());
const [masonrySolver] = useState(() => new TileFlowSolver());
const containerRef = useRef<HTMLDivElement>(null);
const [version, setVersion] = useState(0);
useEffect(() => {
setVersion((v) => v + 1);
}, [data, columns, itemGutter, viewportWidth, viewportHeight, itemKeys, stableExtraSections]);
}, [data, columns, tileSpacing, viewportWidth, viewportHeight, itemKeys, stableExtraSections]);
const sectionCount = 1 + stableExtraSections.length;
const getItemKeyForComputer = useCallback(
const tileKeyForSolver = useCallback(
(sectionIndex: number, itemIndex: number): string | null => {
if (sectionIndex !== 0) return null;
const item = data[itemIndex];
return item != null ? getItemKey(item, itemIndex) : null;
return item != null ? tileKeyOf(item, itemIndex) : null;
},
[data, getItemKey],
[data, tileKeyOf],
);
const getItemHeightForComputer = useCallback(
(sectionIndex: number, itemIndex: number, columnWidth: number): number => {
const tileHeightForSolver = useCallback(
(sectionIndex: number, itemIndex: number, laneWidth: number): number => {
if (sectionIndex !== 0) return 0;
const item = data[itemIndex];
if (item == null) return 0;
return getItemHeight(item, itemIndex, columnWidth);
return tileHeightOf(item, itemIndex, laneWidth);
},
[data, getItemHeight],
[data, tileHeightOf],
);
const getSectionHeightForComputer = useCallback(
const sectionHeightForSolver = useCallback(
(sectionIndex: number): number => {
if (sectionIndex === 0) return 0;
const extra = stableExtraSections.find((s) => s.sectionIndex === sectionIndex);
@@ -109,54 +113,59 @@ export function MasonryVirtualGrid<T>({
},
[stableExtraSections],
);
const scrollChunkPx = Math.max(1, Math.min(MASONRY_SCROLL_CHUNK_PX, overscanPx));
const windowStartChunk = Math.floor(scrollTop / scrollChunkPx);
const windowEndChunk = Math.ceil((scrollTop + viewportHeight) / scrollChunkPx);
const masonryState = useMemo(() => {
if (viewportWidth <= 0 || viewportHeight <= 0) {
return {
coordsMap: {} as CoordsMap,
visibleSections: {} as VisibleSections,
totalHeight: 0,
gridData: null,
tileBoxes: {} as TileBoxMap,
windowedTiles: {} as WindowedTiles,
contentHeight: 0,
navGrid: null,
};
}
masonryComputer.mergeProps({
sections: [data.length, ...Array.from({length: sectionCount - 1}, () => 0)],
columns,
itemGutter,
getItemKey: getItemKeyForComputer,
getItemHeight: getItemHeightForComputer,
getSectionHeight: getSectionHeightForComputer,
bufferWidth: viewportWidth,
padding: {left: paddingPx, right: paddingPx, top: 0, bottom: 0},
version,
masonrySolver.applySettings({
sectionSizes: [data.length, ...Array.from({length: sectionCount - 1}, () => 0)],
laneCount: columns,
tileSpacing,
tileKeyOf: tileKeyForSolver,
tileHeightOf: tileHeightForSolver,
sectionHeightOf: sectionHeightForSolver,
viewportSpan: viewportWidth,
padBox: {left: paddingPx, right: paddingPx, top: 0, bottom: 0},
layoutStamp: version,
});
const start = Math.max(0, scrollTop - overscanPx);
const end = scrollTop + viewportHeight + overscanPx;
masonryComputer.computeVisibleSections(start, end);
const state = masonryComputer.getState() as {
coordsMap: CoordsMap;
visibleSections: VisibleSections;
totalHeight: number;
gridData: GridData;
const start = Math.max(0, windowStartChunk * scrollChunkPx - overscanPx);
const end = windowEndChunk * scrollChunkPx + overscanPx;
masonrySolver.selectWindow(start, end);
const state = masonrySolver.readLayout() as {
tileBoxes: TileBoxMap;
windowedTiles: WindowedTiles;
contentHeight: number;
navGrid: NavGrid;
};
return state;
}, [
masonryComputer,
masonrySolver,
data.length,
sectionCount,
columns,
itemGutter,
getItemKeyForComputer,
getItemHeightForComputer,
getSectionHeightForComputer,
tileSpacing,
tileKeyForSolver,
tileHeightForSolver,
sectionHeightForSolver,
viewportWidth,
viewportHeight,
scrollTop,
windowStartChunk,
windowEndChunk,
scrollChunkPx,
overscanPx,
paddingPx,
version,
]);
const {focusedItemKey} = useMasonryGridNavigation({
gridData: masonryState.gridData,
navGrid: masonryState.navGrid,
itemKeys,
columns,
onSelect: onSelectItemKey,
@@ -164,11 +173,11 @@ export function MasonryVirtualGrid<T>({
checkSuspension,
});
const topPadding = paddingPx;
const contentSize = masonryState.totalHeight + topPadding + bottomPaddingPx;
const contentSize = masonryState.contentHeight + topPadding + bottomPaddingPx;
useEffect(() => {
onContentSizeChange?.(contentSize);
}, [contentSize, onContentSizeChange]);
const visibleEntries = Object.entries(masonryState.visibleSections) as Array<[string, VisibleSections[string]]>;
const visibleEntries = Object.entries(masonryState.windowedTiles) as Array<[string, WindowedTiles[string]]>;
const parseSectionIndex = (sectionKey: string): number | null => {
const prefix = 'section-';
if (!sectionKey.startsWith(prefix)) return null;
@@ -189,7 +198,7 @@ export function MasonryVirtualGrid<T>({
data-flx="channel.pickers.masonry-virtual-grid.div"
>
{visibleEntries.flatMap(([sectionKey, items]): Array<React.ReactNode> => {
const sectionCoords = masonryState.coordsMap[sectionKey];
const sectionCoords = masonryState.tileBoxes[sectionKey];
if (!sectionCoords) return [];
const sectionTop = (sectionCoords.top ?? 0) + topPadding;
const sectionIndex = parseSectionIndex(sectionKey);
@@ -216,11 +225,11 @@ export function MasonryVirtualGrid<T>({
}
return items.flatMap(([itemKey, itemSectionIndex, itemIndex]): Array<React.ReactNode> => {
if (itemSectionIndex !== 0) return [];
const itemCoords = masonryState.coordsMap[itemKey];
const itemCoords = masonryState.tileBoxes[itemKey];
if (!itemCoords) return [];
const item = data[itemIndex];
if (item == null) return [];
const absoluteItemCoords: Coords = {
const absoluteItemCoords: TileBox = {
...itemCoords,
top: (itemCoords.top ?? 0) + sectionTop,
};
@@ -2,3 +2,4 @@
export const MASONRY_OVERSCAN_PX = 480;
export const MASONRY_PADDING_PX = 12;
export const MASONRY_SCROLL_CHUNK_PX = 128;