mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae150975c | ||
|
|
e9a6c7d8b9 | ||
|
|
8f12fa8128 | ||
|
|
673df18b30 | ||
|
|
e69ed08665 | ||
|
|
8311eb1c23 | ||
|
|
49f9aa4a69 | ||
|
|
afcf13c5c4 | ||
|
|
e2d1c4e2bc | ||
|
|
ff964416a4 | ||
|
|
9d16c7d346 |
@@ -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;
|
||||
};
|
||||
+213
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user