From dceb9061d9c54d50c215df25e7b6c63275c8d430 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:51:13 +0200 Subject: [PATCH] perf(app): recompute picker grid layout in scroll chunks (#1947) --- fluxer_app/package.json | 4 +- .../app/hooks/useMasonryGridNavigation.ts | 36 +- .../channel/components/MasonryListComputer.ts | 362 ------------------ .../channel/components/TileFlowSolver.ts | 357 +++++++++++++++++ .../components/pickers/gif/GifPickerGrid.tsx | 14 +- .../gif/GifPickerLoadingSkeletonGridLayout.ts | 32 +- .../components/pickers/memes/MemesGrid.tsx | 10 +- .../pickers/shared/ComputeColumns.ts | 14 +- .../pickers/shared/MasonryVirtualGrid.tsx | 129 ++++--- .../pickers/shared/PickerConstants.ts | 1 + 10 files changed, 478 insertions(+), 481 deletions(-) delete mode 100644 fluxer_app/src/features/channel/components/MasonryListComputer.ts create mode 100644 fluxer_app/src/features/channel/components/TileFlowSolver.ts diff --git a/fluxer_app/package.json b/fluxer_app/package.json index 6975390ad..108dfe1b2 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -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", diff --git a/fluxer_app/src/features/app/hooks/useMasonryGridNavigation.ts b/fluxer_app/src/features/app/hooks/useMasonryGridNavigation.ts index f126c3154..1b00897fd 100644 --- a/fluxer_app/src/features/app/hooks/useMasonryGridNavigation.ts +++ b/fluxer_app/src/features/app/hooks/useMasonryGridNavigation.ts @@ -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; 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(null); const focusedItemRef = useRef(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, diff --git a/fluxer_app/src/features/channel/components/MasonryListComputer.ts b/fluxer_app/src/features/channel/components/MasonryListComputer.ts deleted file mode 100644 index 29f544246..000000000 --- a/fluxer_app/src/features/channel/components/MasonryListComputer.ts +++ /dev/null @@ -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; - boundaries: Array; -} - -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] { - 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 = {}; - public gridData: GridData = {coordinates: {}, boundaries: []}; - public coordsMap: Record = {}; - public itemGrid: Array> = []; - public totalHeight: number = 0; - private columnHeights: Array = []; - private columnWidth: number = 0; - private currentRow: number = 0; - private lastColumnIndex: number = 0; - private needsFullCompute: boolean = true; - private bufferWidth: number = 0; - private sections: Array = []; - 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; - 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 = this.columnHeights): number { - return columnHeights.reduce((max, height) => Math.max(max, height), 0); - } - - getState(): { - coordsMap: Record; - gridData: GridData; - visibleSections: Record; - totalHeight: number; - } { - return { - coordsMap: this.coordsMap, - gridData: this.gridData, - visibleSections: this.visibleSections, - totalHeight: this.totalHeight, - }; - } -} diff --git a/fluxer_app/src/features/channel/components/TileFlowSolver.ts b/fluxer_app/src/features/channel/components/TileFlowSolver.ts new file mode 100644 index 000000000..52eebd67f --- /dev/null +++ b/fluxer_app/src/features/channel/components/TileFlowSolver.ts @@ -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; + rowBase: Array; +} + +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] { + 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 = {}; + public navGrid: NavGrid = {tileCells: {}, rowBase: []}; + public tileBoxes: Record = {}; + public laneOrder: Array> = []; + public contentHeight: number = 0; + private laneFill: Array = []; + private laneWidth: number = 0; + private rowCursor: number = 0; + private prevLaneIndex: number = 0; + private layoutDirty: boolean = true; + private viewportSpan: number = 0; + private sectionSizes: Array = []; + 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; + 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 = this.laneFill): number { + return laneFill.reduce((max, height) => Math.max(max, height), 0); + } + + readLayout(): { + tileBoxes: Record; + navGrid: NavGrid; + windowedTiles: Record; + contentHeight: number; + } { + return { + tileBoxes: this.tileBoxes, + navGrid: this.navGrid, + windowedTiles: this.windowedTiles, + contentHeight: this.contentHeight, + }; + } +} diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGrid.tsx b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGrid.tsx index f1b646021..697969ae9 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGrid.tsx +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGrid.tsx @@ -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={[ { diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.ts index 452ad910c..6a50c2a6c 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.ts +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.ts @@ -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 { 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 = []; 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 { +function findShortestColumnIndex(laneFill: ReadonlyArray): 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; diff --git a/fluxer_app/src/features/channel/components/pickers/memes/MemesGrid.tsx b/fluxer_app/src/features/channel/components/pickers/memes/MemesGrid.tsx index 943f1c45c..455095b41 100644 --- a/fluxer_app/src/features/channel/components/pickers/memes/MemesGrid.tsx +++ b/fluxer_app/src/features/channel/components/pickers/memes/MemesGrid.tsx @@ -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}) => ( ; -type VisibleSections = Record>; +type TileBoxMap = Record; +type WindowedTiles = Record>; -interface GridCoordinates { +interface GridCell { section: number; row: number; column: number; } -interface GridData { - boundaries: Array; - coordinates: Record; +interface NavGrid { + rowBase: Array; + tileCells: Record; } export interface MasonryExtraSection { @@ -43,12 +47,12 @@ export function MasonryVirtualGrid({ data, itemKeys, columns, - itemGutter, + tileSpacing, viewportWidth, viewportHeight, scrollTop, - getItemKey, - getItemHeight, + tileKeyOf, + tileHeightOf, onSelectItemKey, checkSuspension, renderItem, @@ -61,15 +65,15 @@ export function MasonryVirtualGrid({ data: ReadonlyArray; itemKeys: ReadonlyArray; 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; overscanPx?: number; @@ -77,31 +81,31 @@ export function MasonryVirtualGrid({ bottomPaddingPx?: number; }) { const stableExtraSections = extraSections ?? EMPTY_EXTRA_SECTIONS; - const [masonryComputer] = useState(() => new MasonryListComputer()); + const [masonrySolver] = useState(() => new TileFlowSolver()); const containerRef = useRef(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({ }, [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({ 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({ data-flx="channel.pickers.masonry-virtual-grid.div" > {visibleEntries.flatMap(([sectionKey, items]): Array => { - 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({ } return items.flatMap(([itemKey, itemSectionIndex, itemIndex]): Array => { 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, }; diff --git a/fluxer_app/src/features/channel/components/pickers/shared/PickerConstants.ts b/fluxer_app/src/features/channel/components/pickers/shared/PickerConstants.ts index 5da1aed47..7c10df027 100644 --- a/fluxer_app/src/features/channel/components/pickers/shared/PickerConstants.ts +++ b/fluxer_app/src/features/channel/components/pickers/shared/PickerConstants.ts @@ -2,3 +2,4 @@ export const MASONRY_OVERSCAN_PX = 480; export const MASONRY_PADDING_PX = 12; +export const MASONRY_SCROLL_CHUNK_PX = 128;