From 75e1ac93db25a12dece8a83892c63f19d78b7673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 22 Jun 2026 21:37:39 +0800 Subject: [PATCH] Refactor UI asset extraction overlay and handlers Remove explicit cancel callback and centralize dismissal: clicking the canvas now dismisses active UI extraction (handled in ImageCanvasEditorView) instead of relying on an onCancel prop. Simplify ImageCanvasStageView and ImageCanvasUiAssetExtractionOverlayView APIs by removing onCancel and related UI. Improve preview bounds and rendering: add normalization to mark bounds, clamp to source dimensions, and render crop previews using an absolutely-positioned for accurate, coordinate-based previews. Update styles for toolbar active states and preview image layout, and reduce SVG stroke width. Update tests and docs to reflect new behaviors and UI wording (tool highlight, accurate crop preview, and dismissal behavior). --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 6 +- .../ImageCanvasEditorShellView.test.tsx | 1 - .../image-editor/ImageCanvasEditorView.tsx | 15 ++++- .../image-editor/ImageCanvasStageView.tsx | 3 - ...anvasUiAssetExtractionOverlayView.test.tsx | 11 +++- ...mageCanvasUiAssetExtractionOverlayView.tsx | 66 ++++++++++++------- src/index.css | 15 +++-- 8 files changed, 79 insertions(+), 40 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 486b802c3..5074673be 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -101,7 +101,7 @@ - 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,显示短提示语、框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index e6b600bbc..ff3c9cd61 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -41,7 +41,7 @@ 8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。 -11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态,再点同一工具取消启用。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 +11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 ## 参数交互 @@ -174,7 +174,7 @@ - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。 - 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。 -- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并显示选中态,再点同一工具取消;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 +- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 - 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。 - 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图。 - 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。 @@ -182,7 +182,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,点击同一工具可取消启用态;面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 9d3b2d3f5..91ce99ff7 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -181,7 +181,6 @@ function createStageProps(): ImageCanvasStageViewProps { onUiAssetExtractionPointerStart: vi.fn(), onUiAssetExtractionPointerMove: vi.fn(), onUiAssetExtractionPointerEnd: vi.fn(), - onCancelUiAssetExtraction: vi.fn(), onSubmitUiAssetExtraction: vi.fn(), onQuickEditSelectionToolChange: vi.fn(), onQuickEditSelectionPointerStart: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 70ea0f28b..a6d84f995 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,5 +1,6 @@ import { type Dispatch, + type PointerEvent as ReactPointerEvent, type SetStateAction, useCallback, useEffect, @@ -948,6 +949,17 @@ export function ImageCanvasEditorView() { onCloseImageContextMenu: () => setImageContextMenu(null), }); resetCanvasInteractionStateRef.current = clearActiveInteraction; + const handleCanvasPointerDownWithUiExtractionDismiss = useCallback( + (event: ReactPointerEvent) => { + if (uiAssetExtractionState) { + event.preventDefault(); + cancelUiAssetExtraction(); + return; + } + handleCanvasPointerDown(event); + }, + [cancelUiAssetExtraction, handleCanvasPointerDown, uiAssetExtractionState], + ); const deleteLayerByIdFromShortcut = useCallback( (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], @@ -1357,7 +1369,7 @@ export function ImageCanvasEditorView() { activeSidebarPanel, isMinimapOpen, minimapModel, - onCanvasPointerDown: handleCanvasPointerDown, + onCanvasPointerDown: handleCanvasPointerDownWithUiExtractionDismiss, onCanvasPointerMove: handlePointerMove, onCanvasPointerUp: finishDrag, onCanvasDragOver: handleCanvasDragOver, @@ -1391,7 +1403,6 @@ export function ImageCanvasEditorView() { onUiAssetExtractionPointerStart: startUiAssetExtractionPointer, onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer, onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer, - onCancelUiAssetExtraction: cancelUiAssetExtraction, onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(), onQuickEditSelectionToolChange: changeQuickEditSelectionTool, onQuickEditSelectionPointerStart: startQuickEditSelectionPointer, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 3a00f6ca0..73d88f1aa 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -121,7 +121,6 @@ export type ImageCanvasStageViewProps = { onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerEnd: () => void; - onCancelUiAssetExtraction: () => void; onSubmitUiAssetExtraction: () => void; onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void; onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void; @@ -232,7 +231,6 @@ export function ImageCanvasStageView({ onUiAssetExtractionPointerStart, onUiAssetExtractionPointerMove, onUiAssetExtractionPointerEnd, - onCancelUiAssetExtraction, onSubmitUiAssetExtraction, onQuickEditSelectionToolChange, onQuickEditSelectionPointerStart, @@ -343,7 +341,6 @@ export function ImageCanvasStageView({ onPointerStart={onUiAssetExtractionPointerStart} onPointerMove={onUiAssetExtractionPointerMove} onPointerEnd={onUiAssetExtractionPointerEnd} - onCancel={onCancelUiAssetExtraction} onSubmit={onSubmitUiAssetExtraction} /> diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index 362a9aeb8..8fdf99399 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -109,7 +109,6 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { onPointerMove={vi.fn()} onPointerEnd={vi.fn()} onSubmit={vi.fn()} - onCancel={vi.fn()} />, ); @@ -120,7 +119,9 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { expect(panel.className).toContain( 'image-canvas-editor__ui-extraction-panel', ); - expect(within(panel).getByText('仅提取被红色框框选的素材并整理成spritesheet')).toBeTruthy(); + expect( + within(panel).getByText('使用框选工具框选你希望从画面中提取的素材'), + ).toBeTruthy(); expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7); expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K'); expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2'); @@ -206,7 +207,13 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { '.image-canvas-editor__ui-extraction-mark-label', ), ).map((node) => node.textContent); + const labelCircles = Array.from( + container.querySelectorAll( + '.image-canvas-editor__ui-extraction-mark-label circle', + ), + ).map((node) => node.getAttribute('r')); expect(labels).toEqual(['1', '2']); + expect(labelCircles).toEqual(['12', '12']); }); }); diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx index 8a86db699..91694c6e3 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx @@ -1,4 +1,4 @@ -import { Circle, Paintbrush, Square, X } from 'lucide-react'; +import { Circle, Paintbrush, Square } from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; @@ -29,7 +29,6 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = { onPointerStart: (point: { x: number; y: number }) => void; onPointerMove: (point: { x: number; y: number }) => void; onPointerEnd: () => void; - onCancel?: () => void; onSubmit?: () => void; }; @@ -81,6 +80,31 @@ function resolveMarkPreviewBounds( mark: UiAssetExtractionMark, sourceLayer: CanvasLayer, ) { + const originalWidth = Math.max(1, sourceLayer.originalWidth); + const originalHeight = Math.max(1, sourceLayer.originalHeight); + const normalizeBounds = (bounds: { + x: number; + y: number; + width: number; + height: number; + }) => { + const left = Math.min(Math.max(bounds.x, 0), originalWidth); + const top = Math.min(Math.max(bounds.y, 0), originalHeight); + const right = Math.min( + Math.max(bounds.x + bounds.width, 0), + originalWidth, + ); + const bottom = Math.min( + Math.max(bounds.y + bounds.height, 0), + originalHeight, + ); + return { + x: left, + y: top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; + }; if (mark.tool === 'brush') { const xs = mark.points.map((point) => point.x); const ys = mark.points.map((point) => point.y); @@ -88,19 +112,19 @@ function resolveMarkPreviewBounds( const top = Math.min(...ys); const right = Math.max(...xs); const bottom = Math.max(...ys); - return { + return normalizeBounds({ x: Number.isFinite(left) ? left : 0, y: Number.isFinite(top) ? top : 0, width: Math.max(1, Number.isFinite(right - left) ? right - left : 1), height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1), - }; + }); } - return { + return normalizeBounds({ x: Math.min(mark.x, mark.x + mark.width), y: Math.min(mark.y, mark.y + mark.height), width: Math.max(1, Math.abs(mark.width)), height: Math.max(1, Math.abs(mark.height)), - }; + }); } function renderMarkPreview( @@ -116,16 +140,17 @@ function renderMarkPreview( key={mark.id} aria-label={`框选区域预览 ${index + 1}`} className="image-canvas-editor__ui-extraction-preview" - style={{ - backgroundImage: `url("${sourceLayer.src}")`, - backgroundPosition: `${(bounds.x / originalWidth) * 100}% ${ - (bounds.y / originalHeight) * 100 - }%`, - backgroundSize: `${(originalWidth / bounds.width) * 100}% ${ - (originalHeight / bounds.height) * 100 - }%`, - }} > + {index + 1} ); @@ -172,7 +197,7 @@ function renderMark( className="image-canvas-editor__ui-extraction-mark-label" transform={`translate(${labelPoint.x} ${labelPoint.y})`} > - + {index + 1} @@ -191,7 +216,6 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerStart, onPointerMove, onPointerEnd, - onCancel, onSubmit, }: ImageCanvasUiAssetExtractionOverlayViewProps) { if (!sourceLayer || !state) { @@ -308,7 +332,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerDown={(event) => event.stopPropagation()} > - 仅提取被红色框框选的素材并整理成spritesheet + 使用框选工具框选你希望从画面中提取的素材 {IMAGE_MODEL_GPT_IMAGE_2} - } - onClick={onCancel} - />