Make tracks draggable to reorder

This commit is contained in:
James Brunton
2026-08-24 10:04:27 +01:00
parent 673df18b30
commit 8f12fa8128
6 changed files with 343 additions and 14 deletions
@@ -19,6 +19,7 @@
/* ── Track ───────────────────────────────────────────────────────────────── */
.track {
position: relative;
/* --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
@@ -36,6 +37,7 @@
}
.trackHeader {
cursor: grab;
display: flex;
align-items: center;
gap: var(--space-2);
@@ -231,6 +233,37 @@
pointer-events: auto;
}
/* ── Track reorder ───────────────────────────────────────────────────────── */
.trackDragging {
opacity: 0.4;
}
/* Same trick as the page marker: drawn on the track rather than inserted
between them, so nothing shifts while the pointer is deciding. */
.trackDropBefore::before,
.trackDropAfterLast::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 3px;
border-radius: 999px;
background: var(--c-primary);
pointer-events: none;
z-index: 3;
}
/* Drawn just inside the edge, not in the gap above it: .track clips its own
overflow to round the lane's corners, so an outside offset is invisible. */
.trackDropBefore::before {
top: 0;
}
.trackDropAfterLast::after {
bottom: 0;
}
/* ── Drop indicator ──────────────────────────────────────────────────────── */
/*
@@ -14,7 +14,7 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useFileState } from "@app/contexts/FileContext";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import {
useNavigationActions,
useNavigationGuard,
@@ -32,17 +32,30 @@ import styles from "@app/components/pageTracks/PageTracks.module.css";
const PAGE_PREFIX = "page:";
const TRACK_PREFIX = "track:";
const ZONE_PREFIX = "zone:";
const HANDLE_PREFIX = "trackhandle:";
/**
* Prefer the page under the pointer over the track that contains it: the
* lane is a droppable too, so nested hits need an explicit ordering.
* Droppables nest (zone > lane > page), so the hit has to be picked by what is
* being dragged: a track header only ever lands on a whole track, while a page
* prefers the tile under the pointer and falls back to the track around it.
*/
const collisionDetection: CollisionDetection = (args) => {
const within = pointerWithin(args);
const page = within.find((c) => String(c.id).startsWith(PAGE_PREFIX));
const first = (prefix: string) =>
within.find((c) => String(c.id).startsWith(prefix));
if (args.active.data.current?.type === "trackHandle") {
const zone = first(ZONE_PREFIX);
return zone ? [zone] : [];
}
const page = first(PAGE_PREFIX);
if (page) return [page];
const track = within.find((c) => String(c.id).startsWith(TRACK_PREFIX));
if (track) return [track];
const lane = first(TRACK_PREFIX);
if (lane) return [lane];
const zone = first(ZONE_PREFIX);
if (zone) return [zone];
return rectIntersection(args);
};
@@ -58,6 +71,17 @@ const sameHint = (a: DropHint | null, b: DropHint | null): boolean =>
* the drag delta. Using this rather than a live pointermove listener keeps the
* side-of-tile decision consistent with the reported collision.
*/
function pointerYOf(event: DragMoveEvent | DragEndEvent): number {
const activator = event.activatorEvent;
const originY =
activator instanceof MouseEvent
? activator.clientY
: activator instanceof TouchEvent && activator.touches.length > 0
? activator.touches[0].clientY
: 0;
return originY + event.delta.y;
}
function pointerXOf(event: DragMoveEvent | DragEndEvent): number {
const activator = event.activatorEvent;
const originX =
@@ -123,6 +147,11 @@ export default function PageTracks() {
() => new Set<string>(),
);
const [dropHint, setDropHint] = useState<DropHint | null>(null);
const [draggingTrack, setDraggingTrack] = useState<FileId | null>(null);
// undefined = no target, null = append to the end.
const [trackDropTarget, setTrackDropTarget] = useState<
FileId | null | undefined
>(undefined);
const totalPages = useMemo(() => totalPageCount(workspace), [workspace]);
const changedSet = useMemo(() => new Set(changedFileIds), [changedFileIds]);
@@ -152,6 +181,40 @@ export default function PageTracks() {
const clearSelection = selection.clear;
const { actions: fileActions } = useFileActions();
/**
* Moves one track before `beforeFileId` (or to the end when null). Track
* order IS the workbench file order, and REORDER_FILES replaces the whole id
* list, so non-PDFs (which have no track) must be written back in place or
* they would drop out of the workbench entirely.
*/
const reorderTracks = useCallback(
(sourceFileId: FileId, beforeFileId: FileId | null) => {
if (sourceFileId === beforeFileId) return;
const trackOrder = workspace.order.filter((id) => id !== sourceFileId);
const at =
beforeFileId == null
? trackOrder.length
: trackOrder.indexOf(beforeFileId);
const insertAt = at === -1 ? trackOrder.length : at;
const nextTrackOrder = [
...trackOrder.slice(0, insertAt),
sourceFileId,
...trackOrder.slice(insertAt),
];
if (nextTrackOrder.every((id, i) => workspace.order[i] === id)) return;
const isTrack = new Set(workspace.order);
let next = 0;
const merged = fileState.files.ids.map((id) =>
isTrack.has(id) ? nextTrackOrder[next++] : id,
);
fileActions.reorderFiles(merged);
},
[fileActions, fileState.files.ids, workspace.order],
);
// ── Drag and drop ────────────────────────────────────────────────────────
const sensors = useSensors(
@@ -179,8 +242,10 @@ export default function PageTracks() {
(overId: string | null, pointerX: number): DropHint | null => {
if (!overId) return null;
if (overId.startsWith(TRACK_PREFIX)) {
const fileId = overId.slice(TRACK_PREFIX.length) as FileId;
// The lane, or anywhere else on the track: append to it.
for (const prefix of [TRACK_PREFIX, ZONE_PREFIX]) {
if (!overId.startsWith(prefix)) continue;
const fileId = overId.slice(prefix.length) as FileId;
if (!workspace.tracks[fileId]) return null;
return { fileId, beforePageId: null };
}
@@ -206,9 +271,34 @@ export default function PageTracks() {
[trackByPageId, workspace],
);
/**
* Track reorder target: insert before this file, or append when null.
*/
const resolveTrackHint = useCallback(
(overId: string | null, pointerY: number): FileId | null | undefined => {
if (!overId || !overId.startsWith(ZONE_PREFIX)) return undefined;
const overFileId = overId.slice(ZONE_PREFIX.length) as FileId;
const index = workspace.order.indexOf(overFileId);
if (index === -1) return undefined;
const element = document.querySelector<HTMLElement>(
`[data-track-file-id="${overFileId}"]`,
);
const rect = element?.getBoundingClientRect();
const dropAfter = rect ? pointerY > rect.top + rect.height / 2 : false;
return workspace.order[dropAfter ? index + 1 : index] ?? null;
},
[workspace.order],
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const pageId = String(event.active.id).slice(PAGE_PREFIX.length);
const activeId = String(event.active.id);
if (activeId.startsWith(HANDLE_PREFIX)) {
setDraggingTrack(activeId.slice(HANDLE_PREFIX.length) as FileId);
return;
}
const pageId = activeId.slice(PAGE_PREFIX.length);
// Dragging a page that is part of the selection moves the whole
// selection; dragging an unselected page moves only that page.
const ids = selection.selectedIds.has(pageId)
@@ -224,6 +314,14 @@ export default function PageTracks() {
// latter. Recomputing per move is what keeps the marker and the drop in sync.
const handleDragMove = useCallback(
(event: DragMoveEvent) => {
if (draggingTrack) {
const target = resolveTrackHint(
event.over ? String(event.over.id) : null,
pointerYOf(event),
);
setTrackDropTarget(target === undefined ? undefined : target);
return;
}
const next = resolveHint(
event.over ? String(event.over.id) : null,
pointerXOf(event),
@@ -232,11 +330,23 @@ export default function PageTracks() {
// object bails the re-render out, so only a real change costs anything.
setDropHint((prev) => (sameHint(prev, next) ? prev : next));
},
[resolveHint],
[draggingTrack, resolveHint, resolveTrackHint],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
if (draggingTrack) {
const target = resolveTrackHint(
event.over ? String(event.over.id) : null,
pointerYOf(event),
);
const source = draggingTrack;
setDraggingTrack(null);
setTrackDropTarget(undefined);
if (target !== undefined) reorderTracks(source, target);
return;
}
const hint = resolveHint(
event.over ? String(event.over.id) : null,
pointerXOf(event),
@@ -251,12 +361,21 @@ export default function PageTracks() {
beforePageId: hint.beforePageId,
});
},
[dispatch, draggingIds, resolveHint],
[
dispatch,
draggingIds,
draggingTrack,
reorderTracks,
resolveHint,
resolveTrackHint,
],
);
const handleDragCancel = useCallback(() => {
setDraggingIds(new Set());
setDropHint(null);
setDraggingTrack(null);
setTrackDropTarget(undefined);
}, []);
// ── Save + navigation guard ──────────────────────────────────────────────
@@ -388,6 +507,15 @@ export default function PageTracks() {
selectedIds={selection.selectedIds}
draggingIds={draggingIds}
dropHint={dropHint}
trackDropBefore={
draggingTrack != null && trackDropTarget === fileId
}
trackDropAfterLast={
draggingTrack != null &&
trackDropTarget === null &&
fileId === workspace.order[workspace.order.length - 1]
}
trackDragging={draggingTrack === fileId}
changed={changedSet.has(fileId)}
thumbnails={thumbnails}
onSelectPage={selection.selectPage}
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useDroppable } from "@dnd-kit/core";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
@@ -21,6 +21,9 @@ import {
import styles from "@app/components/pageTracks/PageTracks.module.css";
export const trackDroppableId = (fileId: FileId) => `track:${fileId}`;
/** Whole-track drop zone, used when a track header is being dragged. */
export const trackZoneId = (fileId: FileId) => `zone:${fileId}`;
export const trackHandleId = (fileId: FileId) => `trackhandle:${fileId}`;
export interface DropHint {
fileId: FileId;
@@ -35,6 +38,12 @@ export interface TrackRowProps {
selectedIds: Set<string>;
draggingIds: Set<string>;
dropHint: DropHint | null;
/** Draw the track-reorder line above this track. */
trackDropBefore: boolean;
/** Draw it below (last track, moving to the end). */
trackDropAfterLast: boolean;
/** This track's header is the one being dragged. */
trackDragging: boolean;
changed: boolean;
thumbnails: TrackThumbnailStore;
onSelectPage: (
@@ -57,6 +66,9 @@ function TrackRowImpl({
selectedIds,
draggingIds,
dropHint,
trackDropBefore,
trackDropAfterLast,
trackDragging,
changed,
thumbnails,
onSelectPage,
@@ -72,6 +84,21 @@ function TrackRowImpl({
data: { type: "track", fileId: track.fileId },
});
// Reordering tracks: the header is the handle, the whole section the target.
// Only the pointer listeners are applied, deliberately NOT dnd-kit's ARIA
// attributes: those would make the header a role="button" whose accessible
// name is everything inside it, with the real controls nested inside.
const { listeners: handleListeners, setNodeRef: setHandleRef } = useDraggable(
{
id: trackHandleId(track.fileId),
data: { type: "trackHandle", fileId: track.fileId },
},
);
const { setNodeRef: setZoneRef } = useDroppable({
id: trackZoneId(track.fileId),
data: { type: "zone", 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
@@ -126,15 +153,27 @@ function TrackRowImpl({
return (
<section
ref={setZoneRef}
style={geometry.cssVars}
className={[styles.track, isOver ? styles.trackDropActive : ""]
className={[
styles.track,
isOver ? styles.trackDropActive : "",
trackDragging ? styles.trackDragging : "",
trackDropBefore ? styles.trackDropBefore : "",
trackDropAfterLast ? styles.trackDropAfterLast : "",
]
.filter(Boolean)
.join(" ")}
data-track-file-id={track.fileId}
data-changed={changed}
data-track-drop-before={trackDropBefore || undefined}
aria-label={name}
>
<header className={styles.trackHeader}>
<header
ref={setHandleRef}
className={styles.trackHeader}
{...handleListeners}
>
<span className={styles.trackName} title={name}>
{name}
</span>
@@ -184,6 +184,26 @@ describe("trackEditorReducer history", () => {
expect(changedTrackIds(state)).toEqual([A]);
});
it("keeps history when tracks are merely reordered", () => {
let state = twoTracks();
state = trackEditorReducer(state, {
type: "delete",
pageIds: [pagesOf(state, A)[0].id],
});
expect(state.past).toHaveLength(1);
state = sync(state, [source(B, 2), source(A, 3, [0, 90, 0])]);
expect(state.present.order).toEqual([B, A]);
expect(state.past).toHaveLength(1);
// The pending delete survives the reorder rather than being re-baselined.
expect(pagesOf(state, A)).toHaveLength(2);
expect(changedTrackIds(state)).toEqual([A]);
state = trackEditorReducer(state, { type: "undo" });
expect(pagesOf(state, A)).toHaveLength(3);
});
it("clears history on a file-set change, since undo could revive dead pages", () => {
let state = twoTracks();
state = trackEditorReducer(state, {
@@ -118,6 +118,21 @@ function syncSources(
);
if (orderUnchanged && signaturesUnchanged) return state;
// A pure permutation (dragging tracks around) touches no page, so the undo
// history stays valid: only a changed FILE SET can leave an entry pointing at
// pages that no longer exist.
const sameFileSet =
order.length === state.present.order.length &&
order.every((id) => state.present.tracks[id] != null);
if (sameFileSet && signaturesUnchanged) {
return {
...state,
present: { order, tracks: state.present.tracks },
baseline: { order, tracks: state.baseline.tracks },
sourceSignatures: signatures,
};
}
const liveIds = new Set(order);
let seq = state.seq;
const tracks: Record<FileId, Track> = {};
@@ -109,6 +109,48 @@ async function dragPageOnto(
await page.mouse.up();
}
/** The order of the tracks, top to bottom. */
async function trackOrder(page: import("@playwright/test").Page) {
return page
.locator("section[aria-label] header")
.evaluateAll((headers) =>
headers.map(
(h) =>
(h.closest("section") as HTMLElement).getAttribute("aria-label") ??
"",
),
);
}
/** Drags a track's header onto another track, vertically. */
async function dragTrackOnto(
page: import("@playwright/test").Page,
sourceName: string,
targetName: string,
fractionDown = 0.25,
) {
const from = await page
.locator(`section[aria-label="${sourceName}"] header`)
.boundingBox();
const target = await page
.locator(`section[aria-label="${targetName}"]`)
.boundingBox();
if (!from || !target) throw new Error("tracks are not laid out");
await page.mouse.move(from.x + 140, from.y + from.height / 2);
await page.mouse.down();
const toX = target.x + 140;
const toY = target.y + target.height * fractionDown;
for (const step of [0.3, 0.7, 1]) {
await page.mouse.move(
from.x + 140 + (toX - (from.x + 140)) * step,
from.y + from.height / 2 + (toY - (from.y + from.height / 2)) * step,
{ steps: 8 },
);
}
await page.mouse.up();
}
async function openPageEditor(page: import("@playwright/test").Page) {
await page.goto("/editor", {
waitUntil: "domcontentloaded",
@@ -536,4 +578,56 @@ test.describe("Page Editor tracks", () => {
await expect(saved).toContainText("v2", { timeout: 90_000 });
await expect(saved.locator("[data-page-id]")).toHaveCount(3);
});
test("dragging a track header reorders the tracks", async ({ page }) => {
await openPageEditor(page);
await expect(
track(page, "sample.pdf").locator("[data-page-id]"),
).toHaveCount(1, { timeout: 30_000 });
expect(await trackOrder(page)).toEqual(["rotated-pages.pdf", "sample.pdf"]);
// Drop sample.pdf on the top half of rotated-pages.pdf: above it.
await dragTrackOnto(page, "sample.pdf", "rotated-pages.pdf", 0.2);
await expect
.poll(async () => trackOrder(page), { timeout: 30_000 })
.toEqual(["sample.pdf", "rotated-pages.pdf"]);
// Pages stayed with their own files rather than moving between tracks.
await expect(
track(page, "sample.pdf").locator("[data-page-id]"),
).toHaveCount(1);
await expect(
track(page, "rotated-pages.pdf").locator("[data-page-id]"),
).toHaveCount(4);
await expect(track(page, "rotated-pages.pdf")).not.toContainText("edited");
});
test("reordering tracks keeps pending page edits and their undo history", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
const tiles = rotated.locator("[data-page-id]");
await expect(tiles).toHaveCount(4, { timeout: 30_000 });
const last = tiles.nth(3);
await last.hover();
await last.getByRole("button", { name: "Delete page" }).click();
await expect(tiles).toHaveCount(3);
await dragTrackOnto(page, "sample.pdf", "rotated-pages.pdf", 0.2);
await expect
.poll(async () => trackOrder(page), { timeout: 30_000 })
.toEqual(["sample.pdf", "rotated-pages.pdf"]);
// The edit is still pending, not silently re-baselined by the reorder.
const moved = track(page, "rotated-pages.pdf");
await expect(moved.locator("[data-page-id]")).toHaveCount(3);
await expect(moved).toContainText("edited");
// And it is still undoable.
await page.getByRole("button", { name: "Undo" }).click();
await expect(moved.locator("[data-page-id]")).toHaveCount(4);
await expect(moved).not.toContainText("edited");
});
});