mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
perf(app): recompute picker grid layout in scroll chunks (#1947)
This commit is contained in:
1 parent
21438b2d8f
commit
dceb9061d9
10 files changed
+478
-481
No files matched your search
@@ -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={[
|
||||
{
|
||||
|
||||
+16
-16
@@ -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;
|
||||
Reference in new issue
Block a user