fix(preview): 首页图片预览 - 复用画布查看器 (#448)

This commit is contained in:
QAyong
2026-09-09 06:48:09 +08:00
committed by GitHub
parent 65f4339e2e
commit 59ecfec3e0
6 changed files with 63 additions and 92 deletions

View File

@@ -0,0 +1,27 @@
import { Image } from "antd";
type CanvasImagePreviewProps = {
src: string;
alt?: string;
onClose: () => void;
};
export function CanvasImagePreview({ src, alt = "图片", onClose }: CanvasImagePreviewProps) {
if (!src) return null;
return (
<Image
src={src}
alt={alt}
style={{ display: "none" }}
preview={{
open: true,
movable: true,
minScale: 0.5,
maxScale: 12,
scaleStep: 0.25,
onOpenChange: (open) => !open && onClose(),
}}
/>
);
}

View File

@@ -1,6 +1,7 @@
import { Button, Image, Modal } from "antd";
import { Button, Modal } from "antd";
import { XCircle } from "lucide-react";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { TaskDetailItem } from "./canvas-project-feedback";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
@@ -88,18 +89,10 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</Modal>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
<Image
<CanvasImagePreview
src={previewNode.metadata.content}
alt={previewNode.title || "图片"}
style={{ display: "none" }}
preview={{
open: true,
movable: true,
minScale: 0.5,
maxScale: 12,
scaleStep: 0.25,
onOpenChange: (open) => !open && onClosePreview(),
}}
onClose={onClosePreview}
/>
) : null}

View File

@@ -13,6 +13,7 @@ import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelPicker } from "@/components/model-picker";
@@ -1284,82 +1285,12 @@ function CreationMessageReferences({ references }: { references: CreationReferen
})}</div>;
}
type CreationImagePreviewView = { scale: number; offsetX: number; offsetY: number };
const initialCreationImagePreviewView: CreationImagePreviewView = { scale: 1, offsetX: 0, offsetY: 0 };
function CreationImagePreview({ url }: { url: string }) {
const viewportRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number } | null>(null);
const [view, setView] = useState<CreationImagePreviewView>(initialCreationImagePreviewView);
const [dragging, setDragging] = useState(false);
useEffect(() => {
setView(initialCreationImagePreviewView);
dragRef.current = null;
setDragging(false);
}, [url]);
const clampOffset = (offsetX: number, offsetY: number, scale: number) => {
const viewport = viewportRef.current;
const image = imageRef.current;
if (!viewport || !image) return { offsetX, offsetY };
const maxOffsetX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2);
const maxOffsetY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2);
return {
offsetX: Math.max(-maxOffsetX, Math.min(maxOffsetX, offsetX)),
offsetY: Math.max(-maxOffsetY, Math.min(maxOffsetY, offsetY)),
};
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const viewport = event.currentTarget;
const rect = viewport.getBoundingClientRect();
const pointerX = event.clientX - (rect.left + rect.width / 2);
const pointerY = event.clientY - (rect.top + rect.height / 2);
setView((current) => {
const nextScale = Math.max(1, Math.min(5, current.scale * Math.exp(-event.deltaY * 0.0015)));
if (nextScale === current.scale) return current;
if (nextScale === 1) return initialCreationImagePreviewView;
const scaleRatio = nextScale / current.scale;
const nextOffset = clampOffset(pointerX + (current.offsetX - pointerX) * scaleRatio, pointerY + (current.offsetY - pointerY) * scaleRatio, nextScale);
return { scale: nextScale, ...nextOffset };
});
};
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (view.scale <= 1 || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffsetX: view.offsetX, startOffsetY: view.offsetY };
setDragging(true);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const nextOffset = clampOffset(drag.startOffsetX + event.clientX - drag.startX, drag.startOffsetY + event.clientY - drag.startY, view.scale);
setView((current) => ({ ...current, ...nextOffset }));
};
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
return <div ref={viewportRef} className={`creation-media-preview-viewport${view.scale > 1 ? " is-zoomed" : ""}${dragging ? " is-dragging" : ""}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={endDrag}>
<img ref={imageRef} className="creation-media-preview-image" src={url} alt="媒体预览" draggable={false} style={{ transform: `translate3d(${view.offsetX}px, ${view.offsetY}px, 0) scale(${view.scale})` }} />
<span className="creation-media-preview-hint" aria-hidden="true">滚轮缩放 · 放大后拖动</span>
</div>;
}
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <CreationImagePreview url={url} /> : null}</Modal>;
if (type === "image") return <CanvasImagePreview src={url} alt="媒体预览" onClose={onClose} />;
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
</Modal>;
}
function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {

View File

@@ -11519,11 +11519,6 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37,
.creation-media-preview-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; }
.creation-media-preview-modal .ant-modal-close { color: #fff; }
.creation-media-preview-viewport { position: relative; display: flex; width: 100%; max-height: 82vh; align-items: center; justify-content: center; overflow: hidden; overscroll-behavior: contain; touch-action: none; user-select: none; }
.creation-media-preview-image { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; transform-origin: center center; user-select: none; will-change: transform; }
.creation-media-preview-viewport.is-zoomed .creation-media-preview-image { cursor: grab; }
.creation-media-preview-viewport.is-dragging .creation-media-preview-image { cursor: grabbing; }
.creation-media-preview-hint { position: absolute; right: 12px; bottom: 10px; pointer-events: none; border-radius: 999px; background: rgb(0 0 0 / 52%); color: rgb(255 255 255 / 78%); font-size: 11px; line-height: 1; padding: 7px 9px; }
.creation-media-preview-video { display: block; width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; }
.creation-media-preview-video { width: 100%; background: #050505; }