From 673df18b30e17bd64c1d2f816087741500a7c463 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 21 Aug 2026 15:29:42 +0100 Subject: [PATCH] Add eye icon on files --- .../pageTracks/PageTracks.module.css | 5 ++ .../core/components/pageTracks/PageTracks.tsx | 42 ++++++++++++- .../core/components/pageTracks/TrackRow.tsx | 16 +++++ .../pageTracks/hooks/useTrackSave.ts | 15 ++++- .../core/tests/stubbed/page-tracks.spec.ts | 61 +++++++++++++++++++ 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/frontend/editor/src/core/components/pageTracks/PageTracks.module.css b/frontend/editor/src/core/components/pageTracks/PageTracks.module.css index 82da8614f5..d303423f12 100644 --- a/frontend/editor/src/core/components/pageTracks/PageTracks.module.css +++ b/frontend/editor/src/core/components/pageTracks/PageTracks.module.css @@ -45,6 +45,11 @@ 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; diff --git a/frontend/editor/src/core/components/pageTracks/PageTracks.tsx b/frontend/editor/src/core/components/pageTracks/PageTracks.tsx index fc5eb9c0ca..10e099626c 100644 --- a/frontend/editor/src/core/components/pageTracks/PageTracks.tsx +++ b/frontend/editor/src/core/components/pageTracks/PageTracks.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Center, Loader, LoadingOverlay, Stack, Text } from "@mantine/core"; import { @@ -15,7 +15,11 @@ import { useSensors, } from "@dnd-kit/core"; import { useFileState } from "@app/contexts/FileContext"; -import { useNavigationGuard } from "@app/contexts/NavigationContext"; +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"; @@ -82,7 +86,38 @@ export default function PageTracks() { const selection = useTrackSelection(workspace); const thumbnails = useTrackThumbnails(); - const { saving, progress, save } = useTrackSave(workspace, changedFileIds); + 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(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>( () => new Set(), @@ -357,6 +392,7 @@ export default function PageTracks() { thumbnails={thumbnails} onSelectPage={selection.selectPage} onSelectTrack={selection.selectTrack} + onOpenInViewer={openInViewer} onClearSelection={clearSelection} onRotate={rotatePages} onDelete={deletePages} diff --git a/frontend/editor/src/core/components/pageTracks/TrackRow.tsx b/frontend/editor/src/core/components/pageTracks/TrackRow.tsx index c4bceb9e9c..e2052157f5 100644 --- a/frontend/editor/src/core/components/pageTracks/TrackRow.tsx +++ b/frontend/editor/src/core/components/pageTracks/TrackRow.tsx @@ -6,6 +6,7 @@ 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"; @@ -42,6 +43,7 @@ export interface TrackRowProps { 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; @@ -59,6 +61,7 @@ function TrackRowImpl({ thumbnails, onSelectPage, onSelectTrack, + onOpenInViewer, onClearSelection, onRotate, onDelete, @@ -148,6 +151,19 @@ function TrackRowImpl({ .filter(Boolean) .join(" ยท ")} + + onOpenInViewer(track.fileId)} + > + + +
void; +} + export interface TrackSaveHook { saving: boolean; progress: TrackSaveProgress | null; @@ -62,9 +71,12 @@ function toExportDocument( 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(null); @@ -133,6 +145,7 @@ export function useTrackSave( [outputStub], { silent: true }, ); + onVersionedRef.current?.(entry.fileId, outputStub.id); } if (emptied.length > 0) { diff --git a/frontend/editor/src/core/tests/stubbed/page-tracks.spec.ts b/frontend/editor/src/core/tests/stubbed/page-tracks.spec.ts index fa235575e4..0e182d927f 100644 --- a/frontend/editor/src/core/tests/stubbed/page-tracks.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/page-tracks.spec.ts @@ -86,6 +86,14 @@ async function dragPageOver( } } +/** + * 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]"); @@ -475,4 +483,57 @@ test.describe("Page Editor tracks", () => { 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); + }); });