feat(webui): image zoom/pan and prev-next navigation in file dialog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 11:44:15 +08:00
parent 66781a7012
commit 666a82ebb0
7 changed files with 286 additions and 11 deletions
+15
View File
@@ -36,6 +36,7 @@
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^15.6.1",
"react-zoom-pan-pinch": "^4.0.3",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -11852,6 +11853,20 @@
"react": ">= 0.14.0"
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz",
"integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==",
"license": "MIT",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+1
View File
@@ -78,6 +78,7 @@
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^15.6.1",
"react-zoom-pan-pinch": "^4.0.3",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
+1
View File
@@ -1570,6 +1570,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
path={workspaceFilePath}
threadId={threadId}
onClose={() => setWorkspaceFilePath(null)}
onNavigate={setWorkspaceFilePath}
/>
<MemoryFileDialog
path={memoryFilePath}
+158 -11
View File
@@ -15,7 +15,16 @@ import {
Save,
Trash2,
Eye,
ZoomIn,
ZoomOut,
RotateCcw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
TransformWrapper,
TransformComponent,
} from "react-zoom-pan-pinch";
import {
Dialog,
DialogContent,
@@ -31,6 +40,11 @@ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import { DocxPreview } from "@/app/components/DocxPreview";
import { SpreadsheetPreview } from "@/app/components/SpreadsheetPreview";
import {
IMAGE_EXTS,
imageSiblings,
type ImageSiblingNav,
} from "@/lib/imageSiblings";
const LANGUAGE_MAP: Record<string, string> = {
js: "javascript",
@@ -67,7 +81,6 @@ const LANGUAGE_MAP: Record<string, string> = {
r: "r",
};
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]);
const WORD_EXTS = new Set(["doc", "docx", "docm", "dotx", "dotm"]);
const SPREADSHEET_EXTS = new Set([
"xls",
@@ -151,7 +164,9 @@ export const WorkspaceFileDialog = React.memo<{
onClose: () => void;
/** Called after a successful save or delete so the listing can refresh. */
onChanged?: () => void;
}>(({ path, threadId, size, onClose, onChanged }) => {
/** Switch the preview to another file (image prev/next within the directory). */
onNavigate?: (path: string, size?: number) => void;
}>(({ path, threadId, size, onClose, onChanged, onNavigate }) => {
const [content, setContent] = useState<string | null>(null);
const [officePreview, setOfficePreview] = useState<OfficePreview | null>(
null
@@ -206,6 +221,55 @@ export const WorkspaceFileDialog = React.memo<{
setDocxPreviewFallbackKey(null);
}, [path, threadId]);
// Prev/next image navigation within the current directory. Best-effort:
// a failed listing just leaves both buttons disabled.
const [siblingNav, setSiblingNav] = useState<ImageSiblingNav>({
prev: null,
next: null,
});
const siblingSizesRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
setSiblingNav({ prev: null, next: null });
siblingSizesRef.current = new Map();
if (!path || !threadId || kind !== "image" || !onNavigate) return;
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
let cancelled = false;
const controller = new AbortController();
const load = async () => {
const res = await fetch(
`/api/workspace?${new URLSearchParams({ threadId, path: dir })}`,
{ signal: controller.signal }
);
const body = await res.json().catch(() => null);
if (!res.ok || cancelled) return;
const entries = (body?.entries ?? []) as Array<{
path: string;
type: "dir" | "file";
ext: string;
size: number;
}>;
if (cancelled) return;
setSiblingNav(imageSiblings(entries, path));
siblingSizesRef.current = new Map(entries.map((e) => [e.path, e.size]));
};
load().catch(() => {});
return () => {
cancelled = true;
controller.abort();
};
}, [path, threadId, kind, onNavigate]);
const navigateTo = useCallback(
(target: string | null) => {
if (target && onNavigate) onNavigate(target, siblingSizesRef.current.get(target));
},
[onNavigate]
);
useEffect(() => {
const loadText = kind === "text" && !tooBigForText;
const loadOffice = supportsOfficePreview;
@@ -555,15 +619,98 @@ export const WorkspaceFileDialog = React.memo<{
placeholder="File is empty…"
/>
) : kind === "image" ? (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="flex items-center justify-center p-4">
<img
src={workspaceFileUrl(threadId, path)}
alt={name}
className="max-h-full max-w-full object-contain"
/>
</div>
</ScrollArea>
<div className="relative h-full overflow-hidden rounded-md bg-[var(--color-surface)]">
{/* key on path: switching files resets the zoom state. */}
<TransformWrapper
key={path}
initialScale={1}
minScale={0.2}
maxScale={8}
centerOnInit
smooth={false}
wheel={{ step: 0.05 }}
doubleClick={{ mode: "reset" }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="absolute right-2 top-2 z-10 flex gap-0.5 rounded-md border border-border bg-background/90 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomOut()}
aria-label="Zoom out"
title="Zoom out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomIn()}
aria-label="Zoom in"
title="Zoom in"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => resetTransform()}
aria-label="Reset zoom"
title="Fit to window"
>
<RotateCcw className="size-4" />
</Button>
</div>
<TransformComponent
wrapperClass="!h-full !w-full"
contentClass="!h-full !w-full flex items-center justify-center"
>
<img
src={workspaceFileUrl(threadId, path)}
alt={name}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
</TransformComponent>
{onNavigate && (siblingNav.prev || siblingNav.next) && (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.prev)}
disabled={!siblingNav.prev}
aria-label="Previous image"
title="Previous image"
>
<ChevronLeft className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.next)}
disabled={!siblingNav.next}
aria-label="Next image"
title="Next image"
>
<ChevronRight className="size-5" />
</Button>
</>
)}
</>
)}
</TransformWrapper>
</div>
) : kind === "pdf" ? (
<iframe
src={workspaceFileUrl(threadId, path)}
+5
View File
@@ -183,6 +183,10 @@ export function WorkspacePanel() {
path: string;
size: number;
} | null>(null);
const navigateFile = useCallback(
(p: string, s?: number) => setSelected({ path: p, size: s ?? 0 }),
[]
);
const loadDir = useCallback(
async (path: string) => {
@@ -519,6 +523,7 @@ export function WorkspacePanel() {
size={selected?.size}
onClose={() => setSelected(null)}
onChanged={refresh}
onNavigate={navigateFile}
/>
</div>
);
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { imageSiblings } from "./imageSiblings";
type Entry = { path: string; type: "dir" | "file"; ext: string };
const file = (path: string): Entry => ({
path,
type: "file",
ext: path.includes(".") ? path.split(".").pop()!.toLowerCase() : "",
});
describe("imageSiblings", () => {
it("returns the previous and next image around the current path", () => {
const entries = [file("a.png"), file("b.jpg"), file("c.webp")].map((e) => ({
...e,
path: `plots/${e.path}`,
}));
expect(imageSiblings(entries, "plots/b.jpg")).toEqual({
prev: "plots/a.png",
next: "plots/c.webp",
});
});
it("ignores non-image files and directories", () => {
const entries: Entry[] = [
file("a.png"),
file("notes.md"),
{ path: "sub", type: "dir", ext: "" },
file("b.png"),
];
expect(imageSiblings(entries, "a.png")).toEqual({
prev: null,
next: "b.png",
});
});
it("sorts by path before navigating regardless of input order", () => {
const entries = [file("c.png"), file("a.png"), file("b.png")];
expect(imageSiblings(entries, "a.png")).toEqual({
prev: null,
next: "b.png",
});
expect(imageSiblings(entries, "c.png")).toEqual({
prev: "b.png",
next: null,
});
});
it("returns nulls when the current path is not among the images", () => {
const entries = [file("a.png"), file("b.png")];
expect(imageSiblings(entries, "missing.png")).toEqual({
prev: null,
next: null,
});
});
it("matches image extensions case-insensitively", () => {
const entries = [
{ path: "A.PNG", type: "file" as const, ext: "png" },
file("b.png"),
];
expect(imageSiblings(entries, "b.png")).toEqual({
prev: "A.PNG",
next: null,
});
});
});
+33
View File
@@ -0,0 +1,33 @@
export const IMAGE_EXTS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
]);
export type ImageSiblingNav = { prev: string | null; next: string | null };
/**
* Given one directory's listing entries and the currently-viewed image path,
* return the previous/next image paths within that same directory. Entries
* are sorted by path so navigation order is deterministic.
*/
export function imageSiblings(
entries: Array<{ path: string; type: "dir" | "file"; ext: string }>,
currentPath: string
): ImageSiblingNav {
const images = entries
.filter((e) => e.type === "file" && IMAGE_EXTS.has(e.ext.toLowerCase()))
.map((e) => e.path)
.sort((a, b) => a.localeCompare(b));
const index = images.indexOf(currentPath);
if (index === -1) return { prev: null, next: null };
return {
prev: index > 0 ? images[index - 1] : null,
next: index < images.length - 1 ? images[index + 1] : null,
};
}