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:
Generated
+15
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1570,6 +1570,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
path={workspaceFilePath}
|
||||
threadId={threadId}
|
||||
onClose={() => setWorkspaceFilePath(null)}
|
||||
onNavigate={setWorkspaceFilePath}
|
||||
/>
|
||||
<MemoryFileDialog
|
||||
path={memoryFilePath}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user