Compare commits

...
27 changed files with 3656 additions and 71 deletions
@@ -5598,6 +5598,35 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords"
[pageTracks]
dragging_one = "{{count}} page"
dragging_other = "{{count}} pages"
edited = "edited"
emptyTrack = "No pages left. Drag pages here, or save to close this file."
page = "Page {{number}}"
pageCount_one = "{{count}} page"
pageCount_other = "{{count}} pages"
readingPages = "Reading pages..."
redo = "Redo"
rotateLeft = "Rotate left"
rotateRight = "Rotate right"
saveChanges = "Save changes to all files"
saving = "Saving..."
savingProgress = "Saving {{done}} of {{total}}"
selectPage = "Select page {{number}}"
undo = "Undo"
[pageTracks.delete]
page = "Delete page"
selected = "Delete pages"
[pageTracks.empty]
body = "Add PDFs to expand them into editable page tracks"
title = "No PDF files loaded"
[pageTracks.track]
toggleSelection = "Select all pages"
[payg.activity]
docs = "docs"
empty = "No billable activity yet this period."
@@ -11698,6 +11727,7 @@ formFill = "Fill Form"
hideToolbar = "Hide toolbar"
moreActions = "More actions"
multiTool = "Multi-Tool"
pageEditor = "Page Editor"
panMode = "Pan Mode"
print = "Print PDF"
readAloud = "Read Aloud"
@@ -24,12 +24,12 @@ import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButto
import { ChatFAB } from "@app/components/chat/ChatFAB";
// Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the
// full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file
// full @embedpdf plugin set; FileEditor/PageTracks are only needed once a file
// is open. Lazy-loading keeps all of that out of the initial bundle.
const FileEditor = lazy(() => import("@app/components/fileEditor/FileEditor"));
const PageEditor = lazy(() => import("@app/components/pageEditor/PageEditor"));
const PageEditorControls = lazy(
() => import("@app/components/pageEditor/PageEditorControls"),
const PageTracks = lazy(() => import("@app/components/pageTracks/PageTracks"));
const MultiToolWorkbench = lazy(
() => import("@app/components/pageEditor/MultiToolWorkbench"),
);
const Viewer = lazy(() => import("@app/components/viewer/Viewer"));
const FileManagerView = lazy(
@@ -51,10 +51,8 @@ export default function Workbench() {
const setCurrentView = navActions.setWorkbench;
const {
previewFile,
pageEditorFunctions,
sidebarsVisible,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible,
customWorkbenchViews,
} = useToolWorkflow();
@@ -93,6 +91,9 @@ export default function Workbench() {
(currentView === "viewer" && !!signingOverlay?.file);
const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent;
const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent;
// Page-level editors scroll internally, so the shell must not add its own.
const isPageLevelEditor =
currentView === "pageEditor" || currentView === "multiTool";
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -199,43 +200,10 @@ export default function Workbench() {
);
case "pageEditor":
return (
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
}}
>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
</div>
)}
</div>
);
return <PageTracks />;
case "multiTool":
return <MultiToolWorkbench />;
default:
return null;
@@ -286,7 +254,7 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
className={`flex-1 min-h-0 z-10 ${isPageLevelEditor ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: "opacity 0.15s ease-in-out",
// Force min-width:0 so flex children (notably the files page
@@ -294,7 +262,7 @@ export default function Workbench() {
// toggle) can shrink below their intrinsic content size on
// narrow viewports instead of overflowing horizontally.
minWidth: 0,
...(currentView === "pageEditor" && { height: 0 }),
...(isPageLevelEditor && { height: 0 }),
}}
>
<Suspense
@@ -0,0 +1,63 @@
import { lazy, Suspense } from "react";
import { Center, Loader } from "@mantine/core";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
const PageEditor = lazy(() => import("@app/components/pageEditor/PageEditor"));
const PageEditorControls = lazy(
() => import("@app/components/pageEditor/PageEditorControls"),
);
/**
* The Multi-Tool's workbench: the single-document page editor plus its own
* bottom control bar. Reached only while the Multi-Tool is the selected tool;
* the "Page Editor" view is the multi-file track editor instead.
*/
export default function MultiToolWorkbench() {
const { pageEditorFunctions, setPageEditorFunctions } = useToolWorkflow();
return (
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<Suspense
fallback={
<Center style={{ height: "100%" }}>
<Loader />
</Center>
}
>
<PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
}}
>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
</div>
)}
</Suspense>
</div>
);
}
@@ -341,7 +341,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
useEffect(() => {
return () => {
if (navigationState.workbench !== "pageEditor") {
if (navigationState.workbench !== "multiTool") {
return;
}
@@ -0,0 +1,324 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
}
.scroller {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* ── 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
tracks squeezes each lane below its tiles and clips them. */
flex: 0 0 auto;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
background: var(--c-surface);
overflow: hidden;
}
.trackDropActive {
border-color: var(--c-primary);
box-shadow: inset 0 0 0 1px var(--c-primary);
}
.trackHeader {
cursor: grab;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-surface-sunken);
min-width: 0;
}
/* Sits after the page count, so a long filename must not squeeze it. */
.trackLeadAction {
flex: 0 0 auto;
}
.trackName {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.trackMeta {
font-size: 0.75rem;
color: var(--c-text-muted);
white-space: nowrap;
flex: 0 0 auto;
}
.trackActions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.125rem;
flex: 0 0 auto;
}
.lane {
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);
color: var(--c-text-subtle);
font-size: 0.8125rem;
}
/* ── Tile ────────────────────────────────────────────────────────────────── */
.tile {
position: absolute;
top: 0;
width: var(--pt-tile-w);
cursor: grab;
user-select: none;
border-radius: var(--radius-sm);
outline: 1px solid var(--c-border-subtle);
outline-offset: -1px;
background: var(--c-surface-raised);
transition:
outline-color 0.15s ease,
box-shadow 0.15s ease;
}
.tile:hover {
outline-color: var(--c-border-strong);
}
.tileSelected {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 22%, transparent);
}
.tileDragging {
opacity: 0.35;
}
.canvas {
position: relative;
height: var(--pt-tile-h);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--c-surface-sunken);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.thumb {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
transition: transform 0.2s ease-in-out;
}
/*
* A quarter turn is applied after layout, so the pre-rotation box has to be
* constrained by the OPPOSITE axis or the rotated page overflows and clips.
*/
.thumbQuarterTurn {
max-width: var(--pt-tile-h);
max-height: var(--pt-tile-w);
}
.thumbPending {
width: 60%;
height: 78%;
border-radius: 2px;
background: var(--c-hover);
}
.tileFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
height: var(--pt-tile-footer-h);
padding: 0 0.375rem;
font-size: 0.6875rem;
color: var(--c-text-muted);
min-width: 0;
}
.tileIndex {
font-variant-numeric: tabular-nums;
}
/* Kept out of the way until the tile is hovered or already selected: a
permanent checkbox over every page reads as noise at this density. */
.tileCheckbox {
position: absolute;
top: 0.25rem;
left: 0.25rem;
z-index: 2;
display: flex;
padding: 0.125rem;
border-radius: var(--radius-sm);
background: var(--c-surface);
box-shadow: 0 1px 3px rgb(0 0 0 / 25%);
opacity: 0;
transition: opacity 0.12s ease;
}
.tile:hover .tileCheckbox,
.tile:focus-within .tileCheckbox,
.tileSelected .tileCheckbox {
opacity: 1;
}
/*
* Compacted from the shared pill's defaults: the same component carries up to
* six actions on a 20rem file card, and three at that scale would overflow a
* page tile a third of the width.
*/
.pagePill {
gap: 0.25rem;
padding: 0.25rem 0.5rem;
z-index: 2;
}
/* CSS-hover driven visibility, matching the file cards in Active Files. */
.tile [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 0;
pointer-events: none;
}
.tile:hover
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"],
.tile:focus-within
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 1;
pointer-events: auto;
}
/* ── Unsaved-changes marker on the save button ───────────────────────────── */
.saveButton {
position: relative;
}
.unsavedDot {
position: absolute;
top: 0.125rem;
right: 0.125rem;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 999px;
background: var(--c-danger);
/* Ringed in the bar's own surface so it reads against the icon behind it. */
box-shadow: 0 0 0 1.5px var(--c-bg-raised);
pointer-events: none;
}
/* ── 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 ──────────────────────────────────────────────────────── */
/*
* Drawn on the tile it inserts against, not as a flex item of its own: a real
* element in the lane adds another `gap`, shifting every tile to its right by
* 8px — including the tile whose midpoint decides which side to drop on.
*/
.dropBefore::before,
.dropAfterLast::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 2px;
border-radius: 999px;
background: var(--c-primary);
pointer-events: none;
}
.dropBefore::before {
left: -5px;
}
.dropAfterLast::after {
right: -5px;
}
.dragBadge {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
background: var(--c-primary);
color: var(--c-text-on-primary);
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 6px 16px rgb(0 0 0 / 25%);
}
@@ -0,0 +1,560 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Center, Loader, LoadingOverlay, Stack, Text } from "@mantine/core";
import {
CollisionDetection,
DndContext,
DragEndEvent,
DragMoveEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import {
useNavigationActions,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { FileId } from "@app/types/file";
import { useTrackWorkspace } from "@app/components/pageTracks/hooks/useTrackWorkspace";
import { useTrackSelection } from "@app/components/pageTracks/hooks/useTrackSelection";
import { useTrackThumbnails } from "@app/components/pageTracks/hooks/useTrackThumbnails";
import { useTrackSave } from "@app/components/pageTracks/hooks/useTrackSave";
import { usePageTracksWorkbenchBarButtons } from "@app/components/pageTracks/hooks/usePageTracksWorkbenchBarButtons";
import { totalPageCount } from "@app/components/pageTracks/types";
import TrackRow, { DropHint } from "@app/components/pageTracks/TrackRow";
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:";
/**
* 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 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 lane = first(TRACK_PREFIX);
if (lane) return [lane];
const zone = first(ZONE_PREFIX);
if (zone) return [zone];
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
* 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 =
activator instanceof MouseEvent
? activator.clientX
: activator instanceof TouchEvent && activator.touches.length > 0
? activator.touches[0].clientX
: 0;
return originX + event.delta.x;
}
export default function PageTracks() {
const { t } = useTranslation();
const { state: fileState } = useFileState();
const {
state,
dispatch,
pendingFileIds,
hasPdfFiles,
changedFileIds,
isDirty,
canUndo,
canRedo,
} = useTrackWorkspace();
const workspace = state.present;
const selection = useTrackSelection(workspace);
const thumbnails = useTrackThumbnails();
const { actions: navActions } = useNavigationActions();
const { setActiveFileId } = useViewer();
// The file the user asked to view, held across a save: committing gives it a
// new id, and the viewer drops an active file that has left the workbench.
const viewTargetRef = useRef<FileId | null>(null);
const handleVersioned = useCallback(
(previousId: FileId, nextId: FileId) => {
if (viewTargetRef.current !== previousId) return;
viewTargetRef.current = nextId;
setActiveFileId(nextId as string);
},
[setActiveFileId],
);
const { saving, progress, save } = useTrackSave(workspace, changedFileIds, {
onVersioned: handleVersioned,
});
/**
* Opens one track's file in the Viewer. Routed through setWorkbench so the
* unsaved-changes prompt still fires: viewing a file whose pending edits
* haven't been written would show stale pages.
*/
const openInViewer = useCallback(
(fileId: FileId) => {
viewTargetRef.current = fileId;
setActiveFileId(fileId as string);
navActions.setWorkbench("viewer");
},
[navActions, setActiveFileId],
);
const [draggingIds, setDraggingIds] = useState<Set<string>>(
() => 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]);
// ── Operations ───────────────────────────────────────────────────────────
const rotatePages = useCallback(
(pageIds: string[], delta: number) =>
dispatch({ type: "rotate", pageIds, delta }),
[dispatch],
);
const deletePages = useCallback(
(pageIds: string[]) => dispatch({ type: "delete", pageIds }),
[dispatch],
);
const rotateSelection = useCallback(
(delta: number) => rotatePages(Array.from(selection.selectedIds), delta),
[rotatePages, selection.selectedIds],
);
const deleteSelection = useCallback(
() => deletePages(Array.from(selection.selectedIds)),
[deletePages, selection.selectedIds],
);
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(
// A short distance threshold keeps plain clicks (select) from starting a drag.
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
// Rebuilt per edit rather than scanned per drag-over event: resolving the
// hovered page by walking every track is O(pages) on every pointer move.
const trackByPageId = useMemo(() => {
const map = new Map<string, FileId>();
workspace.order.forEach((fileId) => {
workspace.tracks[fileId]?.pages.forEach((page) =>
map.set(page.id, fileId),
);
});
return map;
}, [workspace]);
/**
* Resolves the pointer position to "insert before this page". An anchor that
* is itself being dragged is fine: the reducer skips past the moved pages.
*/
const resolveHint = useCallback(
(overId: string | null, pointerX: number): DropHint | null => {
if (!overId) return null;
// 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 };
}
if (!overId.startsWith(PAGE_PREFIX)) return null;
const overPageId = overId.slice(PAGE_PREFIX.length);
const fileId = trackByPageId.get(overPageId);
if (!fileId) return null;
const pages = workspace.tracks[fileId]?.pages ?? [];
const overIndex = pages.findIndex((page) => page.id === overPageId);
if (overIndex === -1) return null;
const element = document.querySelector<HTMLElement>(
`[data-page-id="${overPageId}"]`,
);
const rect = element?.getBoundingClientRect();
const dropAfter = rect ? pointerX > rect.left + rect.width / 2 : false;
const anchor = pages[dropAfter ? overIndex + 1 : overIndex];
return { fileId, beforePageId: anchor?.id ?? null };
},
[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 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)
? Array.from(selection.selectedIds)
: [pageId];
setDraggingIds(new Set(ids));
},
[selection.selectedIds],
);
// onDragMove, not onDragOver: which side of a tile the pointer is on changes
// WITHOUT the hovered droppable changing, and onDragOver only fires on the
// 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),
);
// 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));
},
[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),
);
setDraggingIds(new Set());
setDropHint(null);
if (!hint || draggingIds.size === 0) return;
dispatch({
type: "move",
pageIds: Array.from(draggingIds),
targetFileId: hint.fileId,
beforePageId: hint.beforePageId,
});
},
[
dispatch,
draggingIds,
draggingTrack,
reorderTracks,
resolveHint,
resolveTrackHint,
],
);
const handleDragCancel = useCallback(() => {
setDraggingIds(new Set());
setDropHint(null);
setDraggingTrack(null);
setTrackDropTarget(undefined);
}, []);
// ── Save + navigation guard ──────────────────────────────────────────────
const {
setHasUnsavedChanges,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
} = useNavigationGuard();
useEffect(() => {
setHasUnsavedChanges(isDirty);
}, [isDirty, setHasUnsavedChanges]);
useEffect(() => {
// Only the save route is offered: edits live in memory, so "discard" needs
// no handler, and there is no separate export step to leave via.
registerNavigationWarningHandlers({
onApplyAndContinue: async () => {
await save();
},
});
return () => unregisterNavigationWarningHandlers();
}, [
save,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
]);
useEffect(
() => () => {
setHasUnsavedChanges(false);
},
[setHasUnsavedChanges],
);
const undo = useCallback(() => dispatch({ type: "undo" }), [dispatch]);
const redo = useCallback(() => dispatch({ type: "redo" }), [dispatch]);
const saveNow = useCallback(() => {
void save();
}, [save]);
usePageTracksWorkbenchBarButtons({
totalPages,
selectedCount: selection.selectedCount,
canUndo,
canRedo,
isDirty,
saving,
onSelectAll: selection.selectAll,
onDeselectAll: selection.clear,
onRotate: rotateSelection,
onDelete: deleteSelection,
onUndo: undo,
onRedo: redo,
onSave: saveNow,
});
// ── Render ───────────────────────────────────────────────────────────────
if (!hasPdfFiles) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<Text c="dimmed">
{t("pageTracks.empty.title", "No PDF files loaded")}
</Text>
<Text size="sm" c="dimmed">
{t(
"pageTracks.empty.body",
"Add PDFs to expand them into editable page tracks",
)}
</Text>
</Stack>
</Center>
);
}
return (
<div className={styles.root} data-testid="page-tracks">
<LoadingOverlay
visible={saving}
loaderProps={{
children: (
<Stack align="center" gap="xs">
<Loader />
<Text size="sm">
{progress
? t(
"pageTracks.savingProgress",
"Saving {{done}} of {{total}}",
{
done: progress.done,
total: progress.total,
},
)
: t("pageTracks.saving", "Saving...")}
</Text>
</Stack>
),
}}
/>
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div
className={styles.scroller}
data-scrolling-container="true"
onClick={(event) => {
if (event.target === event.currentTarget) clearSelection();
}}
>
{workspace.order.map((fileId) => {
const track = workspace.tracks[fileId];
if (!track) return null;
const stub = fileState.files.byId[fileId];
return (
<TrackRow
key={fileId}
track={track}
name={stub?.name ?? fileId}
versionNumber={stub?.versionNumber}
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}
onSelectTrack={selection.selectTrack}
onOpenInViewer={openInViewer}
onClearSelection={clearSelection}
onRotate={rotatePages}
onDelete={deletePages}
/>
);
})}
{pendingFileIds.map((fileId) => (
<div key={fileId} className={styles.track}>
<header className={styles.trackHeader}>
<span className={styles.trackName}>
{fileState.files.byId[fileId]?.name ?? fileId}
</span>
<span className={styles.trackMeta}>
{t("pageTracks.readingPages", "Reading pages...")}
</span>
</header>
<div className={`${styles.lane} ${styles.laneEmpty}`}>
<Loader size="sm" />
</div>
</div>
))}
</div>
<DragOverlay dropAnimation={null}>
{draggingIds.size > 0 && (
<div className={styles.dragBadge}>
{t("pageTracks.dragging", "{{count}} pages", {
count: draggingIds.size,
})}
</div>
)}
</DragOverlay>
</DndContext>
</div>
);
}
@@ -0,0 +1,211 @@
import React, { useCallback, useMemo } from "react";
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";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import { Checkbox } from "@app/ui/Checkbox";
import HoverActionMenu, {
HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { FileId } from "@app/types/file";
import { TrackPage } from "@app/components/pageTracks/types";
import {
TrackThumbnailStore,
useTrackThumbnail,
} from "@app/components/pageTracks/hooks/useTrackThumbnails";
import styles from "@app/components/pageTracks/PageTracks.module.css";
export const pageDroppableId = (pageId: string) => `page:${pageId}`;
export interface TrackPageTileProps {
page: TrackPage;
/** The track this tile currently sits in. */
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. */
dropBefore: boolean;
/** Draw it on the trailing edge (last tile, appending to the track). */
dropAfterLast: boolean;
thumbnails: TrackThumbnailStore;
onSelect: (
fileId: FileId,
pageId: string,
modifiers: { shift: boolean },
) => void;
onRotate: (pageIds: string[], delta: number) => void;
onDelete: (pageIds: string[]) => void;
}
function TrackPageTileImpl({
page,
trackFileId,
position,
offsetX,
selected,
dragging,
dropBefore,
dropAfterLast,
thumbnails,
onSelect,
onRotate,
onDelete,
}: TrackPageTileProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const dragData = { type: "page", pageId: page.id, fileId: trackFileId };
const {
attributes,
listeners,
setNodeRef: setDragRef,
} = useDraggable({ id: pageDroppableId(page.id), data: dragData });
const { setNodeRef: setDropRef } = useDroppable({
id: pageDroppableId(page.id),
data: dragData,
});
const setRefs = useCallback(
(element: HTMLElement | null) => {
setDragRef(element);
setDropRef(element);
thumbnails.observe(page)(element);
},
[setDragRef, setDropRef, thumbnails, page],
);
const thumbnail = useTrackThumbnail(thumbnails, page);
const handleClick = useCallback(
(event: React.MouseEvent) => {
onSelect(trackFileId, page.id, { shift: event.shiftKey });
},
[onSelect, trackFileId, page.id],
);
const stop = (event: React.MouseEvent) => event.stopPropagation();
const quarterTurn = page.rotation === 90 || page.rotation === 270;
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "rotate-left",
icon: <RotateLeftIcon style={{ fontSize: 16 }} />,
label: t("pageTracks.rotateLeft", "Rotate left"),
onClick: (event) => {
event.stopPropagation();
onRotate([page.id], -90);
},
},
{
id: "rotate-right",
icon: <RotateRightIcon style={{ fontSize: 16 }} />,
label: t("pageTracks.rotateRight", "Rotate right"),
onClick: (event) => {
event.stopPropagation();
onRotate([page.id], 90);
},
},
{
id: "delete",
icon: <DeleteOutlineIcon style={{ fontSize: 16 }} />,
label: t("pageTracks.delete.page", "Delete page"),
color: "var(--c-danger)",
onClick: (event) => {
event.stopPropagation();
onDelete([page.id]);
},
},
],
[t, onRotate, onDelete, page.id],
);
return (
<div
ref={setRefs}
className={[
styles.tile,
selected ? styles.tileSelected : "",
dragging ? styles.tileDragging : "",
dropBefore ? styles.dropBefore : "",
dropAfterLast ? styles.dropAfterLast : "",
]
.filter(Boolean)
.join(" ")}
style={{ left: offsetX }}
{...attributes}
{...listeners}
data-page-id={page.id}
data-selected={selected}
data-drop-before={dropBefore || undefined}
data-drop-after-last={dropAfterLast || undefined}
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={t("pageTracks.page", "Page {{number}}", {
number: position,
})}
onClick={handleClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(trackFileId, page.id, { shift: false });
}
}}
>
<div className={styles.tileCheckbox} onClick={stop}>
<Checkbox
checked={selected}
aria-label={t("pageTracks.selectPage", "Select page {{number}}", {
number: position,
})}
onChange={() => onSelect(trackFileId, page.id, { shift: false })}
/>
</div>
<div className={styles.canvas}>
{thumbnail ? (
<PrivateContent>
<img
className={[
styles.thumb,
quarterTurn ? styles.thumbQuarterTurn : "",
"ph-no-capture",
]
.filter(Boolean)
.join(" ")}
src={thumbnail}
alt=""
draggable={false}
data-original-rotation={page.rotation}
style={{ transform: `rotate(${page.rotation}deg)` }}
/>
</PrivateContent>
) : (
<div className={styles.thumbPending} />
)}
<HoverActionMenu
show={isMobile}
actions={hoverActions}
position="inside"
visibility="cssHover"
className={styles.pagePill}
/>
</div>
<div className={styles.tileFooter}>
<span className={styles.tileIndex}>{position}</span>
</div>
</div>
);
}
export const TrackPageTile = React.memo(TrackPageTileImpl);
export default TrackPageTile;
@@ -0,0 +1,323 @@
import React, { useCallback, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
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";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import { FileId } from "@app/types/file";
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}`;
/** 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;
/** Insert before this page, or append to the track when null. */
beforePageId: string | null;
}
export interface TrackRowProps {
track: Track;
name: string;
versionNumber: number | undefined;
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: (
fileId: FileId,
pageId: string,
modifiers: PageClickModifiers,
) => void;
onSelectTrack: (fileId: FileId) => void;
onOpenInViewer: (fileId: FileId) => void;
/** Called when the click landed on empty lane surface, not on a page. */
onClearSelection: () => void;
onRotate: (pageIds: string[], delta: number) => void;
onDelete: (pageIds: string[]) => void;
}
function TrackRowImpl({
track,
name,
versionNumber,
selectedIds,
draggingIds,
dropHint,
trackDropBefore,
trackDropAfterLast,
trackDragging,
changed,
thumbnails,
onSelectPage,
onSelectTrack,
onOpenInViewer,
onClearSelection,
onRotate,
onDelete,
}: TrackRowProps) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({
id: trackDroppableId(track.fileId),
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
// 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(() => {
const selectedHere = track.pages
.filter((page) => selectedIds.has(page.id))
.map((page) => page.id);
return selectedHere.length > 0
? selectedHere
: track.pages.map((page) => page.id);
}, [track.pages, selectedIds]);
const handleSelectTrack = useCallback(
() => onSelectTrack(track.fileId),
[onSelectTrack, track.fileId],
);
const hintActive = dropHint?.fileId === track.fileId;
return (
<section
ref={setZoneRef}
style={geometry.cssVars}
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
ref={setHandleRef}
className={styles.trackHeader}
{...handleListeners}
>
<span className={styles.trackName} title={name}>
{name}
</span>
<span className={styles.trackMeta}>
{[
versionNumber != null && versionNumber > 1
? `v${versionNumber}`
: null,
t("pageTracks.pageCount", "{{count}} pages", {
count: track.pages.length,
}),
changed ? t("pageTracks.edited", "edited") : null,
]
.filter(Boolean)
.join(" · ")}
</span>
<Tooltip content={t("openInViewer", "Open in Viewer")}>
<ActionIcon
className={styles.trackLeadAction}
variant="quiet"
size="sm"
aria-label={t("openInViewer", "Open in Viewer")}
// An emptied track has nothing to show: saving closes the file.
disabled={track.pages.length === 0}
onClick={() => onOpenInViewer(track.fileId)}
>
<VisibilityOutlinedIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<div className={styles.trackActions}>
<Tooltip
content={t("pageTracks.track.toggleSelection", "Select all pages")}
>
<ActionIcon
variant="quiet"
size="sm"
aria-label={t(
"pageTracks.track.toggleSelection",
"Select all pages",
)}
disabled={track.pages.length === 0}
onClick={handleSelectTrack}
>
<SelectAllIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip content={t("pageTracks.rotateLeft", "Rotate left")}>
<ActionIcon
variant="quiet"
size="sm"
aria-label={t("pageTracks.rotateLeft", "Rotate left")}
disabled={targetIds.length === 0}
onClick={() => onRotate(targetIds, -90)}
>
<RotateLeftIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip content={t("pageTracks.rotateRight", "Rotate right")}>
<ActionIcon
variant="quiet"
size="sm"
aria-label={t("pageTracks.rotateRight", "Rotate right")}
disabled={targetIds.length === 0}
onClick={() => onRotate(targetIds, 90)}
>
<RotateRightIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip content={t("pageTracks.delete.selected", "Delete pages")}>
<ActionIcon
variant="quiet"
size="sm"
accent="danger"
aria-label={t("pageTracks.delete.selected", "Delete pages")}
disabled={targetIds.length === 0}
onClick={() => onDelete(targetIds)}
>
<DeleteOutlineIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
</header>
<div
ref={setLaneRef}
data-track-lane={track.fileId}
className={[
styles.lane,
track.pages.length === 0 ? styles.laneEmpty : "",
]
.filter(Boolean)
.join(" ")}
// Only a click on the lane itself, never one that bubbled up from a
// page: clicking a tile would otherwise select it and immediately
// clear it again.
onClick={(event) => {
if (event.target === event.currentTarget) onClearSelection();
}}
>
{track.pages.length === 0 && (
<span className={styles.laneHint}>
{t(
"pageTracks.emptyTrack",
"No pages left. Drag pages here, or save to close this file.",
)}
</span>
)}
{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>
);
}
export const TrackRow = React.memo(TrackRowImpl);
export default TrackRow;
@@ -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;
};
@@ -0,0 +1,213 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import {
useWorkbenchBarButtons,
WorkbenchBarButtonWithAction,
} from "@app/hooks/useWorkbenchBarButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import styles from "@app/components/pageTracks/PageTracks.module.css";
export interface PageTracksBarParams {
totalPages: number;
selectedCount: number;
canUndo: boolean;
canRedo: boolean;
isDirty: boolean;
saving: boolean;
onSelectAll: () => void;
onDeselectAll: () => void;
onRotate: (delta: number) => void;
onDelete: () => void;
onUndo: () => void;
onRedo: () => void;
onSave: () => void;
}
export function usePageTracksWorkbenchBarButtons(params: PageTracksBarParams) {
const {
totalPages,
selectedCount,
canUndo,
canRedo,
isDirty,
saving,
onSelectAll,
onDeselectAll,
onRotate,
onDelete,
onUndo,
onRedo,
onSave,
} = params;
const { t } = useTranslation();
const labels = {
selectAll: t("workbenchBar.selectAll", "Select All"),
deselectAll: t("workbenchBar.deselectAll", "Deselect All"),
rotateLeft: t("pageTracks.rotateLeft", "Rotate left"),
rotateRight: t("pageTracks.rotateRight", "Rotate right"),
deleteSelected: t("workbenchBar.deleteSelected", "Delete Selected Pages"),
undo: t("pageTracks.undo", "Undo"),
redo: t("pageTracks.redo", "Redo"),
save: t("pageTracks.saveChanges", "Save changes to all files"),
};
const hasPages = totalPages > 0;
const hasSelection = selectedCount > 0;
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(
() => [
{
id: "tracks-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: labels.selectAll,
ariaLabel: labels.selectAll,
section: "top" as const,
order: 10,
disabled: !hasPages || selectedCount === totalPages,
visible: hasPages,
onClick: onSelectAll,
},
{
id: "tracks-deselect-all",
icon: (
<LocalIcon
icon="crop-square-outline"
width="1.5rem"
height="1.5rem"
/>
),
tooltip: labels.deselectAll,
ariaLabel: labels.deselectAll,
section: "top" as const,
order: 20,
disabled: !hasSelection,
visible: hasPages,
onClick: onDeselectAll,
},
{
id: "tracks-rotate-left",
icon: <RotateLeftIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: labels.rotateLeft,
ariaLabel: labels.rotateLeft,
section: "middle" as const,
order: 10,
disabled: !hasSelection,
visible: hasPages,
onClick: () => onRotate(-90),
},
{
id: "tracks-rotate-right",
icon: <RotateRightIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: labels.rotateRight,
ariaLabel: labels.rotateRight,
section: "middle" as const,
order: 20,
disabled: !hasSelection,
visible: hasPages,
onClick: () => onRotate(90),
},
{
id: "tracks-delete-selected",
icon: (
<LocalIcon
icon="delete-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
tooltip: labels.deleteSelected,
ariaLabel: labels.deleteSelected,
section: "middle" as const,
order: 30,
disabled: !hasSelection,
visible: hasPages,
onClick: onDelete,
},
{
id: "tracks-undo",
icon: <UndoIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: labels.undo,
ariaLabel: labels.undo,
section: "bottom" as const,
order: 10,
disabled: !canUndo,
visible: hasPages,
onClick: onUndo,
},
{
id: "tracks-redo",
icon: <RedoIcon sx={{ fontSize: "1.25rem" }} />,
tooltip: labels.redo,
ariaLabel: labels.redo,
section: "bottom" as const,
order: 20,
disabled: !canRedo,
visible: hasPages,
onClick: onRedo,
},
{
id: "tracks-save",
tooltip: labels.save,
ariaLabel: labels.save,
section: "bottom" as const,
order: 30,
disabled: !isDirty || saving,
visible: hasPages,
onClick: onSave,
// Custom render for the unsaved-changes dot. A custom render also
// bypasses the bar's own tooltip wrapper, hence the Tooltip here.
render: ({ disabled, triggerAction }) => (
<Tooltip content={labels.save} position="bottom" offset={6} arrow>
<ActionIcon
variant="quiet"
hover={false}
// The bar's own class carries the muted colour and 24px clamp the
// default renderer would have applied.
className={`workbench-bar-action-icon ${styles.saveButton}`}
onClick={triggerAction}
disabled={disabled}
aria-label={labels.save}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
{isDirty && <span className={styles.unsavedDot} aria-hidden />}
</ActionIcon>
</Tooltip>
),
},
],
[
labels.selectAll,
labels.deselectAll,
labels.rotateLeft,
labels.rotateRight,
labels.deleteSelected,
labels.undo,
labels.redo,
labels.save,
hasPages,
hasSelection,
selectedCount,
totalPages,
canUndo,
canRedo,
isDirty,
saving,
onSelectAll,
onDeselectAll,
onRotate,
onDelete,
onUndo,
onRedo,
onSave,
],
);
useWorkbenchBarButtons(buttons);
}
@@ -0,0 +1,168 @@
import { useCallback, useRef, useState } from "react";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import {
createChildStub,
generateProcessedFileMetadata,
} from "@app/contexts/file/fileActions";
import { createStirlingFile, StirlingFileStub } from "@app/types/fileContext";
import { FileId } from "@app/types/file";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { pdfExportService } from "@app/services/pdfExportService";
import { TrackPage, TrackWorkspace } from "@app/components/pageTracks/types";
/**
* The page editor is a page-level rework of the open documents, which is what
* the Multi-Tool super tool has always represented in a file's history.
*/
const SAVE_TOOL_ID = "multiTool" as const;
export interface TrackSaveProgress {
done: number;
total: number;
}
export interface TrackSaveOptions {
/**
* Called per committed file with its old and new ids. Saving replaces a file
* with a new version under a NEW id, so anything holding the old one (the
* viewer's active file, for instance) has to be re-pointed.
*/
onVersioned?: (previousId: FileId, nextId: FileId) => void;
}
export interface TrackSaveHook {
saving: boolean;
progress: TrackSaveProgress | null;
/** Writes every changed track back as a new version of its own file. */
save: () => Promise<boolean>;
}
interface BuiltTrack {
fileId: FileId;
parentStub: StirlingFileStub;
file: File;
}
/** Shape the export service expects: pages tagged with their source page. */
function toExportDocument(
name: string,
ownFile: File,
pages: TrackPage[],
): PDFDocument {
const exportPages: PDFPage[] = pages.map((page, index) => ({
id: page.id,
pageNumber: index + 1,
originalPageNumber: page.sourcePageNumber,
originalFileId: page.sourceFileId,
rotation: page.rotation,
thumbnail: null,
selected: false,
}));
return {
id: `page-tracks-${name}`,
name,
file: ownFile,
pages: exportPages,
totalPages: exportPages.length,
};
}
export function useTrackSave(
workspace: TrackWorkspace,
changedFileIds: FileId[],
options: TrackSaveOptions = {},
): TrackSaveHook {
const { selectors } = useFileState();
const { actions } = useFileActions();
const onVersionedRef = useRef(options.onVersioned);
onVersionedRef.current = options.onVersioned;
const [saving, setSaving] = useState(false);
const [progress, setProgress] = useState<TrackSaveProgress | null>(null);
const save = useCallback(async () => {
if (saving || changedFileIds.length === 0) return false;
const emptied = changedFileIds.filter(
(fileId) => (workspace.tracks[fileId]?.pages.length ?? 0) === 0,
);
const rebuilt = changedFileIds.filter(
(fileId) => !emptied.includes(fileId),
);
setSaving(true);
setProgress({ done: 0, total: rebuilt.length });
try {
// Build every output first: a track can hold pages belonging to another
// track's file, and committing as we go would swap those bytes out from
// under a later build.
const built: BuiltTrack[] = [];
for (const fileId of rebuilt) {
const pages = workspace.tracks[fileId]?.pages ?? [];
const parentStub = selectors.getStirlingFileStub(fileId);
const ownFile = selectors.getFile(fileId);
if (!parentStub || !ownFile) continue;
const sourceFiles = new Map<string, File>();
for (const page of pages) {
if (sourceFiles.has(page.sourceFileId)) continue;
const sourceFile = selectors.getFile(page.sourceFileId);
if (sourceFile) sourceFiles.set(page.sourceFileId, sourceFile);
}
const { blob } = await pdfExportService.exportPDFMultiFile(
toExportDocument(parentStub.name, ownFile, pages),
sourceFiles,
[],
{ filename: parentStub.name },
);
built.push({
fileId,
parentStub,
file: new File([blob], parentStub.name, {
type: "application/pdf",
}),
});
setProgress({ done: built.length, total: rebuilt.length });
}
// Commit one file at a time so each new version lands in its own track's
// slot instead of the whole batch clumping at the top of the file list.
for (const entry of built) {
const processedFile = await generateProcessedFileMetadata(entry.file);
const outputStub = createChildStub(
entry.parentStub,
{ toolId: SAVE_TOOL_ID, timestamp: Date.now() },
entry.file,
processedFile?.thumbnailUrl,
processedFile,
);
await actions.consumeFiles(
[entry.fileId],
[createStirlingFile(entry.file, outputStub.id)],
[outputStub],
{ silent: true },
);
onVersionedRef.current?.(entry.fileId, outputStub.id);
}
if (emptied.length > 0) {
// Every page moved out, so there is nothing left to version. Drop the
// file from the workbench but keep it in storage at its last version.
await actions.removeFiles(emptied, false);
}
return true;
} catch (error) {
console.error("[PageTracks] save failed", error);
return false;
} finally {
setSaving(false);
setProgress(null);
}
}, [actions, changedFileIds, saving, selectors, workspace]);
return { saving, progress, save };
}
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FileId } from "@app/types/file";
import { TrackWorkspace, allPages } from "@app/components/pageTracks/types";
export interface PageClickModifiers {
/** Extend the selection from the last clicked page in the same track. */
shift: boolean;
}
export interface TrackSelectionHook {
selectedIds: Set<string>;
selectedCount: number;
selectPage: (
fileId: FileId,
pageId: string,
modifiers: PageClickModifiers,
) => void;
setSelection: (pageIds: string[]) => void;
selectAll: () => void;
selectTrack: (fileId: FileId) => void;
clear: () => void;
/** Selected pages that live in this track, in track order. */
idsInTrack: (fileId: FileId) => string[];
}
export function useTrackSelection(
workspace: TrackWorkspace,
): TrackSelectionHook {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set<string>(),
);
const anchorRef = useRef<{ fileId: FileId; pageId: string } | null>(null);
// Read through a ref so the click handler stays referentially stable: it is
// passed down to every tile, and a new identity per edit would re-render the
// whole workspace.
const workspaceRef = useRef(workspace);
workspaceRef.current = workspace;
const livePageIds = useMemo(
() => new Set(allPages(workspace).map((page) => page.id)),
[workspace],
);
// Deleted, undone and re-synced pages must drop out of the selection or the
// action buttons stay enabled for pages that no longer exist.
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const next = new Set<string>();
prev.forEach((id) => {
if (livePageIds.has(id)) next.add(id);
});
return next.size === prev.size ? prev : next;
});
if (anchorRef.current && !livePageIds.has(anchorRef.current.pageId)) {
anchorRef.current = null;
}
}, [livePageIds]);
/**
* A click toggles the page in or out of the selection, so pages accumulate
* without a modifier: picking a set to rotate or move is the whole job here,
* and replace-on-click would make anything past the first page a fight.
* Shift extends from the last clicked page instead.
*/
const selectPage = useCallback(
(fileId: FileId, pageId: string, modifiers: PageClickModifiers) => {
const trackPages = workspaceRef.current.tracks[fileId]?.pages ?? [];
const anchor = anchorRef.current;
// Shift only ranges within one track: a range across tracks has no
// single ordering the user could predict.
if (modifiers.shift && anchor && anchor.fileId === fileId) {
const from = trackPages.findIndex((p) => p.id === anchor.pageId);
const to = trackPages.findIndex((p) => p.id === pageId);
if (from !== -1 && to !== -1) {
const [start, end] = from <= to ? [from, to] : [to, from];
const range = trackPages.slice(start, end + 1).map((p) => p.id);
setSelectedIds((prev) => {
const next = new Set(prev);
range.forEach((id) => next.add(id));
return next;
});
// Anchor stays put so repeated shift-clicks re-extend from it.
return;
}
}
anchorRef.current = { fileId, pageId };
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(pageId)) next.delete(pageId);
else next.add(pageId);
return next;
});
},
[],
);
const setSelection = useCallback((pageIds: string[]) => {
setSelectedIds(new Set(pageIds));
}, []);
const selectAll = useCallback(() => {
setSelectedIds(new Set(livePageIds));
}, [livePageIds]);
const selectTrack = useCallback(
(fileId: FileId) => {
const trackPages = workspaceRef.current.tracks[fileId]?.pages ?? [];
const ids = trackPages.map((p) => p.id);
const allSelected =
ids.length > 0 && ids.every((id) => selectedIds.has(id));
setSelectedIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => (allSelected ? next.delete(id) : next.add(id)));
return next;
});
},
[selectedIds],
);
const clear = useCallback(() => {
setSelectedIds(new Set());
anchorRef.current = null;
}, []);
const idsInTrack = useCallback(
(fileId: FileId) =>
(workspace.tracks[fileId]?.pages ?? [])
.filter((page) => selectedIds.has(page.id))
.map((page) => page.id),
[workspace, selectedIds],
);
return {
selectedIds,
selectedCount: selectedIds.size,
selectPage,
setSelection,
selectAll,
selectTrack,
clear,
idsInTrack,
};
}
@@ -0,0 +1,165 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import { useFileSelectors } from "@app/contexts/FileContext";
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
import { TrackPage, sourcePageKey } from "@app/components/pageTracks/types";
/** Pre-load a screen's worth either side so sideways scrolling stays smooth. */
const ROOT_MARGIN = "300px";
const MAX_IN_FLIGHT = 12;
const NOTIFY_MS = 60;
export interface TrackThumbnailStore {
subscribe: (listener: () => void) => () => void;
get: (key: string) => string | null;
/** Ref callback for a page tile, driving lazy loading via intersection. */
observe: (page: TrackPage) => (element: HTMLElement | null) => void;
}
/**
* Renders page thumbnails on demand, keyed by source page rather than by track
* position, so a page keeps its image when it is moved, reordered or undone.
*
* Results live outside React state: a track can hold thousands of pages, and
* pushing each arriving thumbnail through a prop would re-render every tile in
* the track. Tiles subscribe individually via {@link useTrackThumbnail}.
*/
export function useTrackThumbnails(): TrackThumbnailStore {
const selectors = useFileSelectors();
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
const resolvedRef = useRef(new Map<string, string>());
const listenersRef = useRef(new Set<() => void>());
const inFlightRef = useRef(new Set<string>());
const queueRef = useRef<TrackPage[]>([]);
const notifyTimerRef = useRef<number | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const pageByElementRef = useRef(new Map<Element, TrackPage>());
const elementByKeyRef = useRef(new Map<string, Element>());
const scheduleNotify = useCallback(() => {
if (notifyTimerRef.current != null) return;
notifyTimerRef.current = window.setTimeout(() => {
notifyTimerRef.current = null;
listenersRef.current.forEach((listener) => listener());
}, NOTIFY_MS);
}, []);
const pump = useCallback(() => {
while (
inFlightRef.current.size < MAX_IN_FLIGHT &&
queueRef.current.length > 0
) {
const page = queueRef.current.shift();
if (!page) break;
const key = sourcePageKey(page);
if (resolvedRef.current.has(key) || inFlightRef.current.has(key))
continue;
const cached = getThumbnailFromCache(key);
if (cached) {
resolvedRef.current.set(key, cached);
scheduleNotify();
continue;
}
const file = selectors.getFile(page.sourceFileId);
if (!file) continue;
inFlightRef.current.add(key);
requestThumbnail(key, file, page.sourcePageNumber)
.then((thumbnail) => {
if (thumbnail) {
resolvedRef.current.set(key, thumbnail);
scheduleNotify();
}
})
.catch((error) => {
console.error("[PageTracks] thumbnail failed", error);
})
.finally(() => {
inFlightRef.current.delete(key);
pump();
});
}
}, [getThumbnailFromCache, requestThumbnail, scheduleNotify, selectors]);
const enqueue = useCallback(
(page: TrackPage) => {
const key = sourcePageKey(page);
if (resolvedRef.current.has(key) || inFlightRef.current.has(key)) return;
if (queueRef.current.some((queued) => sourcePageKey(queued) === key))
return;
queueRef.current.push(page);
pump();
},
[pump],
);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const page = pageByElementRef.current.get(entry.target);
if (page) enqueue(page);
});
},
{ rootMargin: ROOT_MARGIN },
);
observerRef.current = observer;
return () => {
observer.disconnect();
observerRef.current = null;
pageByElementRef.current.clear();
elementByKeyRef.current.clear();
if (notifyTimerRef.current != null) {
window.clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = null;
}
};
}, [enqueue]);
return useMemo<TrackThumbnailStore>(
() => ({
subscribe: (listener) => {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
},
get: (key) => resolvedRef.current.get(key) ?? null,
observe: (page) => (element) => {
const observer = observerRef.current;
const key = sourcePageKey(page);
const previous = elementByKeyRef.current.get(key);
if (previous && previous !== element) {
observer?.unobserve(previous);
pageByElementRef.current.delete(previous);
elementByKeyRef.current.delete(key);
}
if (!element) return;
elementByKeyRef.current.set(key, element);
pageByElementRef.current.set(element, page);
observer?.observe(element);
},
}),
[],
);
}
/** Subscribes a single tile to its own page's thumbnail. */
export function useTrackThumbnail(
store: TrackThumbnailStore,
page: TrackPage,
): string | null {
const key = sourcePageKey(page);
return useSyncExternalStore(
store.subscribe,
() => store.get(key),
() => null,
);
}
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useFileState } from "@app/contexts/FileContext";
import { FileId } from "@app/types/file";
import { TrackSource } from "@app/components/pageTracks/types";
import {
changedTrackIds,
initialTrackEditorState,
TrackEditorAction,
TrackEditorState,
trackEditorReducer,
} from "@app/components/pageTracks/trackWorkspaceReducer";
export interface TrackWorkspaceHook {
state: TrackEditorState;
dispatch: (action: TrackEditorAction) => void;
/** PDFs that are open but whose page metadata hasn't been read yet. */
pendingFileIds: FileId[];
/** True when any open file is a PDF (drives the empty state). */
hasPdfFiles: boolean;
changedFileIds: FileId[];
isDirty: boolean;
canUndo: boolean;
canRedo: boolean;
}
const isPdf = (name: string | undefined): boolean =>
name?.toLowerCase().endsWith(".pdf") ?? false;
export function useTrackWorkspace(): TrackWorkspaceHook {
const { state: fileState } = useFileState();
const [state, dispatch] = useReducer(
trackEditorReducer,
initialTrackEditorState,
);
// Only files whose page metadata has been hydrated can be expanded into a
// track: the per-page /Rotate baseline comes from it, and assuming 0 would
// silently un-rotate pre-rotated pages on save.
const { sources, pendingFileIds, hasPdfFiles } = useMemo(() => {
const resolved: TrackSource[] = [];
const pending: FileId[] = [];
let anyPdf = false;
for (const fileId of fileState.files.ids) {
const stub = fileState.files.byId[fileId];
if (!isPdf(stub?.name)) continue;
anyPdf = true;
const pages = stub?.processedFile?.pages;
if (!pages || pages.length === 0) {
pending.push(fileId);
continue;
}
resolved.push({
fileId,
pageCount: pages.length,
rotations: pages.map((page) => page.rotation ?? 0),
});
}
return { sources: resolved, pendingFileIds: pending, hasPdfFiles: anyPdf };
}, [fileState.files]);
useEffect(() => {
dispatch({ type: "sync", sources });
}, [sources]);
const changedFileIds = useMemo(() => changedTrackIds(state), [state]);
const stableDispatch = useCallback(
(action: TrackEditorAction) => dispatch(action),
[],
);
return {
state,
dispatch: stableDispatch,
pendingFileIds,
hasPdfFiles,
changedFileIds,
isDirty: changedFileIds.length > 0,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
};
}
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import { FileId } from "@app/types/file";
import { TrackSource, trackSignature } from "@app/components/pageTracks/types";
import {
TrackEditorState,
changedTrackIds,
initialTrackEditorState,
trackEditorReducer,
} from "@app/components/pageTracks/trackWorkspaceReducer";
const A = "file-a" as FileId;
const B = "file-b" as FileId;
const source = (
fileId: FileId,
pageCount: number,
rotations: number[] = [],
): TrackSource => ({
fileId,
pageCount,
rotations: Array.from({ length: pageCount }, (_, i) => rotations[i] ?? 0),
});
const sync = (state: TrackEditorState, sources: TrackSource[]) =>
trackEditorReducer(state, { type: "sync", sources });
const pagesOf = (state: TrackEditorState, fileId: FileId) =>
state.present.tracks[fileId]?.pages ?? [];
const ids = (state: TrackEditorState, fileId: FileId) =>
pagesOf(state, fileId).map((p) => `${p.sourceFileId}:${p.sourcePageNumber}`);
/** Two files: A with 3 pages (middle one pre-rotated 90), B with 2. */
function twoTracks(): TrackEditorState {
return sync(initialTrackEditorState, [
source(A, 3, [0, 90, 0]),
source(B, 2),
]);
}
describe("trackEditorReducer sync", () => {
it("expands each file into its own track and seeds source rotations", () => {
const state = twoTracks();
expect(state.present.order).toEqual([A, B]);
expect(pagesOf(state, A).map((p) => p.rotation)).toEqual([0, 90, 0]);
expect(pagesOf(state, B)).toHaveLength(2);
expect(changedTrackIds(state)).toEqual([]);
});
it("keeps pending edits and their dirty state when another file opens", () => {
let state = twoTracks();
state = trackEditorReducer(state, {
type: "delete",
pageIds: [pagesOf(state, A)[0].id],
});
expect(changedTrackIds(state)).toEqual([A]);
const C = "file-c" as FileId;
state = sync(state, [source(A, 3, [0, 90, 0]), source(B, 2), source(C, 1)]);
expect(pagesOf(state, A)).toHaveLength(2);
// Re-baselining everything here would mark the pending delete as saved.
expect(changedTrackIds(state)).toEqual([A]);
});
it("rebuilds a track whose underlying file changed, discarding its edits", () => {
let state = twoTracks();
state = trackEditorReducer(state, {
type: "delete",
pageIds: [pagesOf(state, A)[0].id],
});
state = sync(state, [source(A, 5), source(B, 2)]);
expect(pagesOf(state, A)).toHaveLength(5);
expect(changedTrackIds(state)).toEqual([]);
});
it("drops pages sourced from a file that is no longer open", () => {
let state = twoTracks();
const moved = pagesOf(state, B).map((p) => p.id);
state = trackEditorReducer(state, {
type: "move",
pageIds: moved,
targetFileId: A,
beforePageId: null,
});
expect(pagesOf(state, A)).toHaveLength(5);
state = sync(state, [source(A, 3, [0, 90, 0])]);
expect(state.present.order).toEqual([A]);
expect(ids(state, A)).toEqual([`${A}:1`, `${A}:2`, `${A}:3`]);
});
});
describe("trackEditorReducer operations", () => {
it("rotates only the given pages, normalising past a full turn", () => {
let state = twoTracks();
const [first, second] = pagesOf(state, A);
state = trackEditorReducer(state, {
type: "rotate",
pageIds: [first.id, second.id],
delta: 270,
});
expect(pagesOf(state, A).map((p) => p.rotation)).toEqual([270, 0, 0]);
});
it("moves a selection into another track, preserving its order", () => {
let state = twoTracks();
const [a1, , a3] = pagesOf(state, A);
const b2 = pagesOf(state, B)[1];
state = trackEditorReducer(state, {
type: "move",
pageIds: [a3.id, a1.id],
targetFileId: B,
beforePageId: b2.id,
});
// Order follows the workspace, not the order the ids were passed in.
expect(ids(state, B)).toEqual([`${B}:1`, `${A}:1`, `${A}:3`, `${B}:2`]);
expect(ids(state, A)).toEqual([`${A}:2`]);
expect(changedTrackIds(state)).toEqual([A, B]);
});
it("reorders within a track when the anchor is the moved page itself", () => {
let state = twoTracks();
const before = trackSignature(pagesOf(state, A));
const [a1] = pagesOf(state, A);
state = trackEditorReducer(state, {
type: "move",
pageIds: [a1.id],
targetFileId: A,
beforePageId: a1.id,
});
// A no-op drop must not register as an edit or fill the undo stack.
expect(trackSignature(pagesOf(state, A))).toEqual(before);
expect(state.past).toHaveLength(0);
expect(changedTrackIds(state)).toEqual([]);
});
it("appends when the anchor is null", () => {
let state = twoTracks();
const [a1] = pagesOf(state, A);
state = trackEditorReducer(state, {
type: "move",
pageIds: [a1.id],
targetFileId: A,
beforePageId: null,
});
expect(ids(state, A)).toEqual([`${A}:2`, `${A}:3`, `${A}:1`]);
});
it("empties a track without removing it, so save can close the file", () => {
let state = twoTracks();
state = trackEditorReducer(state, {
type: "delete",
pageIds: pagesOf(state, B).map((p) => p.id),
});
expect(state.present.order).toContain(B);
expect(pagesOf(state, B)).toEqual([]);
expect(changedTrackIds(state)).toEqual([B]);
});
});
describe("trackEditorReducer history", () => {
it("undoes and redoes an edit, restoring dirty state each way", () => {
let state = twoTracks();
const original = trackSignature(pagesOf(state, A));
state = trackEditorReducer(state, {
type: "delete",
pageIds: [pagesOf(state, A)[1].id],
});
state = trackEditorReducer(state, { type: "undo" });
expect(trackSignature(pagesOf(state, A))).toEqual(original);
expect(changedTrackIds(state)).toEqual([]);
state = trackEditorReducer(state, { type: "redo" });
expect(pagesOf(state, A)).toHaveLength(2);
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, {
type: "delete",
pageIds: [pagesOf(state, A)[0].id],
});
expect(state.past).toHaveLength(1);
state = sync(state, [source(A, 3, [0, 90, 0])]);
expect(state.past).toHaveLength(0);
expect(state.future).toHaveLength(0);
});
it("resets back to the last saved baseline", () => {
let state = twoTracks();
const original = trackSignature(pagesOf(state, A));
state = trackEditorReducer(state, {
type: "rotate",
pageIds: pagesOf(state, A).map((p) => p.id),
delta: 90,
});
state = trackEditorReducer(state, { type: "reset" });
expect(trackSignature(pagesOf(state, A))).toEqual(original);
});
});
@@ -0,0 +1,339 @@
import { FileId } from "@app/types/file";
import {
Track,
TrackPage,
TrackSource,
TrackWorkspace,
emptyWorkspace,
trackSignature,
} from "@app/components/pageTracks/types";
const MAX_HISTORY = 100;
export interface TrackEditorState {
present: TrackWorkspace;
/** Last saved (or freshly synced) state: what "dirty" is measured against. */
baseline: TrackWorkspace;
/** Per-file `pageCount:rotations` fingerprint, so an outside edit rebuilds. */
sourceSignatures: Record<FileId, string>;
past: TrackWorkspace[];
future: TrackWorkspace[];
/** Monotonic page-id counter, held in state to keep the reducer pure. */
seq: number;
}
export type TrackEditorAction =
| { type: "sync"; sources: TrackSource[] }
| { type: "rotate"; pageIds: string[]; delta: number }
| { type: "delete"; pageIds: string[] }
| {
type: "move";
pageIds: string[];
targetFileId: FileId;
/** Insert before this page, or append when null. */
beforePageId: string | null;
}
| { type: "undo" }
| { type: "redo" }
| { type: "reset" };
export const initialTrackEditorState: TrackEditorState = {
present: emptyWorkspace,
baseline: emptyWorkspace,
sourceSignatures: {},
past: [],
future: [],
seq: 0,
};
const sourceSignature = (source: TrackSource): string =>
`${source.pageCount}:${source.rotations.join(",")}`;
function buildTrack(source: TrackSource, seq: number): [Track, number] {
const pages: TrackPage[] = [];
let next = seq;
for (let i = 0; i < source.pageCount; i++) {
pages.push({
id: `tp-${next++}`,
sourceFileId: source.fileId,
sourcePageNumber: i + 1,
rotation: normalizeRotation(source.rotations[i] ?? 0),
});
}
return [{ fileId: source.fileId, pages }, next];
}
export const normalizeRotation = (degrees: number): number =>
(((Math.round(degrees / 90) * 90) % 360) + 360) % 360;
/** Applies `mutate` to every track, dropping unchanged tracks by reference. */
function mapTracks(
workspace: TrackWorkspace,
mutate: (pages: TrackPage[], fileId: FileId) => TrackPage[],
): TrackWorkspace {
let changed = false;
const tracks: Record<FileId, Track> = {};
for (const fileId of workspace.order) {
const track = workspace.tracks[fileId];
if (!track) continue;
const pages = mutate(track.pages, fileId);
if (pages === track.pages) {
tracks[fileId] = track;
} else {
tracks[fileId] = { ...track, pages };
changed = true;
}
}
return changed ? { order: workspace.order, tracks } : workspace;
}
function withEdit(
state: TrackEditorState,
next: TrackWorkspace,
): TrackEditorState {
if (next === state.present) return state;
return {
...state,
present: next,
past: [...state.past, state.present].slice(-MAX_HISTORY),
future: [],
};
}
function syncSources(
state: TrackEditorState,
sources: TrackSource[],
): TrackEditorState {
const signatures: Record<FileId, string> = {};
sources.forEach((source) => {
signatures[source.fileId] = sourceSignature(source);
});
const order = sources.map((s) => s.fileId);
const orderUnchanged =
order.length === state.present.order.length &&
order.every((id, i) => state.present.order[i] === id);
const signaturesUnchanged = order.every(
(id) => state.sourceSignatures[id] === signatures[id],
);
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> = {};
const baselineTracks: Record<FileId, Track> = {};
for (const source of sources) {
const existing = state.present.tracks[source.fileId];
const rebuild =
!existing ||
state.sourceSignatures[source.fileId] !== signatures[source.fileId];
if (rebuild) {
// A file that just opened, or whose bytes changed underneath us, starts
// from its own pages again, so any pending edit to it is void.
const [track, nextSeq] = buildTrack(source, seq);
seq = nextSeq;
tracks[source.fileId] = track;
baselineTracks[source.fileId] = track;
} else {
tracks[source.fileId] = existing;
// Keep the old baseline so opening another file doesn't silently mark
// pending edits as saved.
baselineTracks[source.fileId] =
state.baseline.tracks[source.fileId] ?? existing;
}
}
// A closed file's bytes are gone, so pages it sourced can't be saved anywhere.
const dropDeadSources = (pages: TrackPage[]) => {
const kept = pages.filter((p) => liveIds.has(p.sourceFileId));
return kept.length === pages.length ? pages : kept;
};
const present = mapTracks({ order, tracks }, dropDeadSources);
return {
present,
baseline: { order, tracks: baselineTracks },
sourceSignatures: signatures,
// Undo entries can reference pages from files that are no longer open, and
// restoring one would leave a page that cannot be saved. Drop the history.
past: [],
future: [],
seq,
};
}
export function trackEditorReducer(
state: TrackEditorState,
action: TrackEditorAction,
): TrackEditorState {
switch (action.type) {
case "sync":
return syncSources(state, action.sources);
case "rotate": {
if (action.pageIds.length === 0 || action.delta === 0) return state;
const ids = new Set(action.pageIds);
const next = mapTracks(state.present, (pages) => {
if (!pages.some((p) => ids.has(p.id))) return pages;
return pages.map((p) =>
ids.has(p.id)
? { ...p, rotation: normalizeRotation(p.rotation + action.delta) }
: p,
);
});
return withEdit(state, next);
}
case "delete": {
if (action.pageIds.length === 0) return state;
const ids = new Set(action.pageIds);
const next = mapTracks(state.present, (pages) => {
const kept = pages.filter((p) => !ids.has(p.id));
return kept.length === pages.length ? pages : kept;
});
return withEdit(state, next);
}
case "move": {
const { pageIds, targetFileId, beforePageId } = action;
if (pageIds.length === 0) return state;
const target = state.present.tracks[targetFileId];
if (!target) return state;
const moving = new Set(pageIds);
// Take the pages in workspace order so a multi-select keeps its sequence.
const moved: TrackPage[] = [];
for (const fileId of state.present.order) {
for (const page of state.present.tracks[fileId]?.pages ?? []) {
if (moving.has(page.id)) moved.push(page);
}
}
if (moved.length === 0) return state;
// Resolve the anchor against the track as it stands now, skipping over
// the pages being moved: the anchor is often one of them (dropping a
// selection onto itself), and it will not exist after the strip below.
const anchorId = resolveAnchor(target.pages, beforePageId, moving);
const stripped = mapTracks(state.present, (pages) => {
const kept = pages.filter((p) => !moving.has(p.id));
return kept.length === pages.length ? pages : kept;
});
const targetPages = stripped.tracks[targetFileId]?.pages ?? [];
const anchorIndex =
anchorId == null
? targetPages.length
: targetPages.findIndex((p) => p.id === anchorId);
const insertAt = anchorIndex === -1 ? targetPages.length : anchorIndex;
const nextTargetPages = [
...targetPages.slice(0, insertAt),
...moved,
...targetPages.slice(insertAt),
];
const next: TrackWorkspace = {
order: stripped.order,
tracks: {
...stripped.tracks,
[targetFileId]: {
...stripped.tracks[targetFileId],
pages: nextTargetPages,
},
},
};
if (trackSignaturesMatch(state.present, next)) return state;
return withEdit(state, next);
}
case "undo": {
if (state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
return {
...state,
present: previous,
past: state.past.slice(0, -1),
future: [state.present, ...state.future].slice(0, MAX_HISTORY),
};
}
case "redo": {
if (state.future.length === 0) return state;
const [next, ...rest] = state.future;
return {
...state,
present: next,
past: [...state.past, state.present].slice(-MAX_HISTORY),
future: rest,
};
}
case "reset":
return {
...state,
present: state.baseline,
past: [],
future: [],
};
default:
return state;
}
}
/**
* The first page at or after `beforePageId` that is not itself being moved, or
* null to append. Returns null when the anchor is not in this track at all.
*/
function resolveAnchor(
pages: TrackPage[],
beforePageId: string | null,
moving: Set<string>,
): string | null {
if (beforePageId == null) return null;
const start = pages.findIndex((p) => p.id === beforePageId);
if (start === -1) return null;
for (let i = start; i < pages.length; i++) {
if (!moving.has(pages[i].id)) return pages[i].id;
}
return null;
}
function trackSignaturesMatch(a: TrackWorkspace, b: TrackWorkspace): boolean {
if (a.order.length !== b.order.length) return false;
return a.order.every((fileId, i) => {
if (b.order[i] !== fileId) return false;
const left = a.tracks[fileId]?.pages ?? [];
const right = b.tracks[fileId]?.pages ?? [];
return (
left.length === right.length && left.every((p, j) => p.id === right[j].id)
);
});
}
/** File ids whose page list differs from the last saved baseline. */
export function changedTrackIds(state: TrackEditorState): FileId[] {
return state.present.order.filter((fileId) => {
const current = state.present.tracks[fileId]?.pages ?? [];
const original = state.baseline.tracks[fileId]?.pages ?? [];
return trackSignature(current) !== trackSignature(original);
});
}
@@ -0,0 +1,52 @@
import { FileId } from "@app/types/file";
/**
* One page instance inside a track. `sourceFileId`/`sourcePageNumber` point at
* the bytes to copy on save, so a page dragged into another track still knows
* where it came from. `id` is per-instance and survives moves.
*/
export interface TrackPage {
id: string;
sourceFileId: FileId;
sourcePageNumber: number;
/** Absolute rotation in degrees, seeded from the source page's /Rotate. */
rotation: number;
}
/** One open PDF, expanded into the pages that will be written back to it. */
export interface Track {
fileId: FileId;
pages: TrackPage[];
}
export interface TrackWorkspace {
order: FileId[];
tracks: Record<FileId, Track>;
}
/** Page counts + rotation baselines for the files a sync should cover. */
export interface TrackSource {
fileId: FileId;
pageCount: number;
rotations: number[];
}
/** Cache key for a source page's thumbnail, shared by every instance of it. */
export const sourcePageKey = (page: TrackPage): string =>
`${page.sourceFileId}#${page.sourcePageNumber}`;
export const trackSignature = (pages: TrackPage[]): string =>
pages
.map((p) => `${p.sourceFileId}:${p.sourcePageNumber}:${p.rotation}`)
.join("|");
export const emptyWorkspace: TrackWorkspace = { order: [], tracks: {} };
export const allPages = (workspace: TrackWorkspace): TrackPage[] =>
workspace.order.flatMap((fileId) => workspace.tracks[fileId]?.pages ?? []);
export const totalPageCount = (workspace: TrackWorkspace): number =>
workspace.order.reduce(
(sum, fileId) => sum + (workspace.tracks[fileId]?.pages.length ?? 0),
0,
);
@@ -240,7 +240,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
[],
);
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
currentWorkbench === "multiTool" && selectedTool === "multiTool";
const { requestNavigation } = useNavigationGuard();
const { activeFileId, setActiveFileId } = useViewer();
const { addFiles } = useFileHandler();
@@ -107,12 +107,17 @@
justify-content: center;
height: 38px;
transform: translateX(var(--workbench-bar-search-offset, 0px));
/* The slot spans the whole centre and is shifted by a transform, so its empty
flanks can sit over the view switcher or the globals cluster and swallow
their clicks. Only the pill itself takes pointer events. */
pointer-events: none;
}
.workbench-bar-search .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
pointer-events: auto;
}
.workbench-bar-search .super-search input {
@@ -59,6 +59,7 @@ import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbench
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
import { useIsMobile } from "@app/hooks/useIsMobile";
import "@app/components/shared/WorkbenchBar.css";
import CloseIcon from "@mui/icons-material/Close";
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
@@ -156,12 +157,12 @@ export default function WorkbenchBar({
pageEditorFunctions?.selectedPageIds?.length ?? 0;
const totalItems = useMemo(() => {
if (currentView === "pageEditor") return pageEditorTotalPages;
if (currentView === "multiTool") return pageEditorTotalPages;
return activeFiles.length;
}, [currentView, pageEditorTotalPages, activeFiles.length]);
const selectedCount = useMemo(() => {
if (currentView === "pageEditor") return pageEditorSelectedCount;
if (currentView === "multiTool") return pageEditorSelectedCount;
return selectedFileIds.length;
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
@@ -205,7 +206,7 @@ export default function WorkbenchBar({
return;
}
if (currentView === "pageEditor") {
if (currentView === "multiTool") {
pageEditorFunctions?.onExportAll?.();
return;
}
@@ -278,7 +279,7 @@ export default function WorkbenchBar({
}, [viewerContext]);
const handleClose = useCallback(async () => {
if (currentView === "fileEditor") {
if (currentView === "fileEditor" || currentView === "pageEditor") {
await fileActions.clearAllFiles();
} else if (currentView === "viewer") {
const file =
@@ -303,7 +304,7 @@ export default function WorkbenchBar({
} else if (countBeforeRemove <= 1) {
setCurrentView("fileEditor");
}
} else if (currentView === "pageEditor") {
} else if (currentView === "multiTool") {
pageEditorFunctions?.closePdf?.();
}
}, [
@@ -317,7 +318,7 @@ export default function WorkbenchBar({
]);
const downloadTooltip = useMemo(() => {
if (currentView === "pageEditor")
if (currentView === "multiTool")
return t("workbenchBar.exportAll", "Export PDF");
if (currentView === "viewer") return terminology.download;
if (selectedCount > 0) return terminology.downloadSelected;
@@ -405,6 +406,13 @@ export default function WorkbenchBar({
label: t("workbenchBar.viewer", "Viewer"),
icon: <InsertDriveFileOutlinedIcon fontSize="small" />,
},
{
value: "pageEditor",
label: t("workbenchBar.pageEditor", "Page Editor"),
icon: (
<LocalIcon icon="layers-outline-rounded" width="1rem" height="1rem" />
),
},
{
value: "fileEditor",
label: t("workbenchBar.activeFiles", "Active Files"),
@@ -413,7 +421,7 @@ export default function WorkbenchBar({
...(selectedTool === "multiTool"
? [
{
value: "pageEditor" as WorkbenchType,
value: "multiTool" as WorkbenchType,
label: t("workbenchBar.multiTool", "Multi-Tool"),
icon: (
<LocalIcon
@@ -602,6 +610,30 @@ export default function WorkbenchBar({
enforcingProgress={enforcingProgress}
/>
)}
{/* Close (context-aware: close all / close viewer file / close page editor) */}
{!isCustomView &&
renderWithTooltip(
<ActionIcon
variant="tertiary"
hover={false}
className="workbench-bar-action-icon"
onClick={handleClose}
disabled={
totalItems === 0 || allButtonsDisabled || disableForFullscreen
}
aria-label={
currentView === "fileEditor" || currentView === "pageEditor"
? t("workbenchBar.closeAll", "Close All")
: t("workbenchBar.closePdf", "Close PDF")
}
>
<CloseIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>,
currentView === "fileEditor" || currentView === "pageEditor"
? t("workbenchBar.closeAll", "Close All")
: t("workbenchBar.closePdf", "Close PDF"),
)}
</div>
</div>
);
@@ -54,7 +54,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
const { actions: navActions } = useNavigationActions();
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
currentWorkbench === "multiTool" && selectedTool === "multiTool";
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
@@ -6,7 +6,11 @@ import React, {
useMemo,
useRef,
} from "react";
import { WorkbenchType, getDefaultWorkbench } from "@app/types/workbench";
import {
WorkbenchType,
getDefaultWorkbench,
isPageEditorWorkbench,
} from "@app/types/workbench";
import { ToolId, isValidToolId } from "@app/types/toolId";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
@@ -161,10 +165,10 @@ export const NavigationProvider: React.FC<{
hasUnsavedChanges,
});
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
// If we're leaving a page editor, viewer, or custom workbench and have unsaved changes, request navigation
const leavingWorkbenchWithChanges =
(state.workbench === "pageEditor" &&
workbench !== "pageEditor" &&
(isPageEditorWorkbench(state.workbench) &&
workbench !== state.workbench &&
hasUnsavedChanges) ||
(state.workbench === "viewer" &&
workbench !== "viewer" &&
@@ -227,10 +231,10 @@ export const NavigationProvider: React.FC<{
const hasUnsavedChanges =
unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
// If we're leaving a page editor, viewer, or custom workbench and have unsaved changes, request navigation
const leavingWorkbenchWithChanges =
(state.workbench === "pageEditor" &&
workbench !== "pageEditor" &&
(isPageEditorWorkbench(state.workbench) &&
workbench !== state.workbench &&
hasUnsavedChanges) ||
(state.workbench === "viewer" &&
workbench !== "viewer" &&
@@ -191,9 +191,9 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
const prevWorkbench = prevWorkbenchRef.current;
const nextWorkbench = navigationState.workbench;
const isLeavingPageEditor =
prevWorkbench === "pageEditor" && nextWorkbench !== "pageEditor";
prevWorkbench === "multiTool" && nextWorkbench !== "multiTool";
const isEnteringPageEditor =
prevWorkbench !== "pageEditor" && nextWorkbench === "pageEditor";
prevWorkbench !== "multiTool" && nextWorkbench === "multiTool";
if (isLeavingPageEditor) {
clearPersistedDocument();
@@ -397,7 +397,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
]);
// When in multi-tool, sync left panel visibility with workbench:
// hide the panel on pageEditor, show it when navigating to viewer/fileEditor.
// hide the panel on its own view, show it when navigating to viewer/fileEditor.
const prevMultiToolWorkbenchRef = React.useRef<WorkbenchType | null>(null);
useEffect(() => {
const prev = prevMultiToolWorkbenchRef.current;
@@ -405,11 +405,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
if (navigationState.selectedTool !== "multiTool") return;
if (navigationState.workbench === "pageEditor" && prev !== "pageEditor") {
if (navigationState.workbench === "multiTool" && prev !== "multiTool") {
setLeftPanelView("hidden");
} else if (
navigationState.workbench !== "pageEditor" &&
prev === "pageEditor"
navigationState.workbench !== "multiTool" &&
prev === "multiTool"
) {
setLeftPanelView("toolPicker");
}
@@ -476,13 +476,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return;
}
// Handle multiTool selection - enable page editor workbench
// Handle multiTool selection - enable its own page editor workbench
if (toolId === "multiTool") {
setReaderMode(false);
setLeftPanelView("hidden");
actions.setSelectedTool("multiTool");
actions.setWorkbench(
wasInCustomWorkbench ? getDefaultWorkbench() : "pageEditor",
wasInCustomWorkbench ? getDefaultWorkbench() : "multiTool",
);
setSearchQuery("");
return;
@@ -120,7 +120,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
),
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
workbench: "pageEditor",
workbench: "multiTool",
description: t(
"home.multiTool.desc",
"Use multiple tools on a single PDF document",
@@ -0,0 +1,633 @@
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";
// 4 portrait pages whose intrinsic /Rotate is 0, 90, 270, 180.
const ROTATED_PDF = path.join(
import.meta.dirname,
"../test-fixtures/rotated-pages.pdf",
);
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
test.use({ autoGoto: false });
/** The lane of one track, addressed by the file it saves back to. */
function track(page: import("@playwright/test").Page, name: string) {
return page.locator(`section[aria-label="${name}"]`);
}
/**
* Switches workbench view. The switcher is a Mantine SegmentedControl whose
* radio input is visually hidden, so the click goes through the label.
*/
async function switchView(
page: import("@playwright/test").Page,
label: string,
) {
await page
.locator('[data-tour="view-switcher"]')
.first()
.getByText(label, { exact: true })
.click();
}
/**
* Rotations as the editor is displaying them. Thumbnails load lazily, so this
* waits for the images before reading: an empty list means "not loaded yet",
* not "no rotation".
*/
async function readRotations(
lane: import("@playwright/test").Locator,
expectedCount: number,
) {
const imgs = lane.locator("[data-page-id] img[data-original-rotation]");
await expect(imgs).toHaveCount(expectedCount, { timeout: 60_000 });
return imgs.evaluateAll((nodes) =>
nodes.map((node) => Number(node.getAttribute("data-original-rotation"))),
);
}
/**
* Presses on one tile and drags to a fraction across another, without
* releasing. dnd-kit's PointerSensor needs a real pointer sequence past its
* activation distance, and the intermediate moves let it measure the target.
*/
async function dragPageOver(
page: import("@playwright/test").Page,
from: import("@playwright/test").Locator,
to: import("@playwright/test").Locator,
fractionAcross = 0.25,
) {
const source = await from.boundingBox();
const target = await to.boundingBox();
if (!source || !target) throw new Error("drag endpoints are not laid out");
await page.mouse.move(
source.x + source.width / 2,
source.y + source.height / 2,
);
await page.mouse.down();
const dropX = target.x + target.width * fractionAcross;
const dropY = target.y + target.height / 2;
for (const step of [0.2, 0.5, 0.8, 1]) {
await page.mouse.move(
source.x + (dropX - source.x) * step,
source.y + (dropY - source.y) * step,
{ steps: 8 },
);
}
}
/**
* The file the viewer is showing, per the sidebar's "viewed" row marker. Note
* `.selected` is workbench selection, which is a different thing.
*/
function viewerActiveFile(page: import("@playwright/test").Page) {
return page.locator(".file-sidebar-file-item.viewed .file-sidebar-file-name");
}
/** The tile the insertion line is currently drawn against. */
function dropTarget(page: import("@playwright/test").Page) {
return page.locator("[data-page-id][data-drop-before]");
}
async function dragPageOnto(
page: import("@playwright/test").Page,
from: import("@playwright/test").Locator,
to: import("@playwright/test").Locator,
fractionAcross = 0.25,
) {
await dragPageOver(page, from, to, fractionAcross);
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",
timeout: 120_000,
});
await uploadFiles(page, [ROTATED_PDF, SAMPLE_PDF]);
await dismissTourTooltip(page);
await switchView(page, "Page Editor");
await expect(page.getByTestId("page-tracks")).toBeVisible({
timeout: 30_000,
});
}
test.describe("Page Editor tracks", () => {
test("expands every open PDF into its own track of pages", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
const sample = track(page, "sample.pdf");
await expect(rotated).toBeVisible({ timeout: 30_000 });
await expect(sample).toBeVisible();
// Each track holds only its own file's pages.
await expect(rotated.locator("[data-page-id]")).toHaveCount(4, {
timeout: 30_000,
});
await expect(sample.locator("[data-page-id]")).toHaveCount(1);
// Pages start at their true source rotation, not upright.
expect(await readRotations(rotated, 4)).toEqual([0, 90, 270, 180]);
});
test("rotating and deleting are held in memory until save, then versioned", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
await expect(rotated.locator("[data-page-id]")).toHaveCount(4, {
timeout: 30_000,
});
// Rotate page 3 right: 270 + 90 lands on 0, the case a save must not drop.
const third = rotated.locator("[data-page-id]").nth(2);
await third.hover();
await third.getByRole("button", { name: "Rotate right" }).click();
await expect(third.locator("img[data-original-rotation]")).toHaveAttribute(
"data-original-rotation",
"0",
);
// Delete the last page. Nothing is written yet, so the track just shrinks.
const fourth = rotated.locator("[data-page-id]").nth(3);
await fourth.hover();
await fourth.getByRole("button", { name: "Delete page" }).click();
await expect(rotated.locator("[data-page-id]")).toHaveCount(3);
await expect(rotated).toContainText("edited");
// Undo restores it; redo takes it away again.
await page.getByRole("button", { name: "Undo" }).click();
await expect(rotated.locator("[data-page-id]")).toHaveCount(4);
await page.getByRole("button", { name: "Redo" }).click();
await expect(rotated.locator("[data-page-id]")).toHaveCount(3);
await page
.getByRole("button", { name: "Save changes to all files" })
.click();
// The save landed as version 2 of the same file, with nothing pending.
const saved = track(page, "rotated-pages.pdf");
await expect(saved).toContainText("v2", { timeout: 90_000 });
await expect(saved).not.toContainText("edited");
await expect(saved.locator("[data-page-id]")).toHaveCount(3);
// The rebuilt track reads its rotations back out of the saved PDF, so this
// proves the bytes carry the absolute rotation the editor was showing,
// including page 3's 270 + 90 = 0, which a relative write would drop.
expect(await readRotations(saved, 3)).toEqual([0, 90, 0]);
// The other file was untouched, so it must still be at version 1.
await expect(track(page, "sample.pdf")).not.toContainText("v2");
});
test("dragging pages between tracks moves them, and an emptied track closes on save", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
const sample = track(page, "sample.pdf");
await expect(rotated.locator("[data-page-id]")).toHaveCount(4, {
timeout: 30_000,
});
await expect(sample.locator("[data-page-id]")).toHaveCount(1);
// Move sample.pdf's only page in front of rotated-pages.pdf's first page.
await dragPageOnto(
page,
sample.locator("[data-page-id]").first(),
rotated.locator("[data-page-id]").first(),
);
await expect(rotated.locator("[data-page-id]")).toHaveCount(5);
await expect(sample.locator("[data-page-id]")).toHaveCount(0);
// Both tracks are dirty: one gained a page, the other lost its last one.
await expect(rotated).toContainText("edited");
await expect(sample).toContainText("edited");
await page
.getByRole("button", { name: "Save changes to all files" })
.click();
// The emptied file leaves the workbench; the other keeps the moved page.
await expect(track(page, "sample.pdf")).toHaveCount(0, { timeout: 90_000 });
const saved = track(page, "rotated-pages.pdf");
await expect(saved).toContainText("v2");
await expect(saved.locator("[data-page-id]")).toHaveCount(5);
// The dragged page landed first, ahead of the original page 1.
expect(await readRotations(saved, 5)).toEqual([0, 0, 90, 270, 180]);
});
test("dragging within a track reorders only that track", async ({ page }) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
expect(await readRotations(rotated, 4)).toEqual([0, 90, 270, 180]);
// Move the last page (180) to the front.
await dragPageOnto(
page,
rotated.locator("[data-page-id]").nth(3),
rotated.locator("[data-page-id]").first(),
);
await expect(rotated.locator("[data-page-id]")).toHaveCount(4);
expect(await readRotations(rotated, 4)).toEqual([180, 0, 90, 270]);
await expect(track(page, "sample.pdf")).not.toContainText("edited");
});
test("prompts when leaving with pending edits, and can save from the prompt", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
await expect(rotated.locator("[data-page-id]")).toHaveCount(4, {
timeout: 30_000,
});
const last = rotated.locator("[data-page-id]").nth(3);
await last.hover();
await last.getByRole("button", { name: "Delete page" }).click();
await expect(rotated.locator("[data-page-id]")).toHaveCount(3);
await switchView(page, "Active Files");
await expect(
page.getByRole("heading", { name: "Unsaved Changes" }),
).toBeVisible();
await page.getByRole("button", { name: "Save & Leave" }).click();
// Back in the editor, the delete is now version 2 rather than pending.
await switchView(page, "Page Editor");
const saved = track(page, "rotated-pages.pdf");
await expect(saved).toContainText("v2", { timeout: 90_000 });
await expect(saved.locator("[data-page-id]")).toHaveCount(3);
await expect(saved).not.toContainText("edited");
});
test("the insertion line marks where a right-to-left drag actually lands", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
expect(await readRotations(rotated, 4)).toEqual([0, 90, 270, 180]);
const tiles = rotated.locator("[data-page-id]");
const second = tiles.nth(1);
const secondId = await second.getAttribute("data-page-id");
// Drag page 4 leftwards, crossing page 2's right half before settling on
// its left half. The line must follow the pointer across the midpoint, not
// stay where the tile was first entered.
await dragPageOver(page, tiles.nth(3), second, 0.25);
await expect(dropTarget(page)).toHaveAttribute(
"data-page-id",
secondId as string,
);
await page.mouse.up();
// And the drop lands exactly where the line was: 180 ahead of 90.
expect(await readRotations(rotated, 4)).toEqual([0, 180, 90, 270]);
});
test("the insertion line follows the pointer past a tile's midpoint", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
expect(await readRotations(rotated, 4)).toEqual([0, 90, 270, 180]);
const tiles = rotated.locator("[data-page-id]");
const thirdId = await tiles.nth(2).getAttribute("data-page-id");
// Settling on page 2's RIGHT half must mark page 3 instead, since the page
// is inserted after page 2.
await dragPageOver(page, tiles.nth(3), tiles.nth(1), 0.75);
await expect(dropTarget(page)).toHaveAttribute(
"data-page-id",
thirdId as string,
);
await page.mouse.up();
expect(await readRotations(rotated, 4)).toEqual([0, 90, 180, 270]);
});
test("clicking pages accumulates the selection instead of replacing it", 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 selected = rotated.locator('[data-page-id][data-selected="true"]');
await tiles.nth(0).click();
await expect(selected).toHaveCount(1);
// The second click must ADD, not move the selection onto page 2.
await tiles.nth(1).click();
await expect(selected).toHaveCount(2);
// Clicking a selected page takes it back out again.
await tiles.nth(1).click();
await expect(selected).toHaveCount(1);
await expect(tiles.nth(0)).toHaveAttribute("data-selected", "true");
// Shift extends from the last clicked page across the whole run.
await tiles.nth(3).click({ modifiers: ["Shift"] });
await expect(selected).toHaveCount(4);
// A selection spanning tracks is allowed too.
const sample = track(page, "sample.pdf");
await sample.locator("[data-page-id]").first().click();
await expect(
page.locator('[data-page-id][data-selected="true"]'),
).toHaveCount(5);
});
test("a bar action applies to every page the clicks accumulated", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
const tiles = rotated.locator("[data-page-id]");
expect(await readRotations(rotated, 4)).toEqual([0, 90, 270, 180]);
await tiles.nth(0).click();
await tiles.nth(2).click();
await page.getByRole("button", { name: "Rotate right" }).first().click();
// Only the two clicked pages turn; the ones in between are untouched.
expect(await readRotations(rotated, 4)).toEqual([90, 90, 0, 180]);
});
test("clicking the empty space around the pages deselects everything", 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 anySelected = page.locator('[data-page-id][data-selected="true"]');
await tiles.nth(0).click();
await tiles.nth(2).click();
await expect(anySelected).toHaveCount(2);
// The lane runs past its last page; that surface is not a page.
const box = await rotated.locator("[data-track-lane]").boundingBox();
if (!box) throw new Error("lane is not laid out");
const last = await tiles.nth(3).boundingBox();
if (!last) throw new Error("tile is not laid out");
await page.mouse.click(
(last.x + last.width + box.x + box.width) / 2,
box.y + box.height / 2,
);
await expect(anySelected).toHaveCount(0);
});
test("a drag that lands on empty lane space keeps the moved selection", async ({
page,
}) => {
await openPageEditor(page);
const rotated = track(page, "rotated-pages.pdf");
const sample = track(page, "sample.pdf");
const tiles = rotated.locator("[data-page-id]");
await expect(tiles).toHaveCount(4, { timeout: 30_000 });
await tiles.nth(0).click();
await tiles.nth(1).click();
await expect(
page.locator('[data-page-id][data-selected="true"]'),
).toHaveCount(2);
// sample.pdf has one page, so the lane past it is empty space: the drop
// releases over the lane, which is also where a deselect click would land.
await dragPageOnto(
page,
tiles.nth(0),
sample.locator("[data-page-id]").first(),
0.9,
);
await expect(sample.locator("[data-page-id]")).toHaveCount(3);
await expect(
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 });
}
});
test("the eye opens that track's file in the viewer", async ({ page }) => {
await openPageEditor(page);
const sample = track(page, "sample.pdf");
await expect(sample.locator("[data-page-id]")).toHaveCount(1, {
timeout: 30_000,
});
// Second track, so landing on the first file would look like success.
await sample.getByRole("button", { name: "Open in Viewer" }).click();
await expect(page.getByTestId("page-tracks")).toHaveCount(0);
await expect(viewerActiveFile(page)).toHaveText("sample.pdf");
});
test("the eye prompts when edits are pending, then views the saved version", 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 });
// Dirty the very file being opened: the save gives it a new id, so the
// viewer target has to follow the version bump.
const last = tiles.nth(3);
await last.hover();
await last.getByRole("button", { name: "Delete page" }).click();
await expect(tiles).toHaveCount(3);
await rotated.getByRole("button", { name: "Open in Viewer" }).click();
await expect(
page.getByRole("heading", { name: "Unsaved Changes" }),
).toBeVisible();
await page.getByRole("button", { name: "Save & Leave" }).click();
// Landed in the viewer on the file the eye named. Saving gives every
// changed file a NEW id, and the viewer drops an active file that has left
// the workbench, so this only holds if the target is re-pointed.
await expect(page.getByTestId("page-tracks")).toHaveCount(0, {
timeout: 90_000,
});
await expect(viewerActiveFile(page)).toHaveText("rotated-pages.pdf", {
timeout: 60_000,
});
// And the pending edit was written rather than dropped.
await switchView(page, "Page Editor");
const saved = track(page, "rotated-pages.pdf");
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");
});
});
@@ -1,9 +1,13 @@
// Define workbench values once as source of truth
export const BASE_WORKBENCH_TYPES = [
"viewer",
// Multi-file page editor: every open PDF as its own track of pages.
"pageEditor",
"fileEditor",
"myFiles",
// The Multi-Tool's own single-document page editor, only reachable while
// that tool is selected.
"multiTool",
] as const;
export type BaseWorkbenchType = (typeof BASE_WORKBENCH_TYPES)[number];
@@ -26,3 +30,10 @@ export const isBaseWorkbench = (
): value is BaseWorkbenchType => {
return BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType);
};
/**
* Views that hold in-memory page edits, so leaving them with pending changes
* must prompt: the multi-file page editor and the Multi-Tool's own editor.
*/
export const isPageEditorWorkbench = (value: WorkbenchType): boolean =>
value === "pageEditor" || value === "multiTool";