diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 71643c729..1dba246cf 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -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`:预览、显示切换、排序和恢复默认。 ## 用户诊断包导出 设置页的“问题诊断”入口和任务失败快捷入口支持导出脱敏诊断包,帮助普通用户提交问题时保留足够的排障上下文。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f2a644316..5d7656f3c 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -696,7 +696,9 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 首页生成图片预览交互 -- [ ] 在生成结果、参考图片和故事板图片预览中使用鼠标滚轮放大/缩小,确认缩放以鼠标位置为中心;放大后按住鼠标左键拖动查看局部,缩回原尺寸后确认画面回到居中状态。 +- [x] 首页图片预览改为与画布共用 `CanvasImagePreview`,移除首页独立的 `scale/offset` 和拖拽边界计算;前端 TypeScript 检查与生产构建通过。 +- [ ] 在首页生成结果、参考图片、故事板图片和画布图片节点中分别打开预览,确认遮罩、移动、滚轮缩放、缩放范围、关闭和再次打开后的初始状态一致;本次尚未完成真实浏览器交互验收。 +- [ ] 在首页提示词输入框中分别粘贴截图、从网页复制的图片和带文字的剪贴板内容,确认图片自动加入参考内容、普通文字仍能正常粘贴,超出当前模型参考数量上限时提示明确且不会重复添加。 ## Agnes Video 2.5 JSON 协议 diff --git a/web/src/components/canvas/canvas-image-preview.tsx b/web/src/components/canvas/canvas-image-preview.tsx new file mode 100644 index 000000000..76223edab --- /dev/null +++ b/web/src/components/canvas/canvas-image-preview.tsx @@ -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 ( + {alt} !open && onClose(), + }} + /> + ); +} diff --git a/web/src/pages/canvas/canvas-project-status-dialogs.tsx b/web/src/pages/canvas/canvas-project-status-dialogs.tsx index 367df16f1..a5f65043c 100644 --- a/web/src/pages/canvas/canvas-project-status-dialogs.tsx +++ b/web/src/pages/canvas/canvas-project-status-dialogs.tsx @@ -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, {previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? ( - {previewNode.title !open && onClosePreview(), - }} + onClose={onClosePreview} /> ) : null} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index b781c9924..49eaa5583 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -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 })}; } -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(null); - const imageRef = useRef(null); - const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number } | null>(null); - const [view, setView] = useState(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) => { - 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) => { - 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) => { - 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) => { - 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
1 ? " is-zoomed" : ""}${dragging ? " is-dragging" : ""}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={endDrag}> - 媒体预览 - -
; -} - function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { - return {url ? type === "video" ? ; + if (type === "image") return ; + + return + {url ? ; } function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 255bf0e83..f029f332f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -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; }