From b7df63c5f840fe21d6aa221446c374b85fd719ef Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 24 Mar 2026 16:31:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=E8=BD=AF=E4=BB=B6?= =?UTF-8?q?=E5=86=85=E9=A2=84=E8=A7=88=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/files/file-workspace-panel.tsx | 6 + src/components/files/image-preview.tsx | 290 ++++++++++++++++++ src/contexts/workspace-context.tsx | 64 +++- src/i18n/messages/ar.json | 5 +- src/i18n/messages/de.json | 5 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 5 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/ja.json | 5 +- src/i18n/messages/ko.json | 5 +- src/i18n/messages/pt.json | 5 +- src/i18n/messages/zh-CN.json | 5 +- src/i18n/messages/zh-TW.json | 5 +- 13 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 src/components/files/image-preview.tsx diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index ca14b33..2609f61 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -7,6 +7,7 @@ import type { editor as MonacoEditorNs } from "monaco-editor" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" +import { ImagePreview } from "@/components/files/image-preview" import { DiffViewer } from "@/components/diff/diff-viewer" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { @@ -1336,6 +1337,11 @@ export function FileWorkspacePanel() { ) } + // Image preview + if (isFileTab && activeFileTab && activeFileTab.language === "image") { + return + } + if (isPreviewMode && activeFileTab) { const absFilePath = activeFileTab.path && folderPath diff --git a/src/components/files/image-preview.tsx b/src/components/files/image-preview.tsx new file mode 100644 index 0000000..534bef8 --- /dev/null +++ b/src/components/files/image-preview.tsx @@ -0,0 +1,290 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Minus, Plus, RotateCcw } from "lucide-react" +import { useTranslations } from "next-intl" +import type { FileWorkspaceTab } from "@/contexts/workspace-context" + +const ZOOM_STEP = 0.25 +const ZOOM_MIN = 0.1 +const ZOOM_MAX = 10 +const IMAGE_PADDING = 48 // p-6 * 2 + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** + * Compute the fitted (object-fit: contain) size of an image within a container. + * Returns the CSS pixel dimensions the image would have at zoom=1. + */ +function fittedSize( + naturalW: number, + naturalH: number, + containerW: number, + containerH: number +): { width: number; height: number } { + if (naturalW === 0 || naturalH === 0 || containerW === 0 || containerH === 0) + return { width: 0, height: 0 } + const availW = containerW - IMAGE_PADDING + const availH = containerH - IMAGE_PADDING + if (availW <= 0 || availH <= 0) return { width: 0, height: 0 } + const scale = Math.min(1, availW / naturalW, availH / naturalH) + return { + width: Math.round(naturalW * scale), + height: Math.round(naturalH * scale), + } +} + +export function ImagePreview({ tab }: { tab: FileWorkspaceTab }) { + const t = useTranslations("Folder.fileWorkspacePanel") + const [zoom, setZoom] = useState(1) + const [naturalWidth, setNaturalWidth] = useState(0) + const [naturalHeight, setNaturalHeight] = useState(0) + + const fileSize = useMemo(() => { + if (!tab.content) return 0 + const base64Part = tab.content.split(",")[1] + if (!base64Part) return 0 + const padding = (base64Part.match(/=+$/) ?? [""])[0].length + return Math.floor((base64Part.length * 3) / 4) - padding + }, [tab.content]) + + const [containerSize, setContainerSize] = useState<{ + w: number + h: number + }>({ w: 0, h: 0 }) + + const handleZoomIn = useCallback(() => { + setZoom((z) => Math.min(ZOOM_MAX, z + ZOOM_STEP)) + }, []) + + const handleZoomOut = useCallback(() => { + setZoom((z) => Math.max(ZOOM_MIN, z - ZOOM_STEP)) + }, []) + + const handleZoomReset = useCallback(() => { + setZoom(1) + }, []) + + const handleImageLoad = useCallback( + (e: React.SyntheticEvent) => { + const img = e.currentTarget + setNaturalWidth(img.naturalWidth) + setNaturalHeight(img.naturalHeight) + }, + [] + ) + + // Track container size with ResizeObserver + wheel handler (passive: false). + // Uses a callback ref so setup happens when the DOM node appears + // (it's conditionally rendered behind !tab.loading). + const scrollRef = useRef(null) + const roRef = useRef(null) + const wheelHandler = useRef((e: WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + setZoom((z) => { + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + delta)) + }) + } + }) + + const scrollCallbackRef = useCallback((el: HTMLDivElement | null) => { + // Tear down previous + const prev = scrollRef.current + if (prev) { + prev.removeEventListener("wheel", wheelHandler.current) + } + if (roRef.current) { + roRef.current.disconnect() + roRef.current = null + } + + scrollRef.current = el + if (!el) return + + // ResizeObserver + roRef.current = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + setContainerSize({ + w: entry.contentRect.width, + h: entry.contentRect.height, + }) + }) + roRef.current.observe(el) + + // Wheel handler with { passive: false } so preventDefault works + el.addEventListener("wheel", wheelHandler.current, { passive: false }) + }, []) + + // Right-click drag to pan + const dragRef = useRef<{ + active: boolean + startX: number + startY: number + scrollX: number + scrollY: number + } | null>(null) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 2) return + const el = scrollRef.current + if (!el) return + e.preventDefault() + dragRef.current = { + active: true, + startX: e.clientX, + startY: e.clientY, + scrollX: el.scrollLeft, + scrollY: el.scrollTop, + } + el.style.cursor = "grabbing" + }, []) + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const drag = dragRef.current + if (!drag?.active) return + const el = scrollRef.current + if (!el) return + el.scrollLeft = drag.scrollX - (e.clientX - drag.startX) + el.scrollTop = drag.scrollY - (e.clientY - drag.startY) + } + const handleMouseUp = () => { + if (!dragRef.current?.active) return + dragRef.current = null + const el = scrollRef.current + if (el) el.style.cursor = "" + } + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + return () => { + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + } + }, []) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + }, []) + + // Compute display dimensions dynamically from natural size + container size + const ready = + naturalWidth > 0 && + naturalHeight > 0 && + containerSize.w > 0 && + containerSize.h > 0 + const base = fittedSize( + naturalWidth, + naturalHeight, + containerSize.w, + containerSize.h + ) + const displayWidth = ready ? base.width * zoom : undefined + const displayHeight = ready ? base.height * zoom : undefined + const zoomPercent = Math.round(zoom * 100) + + return ( +
+ {tab.loading && ( +
+ {t("loading")} +
+ )} + {!tab.loading && tab.content && ( + <> + {/* Toolbar */} +
+ + + + + +
+ {naturalWidth > 0 && naturalHeight > 0 && ( + + {naturalWidth} x {naturalHeight} + + )} + {fileSize > 0 && {formatFileSize(fileSize)}} +
+
+ + {/* Image */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {tab.title} +
+
+ + )} +
+ ) +} diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 91da3f4..32cb8bb 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -18,6 +18,7 @@ import { gitIsTracked, gitShowDiff, gitShowFile, + readFileBase64, readFileForEdit, readFilePreview, saveFileContent, @@ -127,6 +128,33 @@ function isDirtyFileTab(tab: FileWorkspaceTab): boolean { return tab.kind === "file" && Boolean(tab.isDirty) } +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "webp", + "bmp", + "ico", +]) + +const IMAGE_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", +} + +function isImageFile(path: string): boolean { + const ext = path.split(".").pop()?.toLowerCase() ?? "" + return IMAGE_EXTENSIONS.has(ext) +} + function loadingTab( id: string, kind: FileWorkspaceTabKind, @@ -350,6 +378,7 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { setPendingFileReveal(null) } const tabId = `file:${path}` + const image = isImageFile(path) upsertLoadingTab( loadingTab( tabId, @@ -357,10 +386,43 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { fileName(path), path, path, - languageFromPath(path) + image ? "image" : languageFromPath(path) ) ) + if (image) { + try { + const absPath = `${folderPath}/${path}` + const ext = path.split(".").pop()?.toLowerCase() ?? "" + const mime = IMAGE_MIME[ext] ?? "image/png" + const b64 = await withTimeout( + readFileBase64(absPath), + 15_000, + t("previewRequestTimedOut") + ) + setFileTabs((prev) => + prev.map((tab) => + tab.id === tabId + ? { + ...tab, + content: `data:${mime};base64,${b64}`, + readonly: true, + loading: false, + saveState: "idle", + saveError: null, + } + : tab + ) + ) + } catch (error) { + rejectTab( + tabId, + error instanceof Error ? error.message : String(error) + ) + } + return + } + try { const [result, gitBaseContent] = await withTimeout( Promise.all([ diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 4c63714..34d5b14 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -872,7 +872,10 @@ "next": "التالي", "jumpToLine": "الانتقال إلى السطر {line}", "noParsedDiffSections": "لا توجد أقسام diff محللة", - "loadingEditor": "جارٍ تحميل المحرر..." + "loadingEditor": "جارٍ تحميل المحرر...", + "imageZoomIn": "تكبير", + "imageZoomOut": "تصغير", + "imageZoomReset": "إعادة تعيين التكبير" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 89ed30b..547eba8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -872,7 +872,10 @@ "next": "Weiter", "jumpToLine": "Zu Zeile {line} springen", "noParsedDiffSections": "Keine geparsten Diff-Abschnitte", - "loadingEditor": "Editor wird geladen..." + "loadingEditor": "Editor wird geladen...", + "imageZoomIn": "Vergrößern", + "imageZoomOut": "Verkleinern", + "imageZoomReset": "Zoom zurücksetzen" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6c61fa8..1642387 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -872,7 +872,10 @@ "next": "Next", "jumpToLine": "Jump to line {line}", "noParsedDiffSections": "No parsed diff sections", - "loadingEditor": "Loading editor..." + "loadingEditor": "Loading editor...", + "imageZoomIn": "Zoom in", + "imageZoomOut": "Zoom out", + "imageZoomReset": "Reset zoom" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 058d3ad..0ae228c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -872,7 +872,10 @@ "next": "Siguiente", "jumpToLine": "Ir a la línea {line}", "noParsedDiffSections": "No hay secciones de diff analizadas", - "loadingEditor": "Cargando editor..." + "loadingEditor": "Cargando editor...", + "imageZoomIn": "Ampliar", + "imageZoomOut": "Reducir", + "imageZoomReset": "Restablecer zoom" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bba8263..359d7da 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -872,7 +872,10 @@ "next": "Suivant", "jumpToLine": "Aller à la ligne {line}", "noParsedDiffSections": "Aucune section de diff analysée", - "loadingEditor": "Chargement de l’éditeur..." + "loadingEditor": "Chargement de l’éditeur...", + "imageZoomIn": "Agrandir", + "imageZoomOut": "Réduire", + "imageZoomReset": "Réinitialiser le zoom" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 331378d..419d9a3 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -872,7 +872,10 @@ "next": "次", "jumpToLine": "{line} 行へ移動", "noParsedDiffSections": "解析済みの差分セクションがありません", - "loadingEditor": "エディターを読み込み中..." + "loadingEditor": "エディターを読み込み中...", + "imageZoomIn": "拡大", + "imageZoomOut": "縮小", + "imageZoomReset": "ズームをリセット" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 735f9e0..1759be9 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -872,7 +872,10 @@ "next": "다음", "jumpToLine": "{line}행으로 이동", "noParsedDiffSections": "파싱된 diff 섹션이 없습니다", - "loadingEditor": "에디터 로딩 중..." + "loadingEditor": "에디터 로딩 중...", + "imageZoomIn": "확대", + "imageZoomOut": "축소", + "imageZoomReset": "확대/축소 초기화" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index cdfb423..3426b27 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -872,7 +872,10 @@ "next": "Próximo", "jumpToLine": "Ir para a linha {line}", "noParsedDiffSections": "Sem seções de diff analisadas", - "loadingEditor": "Carregando editor..." + "loadingEditor": "Carregando editor...", + "imageZoomIn": "Ampliar", + "imageZoomOut": "Reduzir", + "imageZoomReset": "Redefinir zoom" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index dac0998..3a2ff75 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -872,7 +872,10 @@ "next": "下一个", "jumpToLine": "跳转到第 {line} 行", "noParsedDiffSections": "未解析到差异区块", - "loadingEditor": "编辑器加载中..." + "loadingEditor": "编辑器加载中...", + "imageZoomIn": "放大", + "imageZoomOut": "缩小", + "imageZoomReset": "重置缩放" }, "branchDropdown": { "toasts": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5a9af96..e2620f4 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -872,7 +872,10 @@ "next": "下一個", "jumpToLine": "跳到第 {line} 行", "noParsedDiffSections": "未解析到差異區塊", - "loadingEditor": "編輯器載入中..." + "loadingEditor": "編輯器載入中...", + "imageZoomIn": "放大", + "imageZoomOut": "縮小", + "imageZoomReset": "重設縮放" }, "branchDropdown": { "toasts": {