diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9019770a5..b27a1be23 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -15,7 +15,7 @@ - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 -- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 +- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 @@ -29,7 +29,7 @@ - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。 - 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 -- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。 +- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -87,7 +87,7 @@ - `显示画布所有元素` 按可见图层外接矩形计算 viewport。 - 左下角小地图可展示当前图层分布和视口范围,开关按钮可隐藏 / 恢复小地图,背景色菜单可切换白色、浅灰、暖灰和冷蓝工作区底色。 - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 -- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 +- 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 52a820a13..9841871d0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -589,4 +589,79 @@ describe('ImageCanvasEditorModel', () => { horizontal: anchorLayer.y, }); }); + + it('snaps moving layers to equal horizontal and vertical spacing', () => { + const movingLayer: CanvasLayer = { + id: 'moving', + resourceId: 'resource-moving', + title: '移动图', + src: 'data:image/png;base64,moving', + x: 0, + y: 0, + width: 80, + height: 80, + originalWidth: 80, + originalHeight: 80, + zIndex: 1, + sourceType: 'uploaded', + }; + const firstLayer: CanvasLayer = { + ...movingLayer, + id: 'first', + resourceId: 'resource-first', + x: 100, + y: 160, + zIndex: 2, + }; + const secondLayer: CanvasLayer = { + ...movingLayer, + id: 'second', + resourceId: 'resource-second', + x: 260, + y: 160, + zIndex: 3, + }; + const topLayer: CanvasLayer = { + ...movingLayer, + id: 'top', + resourceId: 'resource-top', + x: 520, + y: 120, + zIndex: 4, + }; + const bottomLayer: CanvasLayer = { + ...movingLayer, + id: 'bottom', + resourceId: 'resource-bottom', + x: 520, + y: 300, + zIndex: 5, + }; + + const horizontalSnap = resolveSnappedLayerPosition( + movingLayer, + 417, + 160, + [movingLayer, firstLayer, secondLayer], + 1, + ); + expect(horizontalSnap.x).toBe(420); + expect(horizontalSnap.guide).toEqual({ + vertical: 460, + horizontal: 160, + }); + + const verticalSnap = resolveSnappedLayerPosition( + movingLayer, + 520, + 207, + [movingLayer, topLayer, bottomLayer], + 1, + ); + expect(verticalSnap.y).toBe(210); + expect(verticalSnap.guide).toEqual({ + vertical: 520, + horizontal: 250, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 17e7dcd7c..359e54825 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -9,6 +9,7 @@ import type { CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, + CanvasSnapItem, CanvasMediaType, CanvasViewport, CharacterReferenceImage, @@ -34,6 +35,7 @@ export const MAX_SCALE = 3.2; export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; +export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; @@ -1086,43 +1088,78 @@ export function resolveSnappedLayerPosition( proposedY: number, layers: CanvasLayer[], scale: number, +) { + return resolveSnappedItemPosition( + movingLayer, + proposedX, + proposedY, + layers, + scale, + ); +} + +export function resolveSnappedItemPosition( + movingItem: CanvasSnapItem, + proposedX: number, + proposedY: number, + items: CanvasSnapItem[], + scale: number, ) { const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE); + const visibleItems = items.filter( + (item) => item.id !== movingItem.id && !item.hidden, + ); const verticalTargets = [ 0, CANVAS_WORLD_ORIGIN, - ...layers - .filter((layer) => layer.id !== movingLayer.id) - .flatMap((layer) => [ - layer.x, - layer.x + layer.width / 2, - layer.x + layer.width, - ]), + ...visibleItems.flatMap((item) => [ + item.x, + item.x + item.width / 2, + item.x + item.width, + ]), ]; const horizontalTargets = [ 0, CANVAS_WORLD_ORIGIN, - ...layers - .filter((layer) => layer.id !== movingLayer.id) - .flatMap((layer) => [ - layer.y, - layer.y + layer.height / 2, - layer.y + layer.height, - ]), + ...visibleItems.flatMap((item) => [ + item.y, + item.y + item.height / 2, + item.y + item.height, + ]), ]; - const xSnap = findNearestSnap( + const xAlignmentSnap = findNearestSnap( proposedX, - [0, movingLayer.width / 2, movingLayer.width], + [0, movingItem.width / 2, movingItem.width], verticalTargets, threshold, ); - const ySnap = findNearestSnap( + const yAlignmentSnap = findNearestSnap( proposedY, - [0, movingLayer.height / 2, movingLayer.height], + [0, movingItem.height / 2, movingItem.height], horizontalTargets, threshold, ); + const xDistributionSnap = findNearestEqualSpacingSnap({ + axis: 'x', + proposedStart: proposedX, + proposedCrossStart: proposedY, + movingSize: movingItem.width, + movingCrossSize: movingItem.height, + items: visibleItems, + threshold, + }); + const yDistributionSnap = findNearestEqualSpacingSnap({ + axis: 'y', + proposedStart: proposedY, + proposedCrossStart: proposedX, + movingSize: movingItem.height, + movingCrossSize: movingItem.width, + items: visibleItems, + threshold, + }); + const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap); + const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap); return { x: xSnap ? xSnap.position : proposedX, @@ -1137,6 +1174,177 @@ export function resolveSnappedLayerPosition( }; } +function chooseNearestSnap( + first: SnapCandidate | null, + second: SnapCandidate | null, +) { + if (!first) { + return second; + } + if (!second) { + return first; + } + return second.distance < first.distance ? second : first; +} + +function findNearestEqualSpacingSnap({ + axis, + proposedStart, + proposedCrossStart, + movingSize, + movingCrossSize, + items, + threshold, +}: { + axis: 'x' | 'y'; + proposedStart: number; + proposedCrossStart: number; + movingSize: number; + movingCrossSize: number; + items: CanvasSnapItem[]; + threshold: number; +}): SnapCandidate | null { + const orderedItems = [...items].sort( + (firstItem, secondItem) => + getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), + ); + let nearest: SnapCandidate | null = null; + + for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) { + const firstItem = orderedItems[firstIndex]; + if (!firstItem) { + continue; + } + for ( + let secondIndex = firstIndex + 1; + secondIndex < orderedItems.length; + secondIndex += 1 + ) { + const secondItem = orderedItems[secondIndex]; + if (!secondItem) { + continue; + } + if ( + !snapItemsOverlapOnCrossAxis( + proposedCrossStart, + movingCrossSize, + firstItem, + axis, + ) || + !snapItemsOverlapOnCrossAxis( + proposedCrossStart, + movingCrossSize, + secondItem, + axis, + ) + ) { + continue; + } + + const firstStart = getSnapItemStart(firstItem, axis); + const firstEnd = getSnapItemEnd(firstItem, axis); + const secondStart = getSnapItemStart(secondItem, axis); + const secondEnd = getSnapItemEnd(secondItem, axis); + const pairGap = secondStart - firstEnd; + if (pairGap < 0) { + continue; + } + + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: firstStart - pairGap - movingSize, + proposedStart, + movingSize, + threshold, + }), + ); + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: secondEnd + pairGap, + proposedStart, + movingSize, + threshold, + }), + ); + + const betweenGap = (pairGap - movingSize) / 2; + if (betweenGap >= 0) { + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: firstEnd + betweenGap, + proposedStart, + movingSize, + threshold, + }), + ); + } + } + } + + return nearest; +} + +function createDistributionSnapCandidate({ + position, + proposedStart, + movingSize, + threshold, +}: { + position: number; + proposedStart: number; + movingSize: number; + threshold: number; +}): SnapCandidate | null { + const distance = Math.abs(position - proposedStart); + if (distance > threshold) { + return null; + } + return { + position, + guide: position + movingSize / 2, + distance, + }; +} + +function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.x : item.y; +} + +function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.width : item.height; +} + +function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') { + return getSnapItemStart(item, axis) + getSnapItemSize(item, axis); +} + +function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.y : item.x; +} + +function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.height : item.width; +} + +function snapItemsOverlapOnCrossAxis( + proposedCrossStart: number, + movingCrossSize: number, + item: CanvasSnapItem, + axis: 'x' | 'y', +) { + const movingCrossEnd = proposedCrossStart + movingCrossSize; + const itemCrossStart = getSnapItemCrossStart(item, axis); + const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis); + return ( + proposedCrossStart <= + itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && + movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE + ); +} + export function findNearestSnap( origin: number, offsets: number[], diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 7fcb13f5b..0b7183f11 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -359,6 +359,15 @@ export type SnapCandidate = { distance: number; }; +export type CanvasSnapItem = { + id: string; + x: number; + y: number; + width: number; + height: number; + hidden?: boolean; +}; + export type AssetMarqueeState = { pointerId: number; startX: number; diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index 31995d2f6..d461ca4cc 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -263,15 +263,19 @@ describe('ImageCanvasInteractionModel', () => { expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({ x: 200, - y: 90, + y: 100, }); expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({ x: 330, - y: 90, + y: 100, }); expect(result?.snapGuide).toEqual({ vertical: 300, - horizontal: 90, + horizontal: 100, + }); + expect(result?.snapOffset).toEqual({ + x: 0, + y: 10, }); const generationDialogs = [ @@ -292,6 +296,7 @@ describe('ImageCanvasInteractionModel', () => { moveGenerationFramesFromDrag({ generationDialogs, pointer: { x: 140, y: 170 }, + snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', @@ -309,15 +314,20 @@ describe('ImageCanvasInteractionModel', () => { startLayers: [{ id: 'moving', x: 90, y: 90 }], startScale: 2, }, - }).map((dialog) => [dialog.id, dialog.placeholder?.x, dialog.placeholder?.y]), + }).generationDialogs.map((dialog) => [ + dialog.id, + dialog.placeholder?.x, + dialog.placeholder?.y, + ]), ).toEqual([ - ['dialog-1', 120, 105], - ['dialog-2', 420, 105], + ['dialog-1', 126, 101], + ['dialog-2', 426, 101], ]); expect( moveDragSelectedLayers({ layers, pointer: { x: 140, y: 170 }, + snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', @@ -333,7 +343,54 @@ describe('ImageCanvasInteractionModel', () => { startScale: 2, }, }).find((layer) => layer.id === 'moving'), - ).toMatchObject({ x: 110, y: 75 }); + ).toMatchObject({ x: 116, y: 71 }); + }); + + it('snaps generation frames to nearby layer guides while dragging', () => { + const [anchorLayer] = [ + createLayer({ id: 'anchor', x: 420, y: 300, width: 120, height: 100 }), + ]; + const [movingDialog] = [ + createGenerationDialog({ + id: 'dialog-moving', + placeholder: { + x: 100, + y: 120, + width: 120, + height: 100, + originalWidth: 120, + originalHeight: 100, + }, + }), + ]; + const result = moveGenerationFramesFromDrag({ + layers: [anchorLayer], + generationDialogs: [movingDialog], + pointer: { x: 414, y: 292 }, + dragState: { + kind: 'generation-frame', + dialogId: 'dialog-moving', + dialogIds: ['dialog-moving'], + layerIds: [], + pointerId: 1, + startClientX: 100, + startClientY: 120, + startFrameX: 100, + startFrameY: 120, + startFrames: [{ id: 'dialog-moving', x: 100, y: 120 }], + startLayers: [], + startScale: 1, + }, + }); + + expect(result.generationDialogs[0]?.placeholder).toMatchObject({ + x: 420, + y: 300, + }); + expect(result.snapGuide).toEqual({ + vertical: 420, + horizontal: 300, + }); }); it('builds minimap projection and moves the viewport from minimap interactions', () => { diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index b4eb3ca3d..692210672 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -17,7 +17,7 @@ import { MINIMAP_SIZE, clamp, getLayerBounds, - resolveSnappedLayerPosition, + resolveSnappedItemPosition, } from './ImageCanvasEditorModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; @@ -40,7 +40,14 @@ export type CanvasRect = { export type CanvasLayerMoveResult = { layers: CanvasLayer[]; - snapGuide: ReturnType['guide']; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; +}; + +export type CanvasGenerationFrameMoveResult = { + generationDialogs: CanvasGenerationDialogState[]; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; }; export type StageMinimapModel = { @@ -269,6 +276,47 @@ function intersectsScreenRect({ ); } +function getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds = [], + excludedGenerationDialogIds = [], +}: { + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; + excludedLayerIds?: string[]; + excludedGenerationDialogIds?: string[]; +}) { + const layerSnapItems = layers + .filter((layer) => !excludedLayerIds.includes(layer.id)) + .map((layer) => ({ + id: layer.id, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + hidden: layer.hidden, + })); + const generationSnapItems = generationDialogs.flatMap((dialog) => { + if ( + !dialog.placeholder || + excludedGenerationDialogIds.includes(dialog.id) + ) { + return []; + } + return [ + { + id: getCanvasGenerationSelectionId(dialog.id), + x: dialog.placeholder.x, + y: dialog.placeholder.y, + width: dialog.placeholder.width, + height: dialog.placeholder.height, + }, + ]; + }); + return [...layerSnapItems, ...generationSnapItems]; +} + export function selectLayersInsideMarquee({ marquee, currentPoint, @@ -338,10 +386,12 @@ export function selectCanvasObjectsInsideMarquee({ export function moveLayersFromDrag({ dragState, layers, + generationDialogs = [], pointer, }: { dragState: Extract; layers: CanvasLayer[]; + generationDialogs?: CanvasGenerationDialogState[]; pointer: CanvasPoint; }): CanvasLayerMoveResult | null { const movingLayer = layers.find((layer) => layer.id === dragState.layerId); @@ -352,16 +402,26 @@ export function moveLayersFromDrag({ const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const proposedX = dragState.startLayerX + deltaX; const proposedY = dragState.startLayerY + deltaY; - const snapped = resolveSnappedLayerPosition( + const snapped = resolveSnappedItemPosition( movingLayer, proposedX, proposedY, - layers, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: dragState.generationDialogIds ?? [], + }), dragState.startScale, ); + const snapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; return { snapGuide: snapped.guide, + snapOffset, layers: layers.map((layer) => dragState.layerIds.includes(layer.id) ? (() => { @@ -392,12 +452,16 @@ export function moveLayersFromDrag({ export function moveGenerationFramesFromDrag({ dragState, generationDialogs, + layers = [], + snapOffset, pointer, }: { dragState: Extract; generationDialogs: CanvasGenerationDialogState[]; + layers?: CanvasLayer[]; + snapOffset?: CanvasPoint | null; pointer: CanvasPoint; -}) { +}): CanvasGenerationFrameMoveResult { const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const targetDialogIds = @@ -405,13 +469,64 @@ export function moveGenerationFramesFromDrag({ ? dragState.dialogIds : (dragState.generationDialogIds ?? []); if (!targetDialogIds.length) { - return generationDialogs; + return { + generationDialogs, + snapGuide: null, + snapOffset: { x: 0, y: 0 }, + }; } const startFrames = dragState.kind === 'generation-frame' ? dragState.startFrames : (dragState.startGenerationFrames ?? []); - return generationDialogs.map((dialog) => { + const movingDialogId = + dragState.kind === 'generation-frame' + ? dragState.dialogId + : targetDialogIds[0]; + const movingDialog = generationDialogs.find( + (dialog) => dialog.id === movingDialogId, + ); + const movingPlaceholder = movingDialog?.placeholder; + const movingStartFrame = startFrames.find( + (frame) => frame.id === movingDialogId, + ); + const proposedX = (movingStartFrame?.x ?? 0) + deltaX; + const proposedY = (movingStartFrame?.y ?? 0) + deltaY; + const snapped = + snapOffset || dragState.kind === 'layer' + ? { + x: proposedX + (snapOffset?.x ?? 0), + y: proposedY + (snapOffset?.y ?? 0), + guide: null, + } + : movingDialog && movingPlaceholder && movingStartFrame + ? resolveSnappedItemPosition( + { + id: getCanvasGenerationSelectionId(movingDialog.id), + x: movingPlaceholder.x, + y: movingPlaceholder.y, + width: movingPlaceholder.width, + height: movingPlaceholder.height, + }, + proposedX, + proposedY, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: targetDialogIds, + }), + dragState.startScale, + ) + : { x: proposedX, y: proposedY, guide: null }; + const appliedSnapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; + return { + snapGuide: snapped.guide, + snapOffset: appliedSnapOffset, + generationDialogs: generationDialogs.map((dialog) => { if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) { return dialog; } @@ -423,20 +538,23 @@ export function moveGenerationFramesFromDrag({ ...dialog, placeholder: { ...dialog.placeholder, - x: startFrame.x + deltaX, - y: startFrame.y + deltaY, + x: startFrame.x + deltaX + appliedSnapOffset.x, + y: startFrame.y + deltaY + appliedSnapOffset.y, }, }; - }); + }), + }; } export function moveDragSelectedLayers({ dragState, layers, + snapOffset = { x: 0, y: 0 }, pointer, }: { dragState: Extract; layers: CanvasLayer[]; + snapOffset?: CanvasPoint; pointer: CanvasPoint; }) { if (!dragState.layerIds.length) { @@ -454,8 +572,8 @@ export function moveDragSelectedLayers({ } return { ...layer, - x: startLayer.x + deltaX, - y: startLayer.y + deltaY, + x: startLayer.x + deltaX + snapOffset.x, + y: startLayer.y + deltaY + snapOffset.y, }; }); } diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index edafe3af5..8fe34a582 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -524,6 +524,33 @@ function StageInteractionsHarness({ > 直接拖生成占位 +