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

@@ -108,6 +108,29 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
- `web/src/services/plugin-host.ts`:将插件文本请求接入宿主现有模型渠道。
- `web/src/pages/create/index.tsx`、`web/src/components/canvas/canvas-node-prompt-panel.tsx`:两个入口的接入。
## 首页图片预览与画布同款查看器
创作页 `/create` 的生成结果、参考图片和故事板图片,打开后使用与画布图片节点相同的全屏图片查看器。首页不再维护独立的图片缩放和拖拽实现,避免两个入口的交互行为逐渐分叉。
图片查看器支持移动、缩放和关闭,缩放范围为 `0.5` 到 `12`,步进为 `0.25`;视频仍使用创作页原有的视频播放弹窗。该能力只统一图片预览交互,不改变缩略图、媒体资源地址或生成流程。
### 相关实现
- `web/src/components/canvas/canvas-image-preview.tsx`:画布与创作页共用的图片预览组件。
- `web/src/pages/canvas/canvas-project-status-dialogs.tsx`:画布图片节点预览入口。
- `web/src/pages/create/index.tsx`:生成结果、参考图片和故事板图片预览入口。
## 可自定义工作台导航
内置工作台插件将常用创作入口统一交给宿主渲染。用户可以在设置页的“自定义区域”(`/settings?section=workbench`)预览侧边栏,分别控制入口显示状态,并通过拖拽或上下按钮调整同一分组内的顺序;“恢复默认”可以还原初始布局。
布局只属于当前账号在当前浏览器中的界面偏好,按账号 scope 保存在本机浏览器,不会上传素材、密钥或插件代码。功能开关不可用的入口不会展示;工作台入口使用宿主预注册的内部路由,插件不能直接注入工作区脚本。
### 相关实现
- `web/src/lib/workspace-sidebar-layout.ts`:入口清单、分组、布局归一化、排序和账号 scope 存储。
- `web/src/lib/plugins/builtin/workbench.ts`:内置工作台插件 Manifest。
- `web/src/components/layout/workspace-sidebar-nav.tsx`:按功能开关和用户布局渲染侧边栏。
- `web/src/pages/settings/workbench-settings-pane.tsx`:预览、显示切换、排序和恢复默认。
## 用户诊断包导出
设置页的“问题诊断”入口和任务失败快捷入口支持导出脱敏诊断包,帮助普通用户提交问题时保留足够的排障上下文。

View File

@@ -696,7 +696,9 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
## 首页生成图片预览交互
- [ ] 在生成结果、参考图片和故事板图片预览中使用鼠标滚轮放大/缩小,确认缩放以鼠标位置为中心;放大后按住鼠标左键拖动查看局部,缩回原尺寸后确认画面回到居中状态。
- [x] 首页图片预览改为与画布共用 `CanvasImagePreview`,移除首页独立的 `scale/offset` 和拖拽边界计算;前端 TypeScript 检查与生产构建通过。
- [ ] 在首页生成结果、参考图片、故事板图片和画布图片节点中分别打开预览,确认遮罩、移动、滚轮缩放、缩放范围、关闭和再次打开后的初始状态一致;本次尚未完成真实浏览器交互验收。
- [ ] 在首页提示词输入框中分别粘贴截图、从网页复制的图片和带文字的剪贴板内容,确认图片自动加入参考内容、普通文字仍能正常粘贴,超出当前模型参考数量上限时提示明确且不会重复添加。
## Agnes Video 2.5 JSON 协议

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; }