Add eye icon on files

This commit is contained in:
James Brunton
2026-08-24 10:04:27 +01:00
parent e69ed08665
commit 673df18b30
5 changed files with 135 additions and 4 deletions
@@ -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;
@@ -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<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>(),
@@ -357,6 +392,7 @@ export default function PageTracks() {
thumbnails={thumbnails}
onSelectPage={selection.selectPage}
onSelectTrack={selection.selectTrack}
onOpenInViewer={openInViewer}
onClearSelection={clearSelection}
onRotate={rotatePages}
onDelete={deletePages}
@@ -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(" · ")}
</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
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import {
createChildStub,
@@ -21,6 +21,15 @@ export interface TrackSaveProgress {
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;
@@ -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<TrackSaveProgress | null>(null);
@@ -133,6 +145,7 @@ export function useTrackSave(
[outputStub],
{ silent: true },
);
onVersionedRef.current?.(entry.fileId, outputStub.id);
}
if (emptied.length > 0) {
@@ -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);
});
});