perf: row-virtualize Timeline instead of per-month
Each month was a single virtual item that rendered all of its tiles, so scrolling into a busy month mounted thousands of ImageTiles at once. Flatten months into a fixed-height row list (header rows + tile rows of `cols` images), mirroring the Gallery grid, so only on-screen rows render and thumbnails stream in as you scroll. Active-month tracking and scrubber jump-to-month are remapped to the flat row model.
This commit is contained in:
+90
-66
@@ -14,6 +14,11 @@ interface TimelineGroup {
|
|||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One virtualized row: either a month header or a row of up to `cols` tiles.
|
||||||
|
type TimelineRow =
|
||||||
|
| { type: "header"; group: TimelineGroup }
|
||||||
|
| { type: "tiles"; images: ImageRecord[] };
|
||||||
|
|
||||||
interface ScrubberMonth {
|
interface ScrubberMonth {
|
||||||
monthNum: number;
|
monthNum: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -123,27 +128,46 @@ export function Timeline() {
|
|||||||
[containerWidth, tileSize],
|
[containerWidth, tileSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Flatten the month groups into a single list of fixed-height rows — one
|
||||||
|
// header row per group, then one tile-row per `cols` images. This lets the
|
||||||
|
// virtualizer render only the on-screen rows, exactly like the Gallery.
|
||||||
|
// Previously each *group* was one virtual item that rendered ALL of its
|
||||||
|
// images, so scrolling into a busy month mounted thousands of tiles at once.
|
||||||
|
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
|
||||||
|
const rows: TimelineRow[] = [];
|
||||||
|
const rowToGroupIndex: number[] = [];
|
||||||
|
const groupFirstRow: number[] = [];
|
||||||
|
groups.forEach((group, groupIndex) => {
|
||||||
|
groupFirstRow[groupIndex] = rows.length;
|
||||||
|
rows.push({ type: "header", group });
|
||||||
|
rowToGroupIndex.push(groupIndex);
|
||||||
|
for (let i = 0; i < group.images.length; i += cols) {
|
||||||
|
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
|
||||||
|
rowToGroupIndex.push(groupIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { rows, rowToGroupIndex, groupFirstRow };
|
||||||
|
}, [groups, cols]);
|
||||||
|
|
||||||
const estimateSize = useCallback(
|
const estimateSize = useCallback(
|
||||||
(index: number): number => {
|
(index: number): number =>
|
||||||
const group = groups[index];
|
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
|
||||||
if (!group) return HEADER_HEIGHT;
|
[rows, tileSize],
|
||||||
const rowCount = Math.ceil(group.images.length / cols);
|
|
||||||
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
|
|
||||||
},
|
|
||||||
[groups, cols, tileSize],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: groups.length,
|
count: rows.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize,
|
estimateSize,
|
||||||
overscan: 2,
|
overscan: 6,
|
||||||
|
paddingStart: GAP,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupsRef = useRef(groups);
|
// Refs so the scroll handler can read the latest mappings without re-binding.
|
||||||
groupsRef.current = groups;
|
const rowToGroupIndexRef = useRef(rowToGroupIndex);
|
||||||
const estimateSizeRef = useRef(estimateSize);
|
rowToGroupIndexRef.current = rowToGroupIndex;
|
||||||
estimateSizeRef.current = estimateSize;
|
const groupFirstRowRef = useRef(groupFirstRow);
|
||||||
|
groupFirstRowRef.current = groupFirstRow;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
@@ -153,28 +177,24 @@ export function Timeline() {
|
|||||||
const el = parentRef.current;
|
const el = parentRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
// Active month = the group owning the first row still visible at the top.
|
||||||
const scrollTop = el.scrollTop;
|
const scrollTop = el.scrollTop;
|
||||||
const g = groupsRef.current;
|
const items = virtualizer.getVirtualItems();
|
||||||
const es = estimateSizeRef.current;
|
let activeRow = items.length > 0 ? items[0].index : 0;
|
||||||
let cumHeight = 0;
|
for (const item of items) {
|
||||||
let newActive = 0;
|
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
|
||||||
for (let i = 0; i < g.length; i++) {
|
activeRow = item.index;
|
||||||
const h = es(i);
|
|
||||||
if (cumHeight + h > scrollTop + HEADER_HEIGHT / 2) {
|
|
||||||
newActive = i;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
cumHeight += h;
|
|
||||||
newActive = i;
|
|
||||||
}
|
}
|
||||||
setActiveGroupIndex(newActive);
|
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
|
||||||
|
|
||||||
if (el.scrollTop < 24) return;
|
if (scrollTop < 24) return;
|
||||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
|
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
|
||||||
if (nearBottom && !loadingImages && images.length < totalImages) {
|
if (nearBottom && !loadingImages && images.length < totalImages) {
|
||||||
void loadMoreImages();
|
void loadMoreImages();
|
||||||
}
|
}
|
||||||
}, [images.length, loadMoreImages, loadingImages, totalImages]);
|
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = parentRef.current;
|
const el = parentRef.current;
|
||||||
@@ -200,8 +220,9 @@ export function Timeline() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToGroup = useCallback(
|
const scrollToGroup = useCallback(
|
||||||
(index: number) => {
|
(groupIndex: number) => {
|
||||||
virtualizer.scrollToIndex(index, { align: "start" });
|
const row = groupFirstRowRef.current[groupIndex] ?? 0;
|
||||||
|
virtualizer.scrollToIndex(row, { align: "start" });
|
||||||
},
|
},
|
||||||
[virtualizer],
|
[virtualizer],
|
||||||
);
|
);
|
||||||
@@ -250,8 +271,8 @@ export function Timeline() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const group = groups[virtualItem.index];
|
const row = rows[virtualItem.index];
|
||||||
if (!group) return null;
|
if (!row) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
@@ -262,41 +283,44 @@ export function Timeline() {
|
|||||||
height: virtualItem.size,
|
height: virtualItem.size,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{row.type === "header" ? (
|
||||||
className="flex items-center gap-3 px-4"
|
<div
|
||||||
style={{ height: HEADER_HEIGHT }}
|
className="flex items-center gap-3 px-4"
|
||||||
>
|
style={{ height: HEADER_HEIGHT }}
|
||||||
<span className="text-sm font-semibold text-white/80 shrink-0">
|
>
|
||||||
{group.label}
|
<span className="text-sm font-semibold text-white/80 shrink-0">
|
||||||
</span>
|
{row.group.label}
|
||||||
<span className="text-xs text-white/25 shrink-0 tabular-nums">
|
</span>
|
||||||
{group.images.length}
|
<span className="text-xs text-white/25 shrink-0 tabular-nums">
|
||||||
</span>
|
{row.group.images.length}
|
||||||
<div className="flex-1 h-px bg-white/[0.06]" />
|
</span>
|
||||||
</div>
|
<div className="flex-1 h-px bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
<div
|
) : (
|
||||||
style={{
|
<div
|
||||||
display: "grid",
|
style={{
|
||||||
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
display: "grid",
|
||||||
gap: GAP,
|
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
||||||
paddingLeft: GAP,
|
gap: GAP,
|
||||||
paddingRight: GAP,
|
paddingLeft: GAP,
|
||||||
paddingBottom: GAP,
|
paddingRight: GAP,
|
||||||
}}
|
paddingBottom: GAP,
|
||||||
>
|
boxSizing: "border-box",
|
||||||
{group.images.map((image) => (
|
}}
|
||||||
<ImageTile
|
>
|
||||||
key={image.id}
|
{row.images.map((image) => (
|
||||||
image={image}
|
<ImageTile
|
||||||
onClick={() => openImage(image)}
|
key={image.id}
|
||||||
onContextMenu={(event) => {
|
image={image}
|
||||||
event.preventDefault();
|
onClick={() => openImage(image)}
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
onContextMenu={(event) => {
|
||||||
}}
|
event.preventDefault();
|
||||||
/>
|
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
||||||
))}
|
}}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user