fix(preview): 首页图片预览 - 复用画布查看器 (#448)
This commit is contained in:
@@ -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`:预览、显示切换、排序和恢复默认。
|
||||
## 用户诊断包导出
|
||||
|
||||
设置页的“问题诊断”入口和任务失败快捷入口支持导出脱敏诊断包,帮助普通用户提交问题时保留足够的排障上下文。
|
||||
|
||||
@@ -696,7 +696,9 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
## 首页生成图片预览交互
|
||||
|
||||
- [ ] 在生成结果、参考图片和故事板图片预览中使用鼠标滚轮放大/缩小,确认缩放以鼠标位置为中心;放大后按住鼠标左键拖动查看局部,缩回原尺寸后确认画面回到居中状态。
|
||||
- [x] 首页图片预览改为与画布共用 `CanvasImagePreview`,移除首页独立的 `scale/offset` 和拖拽边界计算;前端 TypeScript 检查与生产构建通过。
|
||||
- [ ] 在首页生成结果、参考图片、故事板图片和画布图片节点中分别打开预览,确认遮罩、移动、滚轮缩放、缩放范围、关闭和再次打开后的初始状态一致;本次尚未完成真实浏览器交互验收。
|
||||
- [ ] 在首页提示词输入框中分别粘贴截图、从网页复制的图片和带文字的剪贴板内容,确认图片自动加入参考内容、普通文字仍能正常粘贴,超出当前模型参考数量上限时提示明确且不会重复添加。
|
||||
|
||||
## Agnes Video 2.5 JSON 协议
|
||||
|
||||
|
||||
27
web/src/components/canvas/canvas-image-preview.tsx
Normal file
27
web/src/components/canvas/canvas-image-preview.tsx
Normal 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(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user