fluxer/fluxer_app/src/components/channel/MasonryListComputer.ts
2026-01-01 21:05:54 +00:00

400 lines
12 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
interface CoordsStyle {
position: 'absolute' | 'sticky';
left?: number;
right?: number;
width: number;
top: number;
height: number;
}
interface GridCoordinates {
section: number;
row: number;
column: number;
}
interface GridData {
boundaries: Array<number>;
coordinates: Record<string, GridCoordinates>;
}
type VisibleSection = Array<[string, number, number]>;
type Padding = number | {top?: number; bottom?: number; left?: number; right?: number};
function findMinColumnIndex(columnHeights: Array<number>): [number, number] {
return columnHeights.reduce((acc, height, index) => (height < acc[0] ? [height, index] : acc), [columnHeights[0], 0]);
}
const defaultGetSectionHeight = () => 0;
const getSectionKey = (sectionIndex: number) => `__section__${sectionIndex}`;
const getSectionHeaderKey = (sectionIndex: number) => `__section_header__${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,
};
}
}