From 666a82ebb0391e14ef6e1542bffdc53bce553de6 Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 11:44:15 +0800 Subject: [PATCH] feat(webui): image zoom/pan and prev-next navigation in file dialog Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 15 ++ package.json | 1 + src/app/components/ChatInterface.tsx | 1 + src/app/components/WorkspaceFileDialog.tsx | 169 +++++++++++++++++++-- src/app/components/WorkspacePanel.tsx | 5 + src/lib/imageSiblings.test.ts | 73 +++++++++ src/lib/imageSiblings.ts | 33 ++++ 7 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 src/lib/imageSiblings.test.ts create mode 100644 src/lib/imageSiblings.ts diff --git a/package-lock.json b/package-lock.json index b57f401..6fc71e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6bb147e..698799c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index f42f551..4db39ab 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -1570,6 +1570,7 @@ export const ChatInterface = React.memo( path={workspaceFilePath} threadId={threadId} onClose={() => setWorkspaceFilePath(null)} + onNavigate={setWorkspaceFilePath} /> = { js: "javascript", @@ -67,7 +81,6 @@ const LANGUAGE_MAP: Record = { 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(null); const [officePreview, setOfficePreview] = useState( 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({ + prev: null, + next: null, + }); + const siblingSizesRef = useRef>(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" ? ( - -
- {name} -
-
+
+ {/* key on path: switching files resets the zoom state. */} + + {({ zoomIn, zoomOut, resetTransform }) => ( + <> +
+ + + +
+ + {name} + + {onNavigate && (siblingNav.prev || siblingNav.next) && ( + <> + + + + )} + + )} +
+
) : kind === "pdf" ? (