Switch to lazy loaded tracks to support large PDFs

This commit is contained in:
James Brunton
2026-08-24 10:04:27 +01:00
parent 49f9aa4a69
commit 8311eb1c23
6 changed files with 187 additions and 35 deletions
@@ -19,8 +19,8 @@
/* ── Track ───────────────────────────────────────────────────────────────── */
.track {
--pt-tile-h: 11.5rem;
--pt-tile-w: 8.5rem;
/* --pt-tile-w / --pt-tile-h / --pt-tile-footer-h / --pt-gap come from
constants.ts, so the virtualiser and the CSS agree on tile geometry. */
/* The scroller is a flex column: without this, an overflowing stack of
tracks squeezes each lane below its tiles and clips them. */
flex: 0 0 auto;
@@ -71,19 +71,25 @@
}
.lane {
display: flex;
align-items: flex-start;
gap: 0.5rem;
position: relative;
padding: 0.75rem;
overflow-x: auto;
overflow-y: hidden;
}
/* Sized by the virtualiser; the tiles inside are absolutely placed, so the
height has to be stated rather than derived from content. */
.laneInner {
position: relative;
height: calc(var(--pt-tile-h) + var(--pt-tile-footer-h));
}
.laneHint {
pointer-events: none;
}
.laneEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(var(--pt-tile-h) + 2.5rem);
@@ -94,8 +100,8 @@
/* ── Tile ────────────────────────────────────────────────────────────────── */
.tile {
position: relative;
flex: 0 0 auto;
position: absolute;
top: 0;
width: var(--pt-tile-w);
cursor: grab;
user-select: none;
@@ -161,7 +167,8 @@
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding: 0.25rem 0.375rem;
height: var(--pt-tile-footer-h);
padding: 0 0.375rem;
font-size: 0.6875rem;
color: var(--c-text-muted);
min-width: 0;
@@ -42,6 +42,13 @@ const collisionDetection: CollisionDetection = (args) => {
return rectIntersection(args);
};
const sameHint = (a: DropHint | null, b: DropHint | null): boolean =>
a === b ||
(a != null &&
b != null &&
a.fileId === b.fileId &&
a.beforePageId === b.beforePageId);
/**
* The pointer x dnd-kit is itself working from: the activator's position plus
* the drag delta. Using this rather than a live pointermove listener keeps the
@@ -182,12 +189,13 @@ export default function PageTracks() {
// latter. Recomputing per move is what keeps the marker and the drop in sync.
const handleDragMove = useCallback(
(event: DragMoveEvent) => {
setDropHint(
resolveHint(
event.over ? String(event.over.id) : null,
pointerXOf(event),
),
const next = resolveHint(
event.over ? String(event.over.id) : null,
pointerXOf(event),
);
// Most moves land on the same side of the same tile. Keeping the previous
// object bails the re-render out, so only a real change costs anything.
setDropHint((prev) => (sameHint(prev, next) ? prev : next));
},
[resolveHint],
);
@@ -24,6 +24,8 @@ export interface TrackPageTileProps {
trackFileId: FileId;
/** 1-based position within the track. */
position: number;
/** Horizontal offset within the lane, from the virtualiser. */
offsetX: number;
selected: boolean;
dragging: boolean;
/** Draw the insertion line on this tile's leading edge. */
@@ -44,6 +46,7 @@ function TrackPageTileImpl({
page,
trackFileId,
position,
offsetX,
selected,
dragging,
dropBefore,
@@ -98,6 +101,7 @@ function TrackPageTileImpl({
]
.filter(Boolean)
.join(" ")}
style={{ left: offsetX }}
{...attributes}
{...listeners}
data-page-id={page.id}
@@ -1,4 +1,5 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useDroppable } from "@dnd-kit/core";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
@@ -12,6 +13,10 @@ import { Track } from "@app/components/pageTracks/types";
import { TrackThumbnailStore } from "@app/components/pageTracks/hooks/useTrackThumbnails";
import { PageClickModifiers } from "@app/components/pageTracks/hooks/useTrackSelection";
import TrackPageTile from "@app/components/pageTracks/TrackPageTile";
import {
TRACK_GEOMETRY,
rootFontSizePx,
} from "@app/components/pageTracks/constants";
import styles from "@app/components/pageTracks/PageTracks.module.css";
export const trackDroppableId = (fileId: FileId) => `track:${fileId}`;
@@ -64,6 +69,40 @@ function TrackRowImpl({
data: { type: "track", fileId: track.fileId },
});
// A lane can hold hundreds of pages. Mounting them all is what made a single
// click cost ~700ms and a drag ~300ms per pointer move: every tile is a
// dnd-kit draggable AND droppable, so the whole set gets re-registered on
// each render, re-measured on drag start and hit-tested on every move.
const laneRef = useRef<HTMLDivElement | null>(null);
const geometry = useMemo(() => {
const px = rootFontSizePx();
return {
tileWidth: TRACK_GEOMETRY.tileWidthRem * px,
stride: (TRACK_GEOMETRY.tileWidthRem + TRACK_GEOMETRY.gapRem) * px,
cssVars: {
"--pt-tile-w": `${TRACK_GEOMETRY.tileWidthRem}rem`,
"--pt-tile-h": `${TRACK_GEOMETRY.tileCanvasHeightRem}rem`,
"--pt-tile-footer-h": `${TRACK_GEOMETRY.tileFooterHeightRem}rem`,
} as React.CSSProperties,
};
}, []);
const virtualizer = useVirtualizer({
count: track.pages.length,
horizontal: true,
getScrollElement: () => laneRef.current,
estimateSize: () => geometry.stride,
overscan: TRACK_GEOMETRY.overscan,
});
const setLaneRef = useCallback(
(element: HTMLDivElement | null) => {
laneRef.current = element;
setNodeRef(element);
},
[setNodeRef],
);
// Track-level actions apply to the selection inside this track, falling back
// to the whole track so the buttons stay useful with nothing selected.
const targetIds = useMemo(() => {
@@ -84,6 +123,7 @@ function TrackRowImpl({
return (
<section
style={geometry.cssVars}
className={[styles.track, isOver ? styles.trackDropActive : ""]
.filter(Boolean)
.join(" ")}
@@ -164,7 +204,7 @@ function TrackRowImpl({
</header>
<div
ref={setNodeRef}
ref={setLaneRef}
data-track-lane={track.fileId}
className={[
styles.lane,
@@ -187,26 +227,38 @@ function TrackRowImpl({
)}
</span>
)}
{track.pages.map((page, index) => (
<TrackPageTile
key={page.id}
page={page}
trackFileId={track.fileId}
position={index + 1}
selected={selectedIds.has(page.id)}
dragging={draggingIds.has(page.id)}
dropBefore={hintActive && dropHint?.beforePageId === page.id}
dropAfterLast={
hintActive &&
dropHint?.beforePageId == null &&
index === track.pages.length - 1
}
thumbnails={thumbnails}
onSelect={onSelectPage}
onRotate={onRotate}
onDelete={onDelete}
/>
))}
{track.pages.length > 0 && (
<div
className={styles.laneInner}
style={{ width: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((item) => {
const page = track.pages[item.index];
if (!page) return null;
return (
<TrackPageTile
key={page.id}
page={page}
trackFileId={track.fileId}
position={item.index + 1}
offsetX={item.start}
selected={selectedIds.has(page.id)}
dragging={draggingIds.has(page.id)}
dropBefore={hintActive && dropHint?.beforePageId === page.id}
dropAfterLast={
hintActive &&
dropHint?.beforePageId == null &&
item.index === track.pages.length - 1
}
thumbnails={thumbnails}
onSelect={onSelectPage}
onRotate={onRotate}
onDelete={onDelete}
/>
);
})}
</div>
)}
</div>
</section>
);
@@ -0,0 +1,21 @@
/**
* Lane geometry. Single source of truth: the numbers are pushed onto the track
* element as CSS custom properties AND used by the horizontal virtualiser, so
* the two can never drift apart.
*/
export const TRACK_GEOMETRY = {
tileWidthRem: 8.5,
tileCanvasHeightRem: 11.5,
tileFooterHeightRem: 1.375,
gapRem: 0.5,
/** Extra tiles rendered either side of the visible window. */
overscan: 6,
} as const;
export const rootFontSizePx = (): number => {
if (typeof window === "undefined") return 16;
const parsed = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
return Number.isNaN(parsed) ? 16 : parsed;
};
@@ -1,5 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PDFDocument, StandardFonts } from "@cantoo/pdf-lib";
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles, dismissTourTooltip } from "@app/tests/helpers/ui-helpers";
@@ -415,4 +419,60 @@ test.describe("Page Editor tracks", () => {
page.locator('[data-page-id][data-selected="true"]'),
).toHaveCount(2);
});
test("a long track mounts only a window of its pages", async ({ page }) => {
// Built here rather than committed as a fixture: the point is the page
// COUNT, and 300 pages of real PDF is a lot of bytes to carry in the repo.
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
for (let i = 1; i <= 300; i++) {
doc
.addPage([595, 842])
.drawText(`page ${i}`, { x: 60, y: 700, size: 28, font });
}
const longPdf = path.join(os.tmpdir(), `tracks-long-${process.pid}.pdf`);
fs.writeFileSync(longPdf, await doc.save());
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/editor", {
waitUntil: "domcontentloaded",
timeout: 120_000,
});
await uploadFiles(page, longPdf);
await dismissTourTooltip(page);
await switchView(page, "Page Editor");
const lane = page.locator("[data-track-lane]");
await expect(page.locator("section[aria-label]").first()).toContainText(
"300 pages",
{ timeout: 60_000 },
);
// Mounting all 300 is what made a click cost ~700ms and a drag ~300ms per
// pointer move, since every tile is a dnd-kit draggable AND droppable.
const mounted = await page.locator("[data-page-id]").count();
expect(mounted).toBeGreaterThan(0);
expect(mounted).toBeLessThan(40);
// The window follows the lane's scroll rather than being a fixed prefix.
const firstBefore = await page
.locator("[data-page-id]")
.first()
.getAttribute("data-page-id");
await lane.evaluate((el) => {
el.scrollLeft = 9000;
});
await expect
.poll(
async () =>
page.locator("[data-page-id]").first().getAttribute("data-page-id"),
{ timeout: 30_000 },
)
.not.toBe(firstBefore);
expect(await page.locator("[data-page-id]").count()).toBeLessThan(40);
} finally {
fs.rmSync(longPdf, { force: true });
}
});
});