From 46abb8259a90f0751395973e25c90c92a95a7a6c Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 10:53:48 +0800 Subject: [PATCH] Show cached images immediately while fullscreen previews load --- components/canvas/CanvasChatPanel.tsx | 36 ++++++----- components/canvas/nodes/ImageNode.tsx | 20 +++--- components/progressive-image-preview.tsx | 74 +++++++++++++++++++++++ lib/image-delivery.ts | 20 ++++++ tests/routes/image-delivery-qiniu.test.ts | 17 +++++- 5 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 components/progressive-image-preview.tsx diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 735839f54..ce79f7b4f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -82,6 +82,7 @@ import { inferOriginalImageUrl, } from "@/lib/image-url"; import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery"; +import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, @@ -3434,6 +3435,7 @@ export function CanvasChatPanel({ const [fullscreenPreview, setFullscreenPreview] = useState<{ url: string; alt?: string; + preview: LoadedImagePreview; } | null>(null); const [downloadProgress, setDownloadProgress] = useState(null); @@ -3471,16 +3473,6 @@ export function CanvasChatPanel({ const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); const [addingClawdImageUrl, setAddingClawdImageUrl] = useState(""); const downloadAbortControllerRef = useRef(null); - const fullscreenPreviewSrc = useMemo(() => { - if (!fullscreenPreview?.url) return ""; - const originalUrl = inferOriginalImageUrl(fullscreenPreview.url); - return buildImageProxyUrl(originalUrl || fullscreenPreview.url, { - delivery: "proxy", - width: 2400, - quality: 90, - format: "webp", - }); - }, [fullscreenPreview]); useEffect(() => { return () => { if (composerUploadClearTimeoutRef.current) { @@ -17036,11 +17028,19 @@ export function CanvasChatPanel({ event.currentTarget.src = buildImageFallbackUrl(url); } }} - onClick={() => setFullscreenPreview({ url })} + onClick={(event) => setFullscreenPreview({ url, preview: { + src: event.currentTarget.currentSrc || event.currentTarget.src, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + } })} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); - setFullscreenPreview({ url }); + setFullscreenPreview({ url, preview: { + src: e.currentTarget.currentSrc || e.currentTarget.src, + width: e.currentTarget.naturalWidth, + height: e.currentTarget.naturalHeight, + } }); }} />
@@ -17286,7 +17286,7 @@ export function CanvasChatPanel({ onClick={() => setFullscreenPreview(null)} > - {fullscreenPreview.alt e.stopPropagation()} />
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index c12d2a354..09e7a83ee 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -60,6 +60,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; +import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview"; import { areImageAdjustmentsEqual, buildImageAdjustmentFilterStyle, @@ -1711,6 +1712,7 @@ export function ImageNode({ const [isUploading, setIsUploading] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); + const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState(null); const [downloadProgress, setDownloadProgress] = useState(null); const downloadAbortControllerRef = useRef(null); @@ -8559,6 +8561,11 @@ export function ImageNode({ onDoubleClick={(e) => { if (!isThisNodePicking && !boxCutoutMode && !localEditMode) { e.stopPropagation(); + setFullscreenPreviewImage({ + src: e.currentTarget.currentSrc || e.currentTarget.src, + width: e.currentTarget.naturalWidth, + height: e.currentTarget.naturalHeight, + }); setFullscreenPreviewOpen(true); } }} @@ -11964,6 +11971,7 @@ export function ImageNode({ {/* 双击放大预览弹窗 */} {fullscreenPreviewOpen && data.src && + fullscreenPreviewImage && createPortal(
{/* 关闭按钮 */} {/* 图片 */} - {data.prompt e.stopPropagation()} - src={fullscreenPreviewSrc} + src={resolveOriginalImageUrl(data.src) || data.src} + preview={fullscreenPreviewImage} style={activeImageAdjustmentsStyle} /> diff --git a/components/progressive-image-preview.tsx b/components/progressive-image-preview.tsx new file mode 100644 index 000000000..8db58ea78 --- /dev/null +++ b/components/progressive-image-preview.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react"; +import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery"; + +export type LoadedImagePreview = { src: string; width: number; height: number }; + +/** Keep the already displayed image visible while its larger preview loads. */ +export function ProgressiveImagePreview({ + src, + preview, + alt, + className, + style, + onClick, +}: { + src: string; + preview: LoadedImagePreview; + alt: string; + className?: string; + style?: CSSProperties; + onClick?: MouseEventHandler; +}) { + const size = getFullscreenPreviewSize({ + imageWidth: preview.width, + imageHeight: preview.height, + viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth, + viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight, + pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio, + }); + const fullSrc = buildImageProxyUrl(src, { + delivery: "proxy", + width: size.requestWidth, + quality: 86, + format: "webp", + }); + const [loadedSrc, setLoadedSrc] = useState(""); + const [failedSrc, setFailedSrc] = useState(""); + const ready = fullSrc === preview.src || loadedSrc === fullSrc; + const failed = failedSrc === fullSrc; + + useEffect(() => { + if (fullSrc === preview.src) return; + const image = new Image(); + image.decoding = "async"; + image.onload = () => setLoadedSrc(fullSrc); + image.onerror = () => setFailedSrc(fullSrc); + image.src = fullSrc; + return () => { + image.onload = null; + image.onerror = null; + image.removeAttribute("src"); + }; + }, [fullSrc, preview.src]); + + return ( +
event.stopPropagation()}> + {alt} + {!ready && ( +
+ {failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"} +
+ )} +
+ ); +} diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index f34e7af1b..fffdfe9a2 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -20,6 +20,26 @@ export type ImageProxyOptions = { delivery?: "display" | "proxy" | "original"; }; +export function getFullscreenPreviewSize({ + imageWidth, + imageHeight, + viewportWidth, + viewportHeight, + pixelRatio = 1, +}: { + imageWidth: number; + imageHeight: number; + viewportWidth: number; + viewportHeight: number; + pixelRatio?: number; +}) { + const ratio = imageWidth > 0 && imageHeight > 0 ? imageWidth / imageHeight : 1; + const displayWidth = Math.min(viewportWidth * 0.95, viewportHeight * 0.95 * ratio); + const requiredWidth = displayWidth * Math.max(1, pixelRatio || 1); + const requestWidth = [640, 960, 1280, 1600, 2048, 2560, 3200, 4096].find((width) => width >= requiredWidth) || 4096; + return { displayWidth, requestWidth }; +} + /** Preserve the actual response format when naming an original download. */ export function getImageDownloadExtension(contentType: string) { const mime = contentType.split(";")[0]?.trim().toLowerCase(); diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index 658cbdfdc..5e9268db6 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -2,9 +2,24 @@ import { expect, test } from "@playwright/test"; import { NextRequest } from "next/server"; import sharp from "sharp"; import { GET } from "@/app/api/image-proxy/route"; -import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; +import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery"; test.describe("Qiniu image delivery", () => { + test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => { + const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 }); + expect(size.displayWidth).toBeCloseTo(839.8); + expect(size.requestWidth).toBe(960); + expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" })) + .toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" })); + }); + + test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => { + expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048); + const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 }); + expect(portrait.displayWidth).toBeCloseTo(427.5); + expect(portrait.requestWidth).toBe(960); + expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096); + }); test("reports a timeout while reading image bytes as a retryable response", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => new Response(new ReadableStream({