- {isAuthenticated && (activeTab === 'create' || activeTab === 'saves') ? (
+ {isAuthenticated ? (
-
- {allowHiddenActiveTab ? (
-
-
+
+
+
-
-
-
- {desktopSearchBar}
-
-
- {desktopTopbarActions}
-
-
-
- {tabPanels}
-
-
-
- ) : (
- <>
+
- {desktopBrandLockup}
{desktopSearchBar}
{desktopTopbarActions}
-
- {desktopRailNav}
-
-
- {tabPanels}
-
+
+ {tabPanels}
- >
- )}
+
+
diff --git a/src/index.css b/src/index.css
index d8b891884..995366226 100644
--- a/src/index.css
+++ b/src/index.css
@@ -11086,7 +11086,7 @@ button.image-canvas-editor__reference-chip:disabled {
display: none;
}
-.creation-home-stage .platform-desktop-shell {
+.platform-desktop-shell--workbench {
--creation-home-sidebar-width: 10rem;
border-color: rgba(221, 191, 166, 0.9);
border-radius: 1.85rem;
@@ -11098,22 +11098,22 @@ button.image-canvas-editor__reference-chip:disabled {
box-shadow: 0 24px 58px rgba(112, 57, 30, 0.08);
}
-.creation-home-stage .platform-desktop-shell::before {
+.platform-desktop-shell--workbench::before {
display: none;
}
-.creation-home-stage .platform-desktop-shell::after {
+.platform-desktop-shell--workbench::after {
display: none;
}
-.creation-home-stage .platform-desktop-layout {
+.platform-desktop-shell--workbench .platform-desktop-layout {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
}
-.creation-home-stage .platform-desktop-leftbar {
+.platform-desktop-shell--workbench .platform-desktop-leftbar {
width: var(--creation-home-sidebar-width);
min-height: 0;
border-right: 1px solid rgba(219, 190, 166, 0.72);
@@ -11121,11 +11121,11 @@ button.image-canvas-editor__reference-chip:disabled {
padding: 0.86rem 0.5rem 0.96rem;
}
-.creation-home-stage .platform-desktop-main {
+.platform-desktop-shell--workbench .platform-desktop-main {
min-height: 0;
}
-.creation-home-stage .platform-desktop-topbar {
+.platform-desktop-shell--workbench .platform-desktop-topbar {
display: flex;
gap: 0.58rem;
min-height: 3.62rem;
@@ -11138,28 +11138,28 @@ button.image-canvas-editor__reference-chip:disabled {
backdrop-filter: none;
}
-.creation-home-stage .platform-desktop-topbar::before {
+.platform-desktop-shell--workbench .platform-desktop-topbar::before {
display: none;
}
-.creation-home-stage .platform-desktop-topbar__brand-search {
+.platform-desktop-shell--workbench .platform-desktop-topbar__brand-search {
gap: 0.58rem !important;
justify-content: flex-start;
padding-left: clamp(3.1rem, 5.8vw, 8.2rem);
}
-.creation-home-stage .platform-brand-logo {
+.platform-desktop-shell--workbench .platform-brand-logo {
align-self: center;
}
-.creation-home-stage .creation-home-brand-lockup {
+.platform-desktop-shell--workbench .creation-home-brand-lockup {
align-self: flex-start;
gap: 0.42rem;
min-height: 3.28rem;
padding-left: 0.28rem;
}
-.creation-home-stage .creation-home-brand-lockup__art {
+.platform-desktop-shell--workbench .creation-home-brand-lockup__art {
display: block;
width: 2.58rem;
height: 2.58rem;
@@ -11171,30 +11171,30 @@ button.image-canvas-editor__reference-chip:disabled {
mix-blend-mode: multiply;
}
-.creation-home-stage .platform-brand-logo__title {
+.platform-desktop-shell--workbench .platform-brand-logo__title {
font-size: 1.38rem;
color: #4a220f;
}
-.creation-home-stage .platform-brand-logo__subtitle {
+.platform-desktop-shell--workbench .platform-brand-logo__subtitle {
color: #9b5a35;
font-size: 0.46rem;
}
-.creation-home-stage
+.platform-desktop-shell--workbench
.platform-desktop-topbar__brand-search
> .platform-desktop-search {
width: 100%;
max-width: 20rem !important;
}
-.creation-home-stage .platform-desktop-topbar__actions {
+.platform-desktop-shell--workbench .platform-desktop-topbar__actions {
align-self: center;
gap: 0.5rem !important;
margin-left: auto;
}
-.creation-home-stage .platform-desktop-search {
+.platform-desktop-shell--workbench .platform-desktop-search {
position: relative;
box-sizing: border-box;
min-height: 0;
@@ -11204,7 +11204,7 @@ button.image-canvas-editor__reference-chip:disabled {
box-shadow: none;
}
-.creation-home-stage
+.platform-desktop-shell--workbench
.platform-desktop-topbar__brand-search
> .platform-desktop-search {
padding-block: 0 !important;
@@ -11212,14 +11212,14 @@ button.image-canvas-editor__reference-chip:disabled {
gap: 0.48rem !important;
}
-.creation-home-stage .platform-desktop-create-wallet-chip {
+.platform-desktop-shell--workbench .platform-desktop-create-wallet-chip {
position: relative;
min-height: 2.28rem;
border-radius: 999px !important;
padding-inline: 0.42rem 0.58rem !important;
}
-.creation-home-stage .platform-desktop-create-wallet-chip::after {
+.platform-desktop-shell--workbench .platform-desktop-create-wallet-chip::after {
position: absolute;
top: 50%;
right: -0.52rem;
@@ -11230,7 +11230,7 @@ button.image-canvas-editor__reference-chip:disabled {
transform: translateY(-50%);
}
-.creation-home-stage .creation-home-topbar-art {
+.platform-desktop-shell--workbench .creation-home-topbar-art {
display: block;
flex: 0 0 auto;
object-fit: cover;
@@ -11239,29 +11239,29 @@ button.image-canvas-editor__reference-chip:disabled {
mix-blend-mode: multiply;
}
-.creation-home-stage .creation-home-topbar-art--search {
+.platform-desktop-shell--workbench .creation-home-topbar-art--search {
width: 1.34rem;
height: 1.34rem;
margin: -0.16rem -0.26rem -0.16rem -0.34rem;
}
-.creation-home-stage .platform-desktop-search > svg {
+.platform-desktop-shell--workbench .platform-desktop-search > svg {
display: none;
}
-.creation-home-stage .creation-home-topbar-art--wallet {
+.platform-desktop-shell--workbench .creation-home-topbar-art--wallet {
width: 1.32rem;
height: 1.32rem;
margin: -0.14rem -0.18rem -0.14rem -0.3rem;
}
-.creation-home-stage
+.platform-desktop-shell--workbench
.platform-desktop-create-wallet-chip
.platform-icon-badge {
display: none;
}
-.creation-home-stage .platform-desktop-topbar button.platform-desktop-search {
+.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search {
width: auto;
max-width: none !important;
min-width: 7.35rem;
@@ -11272,17 +11272,17 @@ button.image-canvas-editor__reference-chip:disabled {
gap: 0.38rem !important;
}
-.creation-home-stage .platform-desktop-topbar button.platform-desktop-search:hover {
+.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search:hover {
border-color: rgba(214, 184, 159, 0.52);
background: rgba(255, 250, 244, 0.56);
}
-.creation-home-stage .platform-desktop-topbar button.platform-desktop-search > span:first-child {
+.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search > span:first-child {
width: 2.06rem;
height: 2.06rem;
}
-.creation-home-stage .platform-desktop-rail {
+.platform-desktop-shell--workbench .platform-desktop-rail {
align-self: flex-start;
width: 7.05rem;
margin: 1.12rem auto 0;
@@ -11295,7 +11295,7 @@ button.image-canvas-editor__reference-chip:disabled {
0 18px 34px rgba(112, 57, 30, 0.08);
}
-.creation-home-stage .platform-desktop-rail__button {
+.platform-desktop-shell--workbench .platform-desktop-rail__button {
min-height: 2.88rem;
display: flex;
flex-direction: row;
@@ -11306,11 +11306,11 @@ button.image-canvas-editor__reference-chip:disabled {
text-align: left;
}
-.creation-home-stage .platform-desktop-rail__button--primary {
+.platform-desktop-shell--workbench .platform-desktop-rail__button--primary {
min-height: 2.88rem;
}
-.creation-home-stage .platform-desktop-rail__icon-shell {
+.platform-desktop-shell--workbench .platform-desktop-rail__icon-shell {
width: 2.08rem;
height: 2.08rem;
flex: 0 0 auto;
@@ -11319,7 +11319,7 @@ button.image-canvas-editor__reference-chip:disabled {
box-shadow: none;
}
-.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell {
+.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell {
background:
radial-gradient(circle at 35% 22%, rgba(255, 196, 133, 0.72), transparent 42%),
linear-gradient(180deg, #df884b 0%, #c6652a 100%);
@@ -11328,11 +11328,11 @@ button.image-canvas-editor__reference-chip:disabled {
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
-.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon {
+.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon {
color: #fffaf3;
}
-.creation-home-stage .creation-home-nav-art {
+.platform-desktop-shell--workbench .creation-home-nav-art {
display: block;
width: 1.62rem;
height: 1.62rem;
@@ -11342,7 +11342,7 @@ button.image-canvas-editor__reference-chip:disabled {
mix-blend-mode: multiply;
}
-.creation-home-stage
+.platform-desktop-shell--workbench
.platform-desktop-rail__button--active
.creation-home-nav-art {
filter:
@@ -11353,38 +11353,38 @@ button.image-canvas-editor__reference-chip:disabled {
mix-blend-mode: screen;
}
-.creation-home-stage .platform-desktop-rail__icon {
+.platform-desktop-shell--workbench .platform-desktop-rail__icon {
display: none;
}
-.creation-home-stage .platform-desktop-rail__button--active {
+.platform-desktop-shell--workbench .platform-desktop-rail__button--active {
border-color: transparent;
background: transparent;
box-shadow: none;
}
-.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label {
+.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label {
color: #c15f28;
}
-.creation-home-stage .platform-desktop-rail__label {
+.platform-desktop-shell--workbench .platform-desktop-rail__label {
font-size: 0.78rem;
font-weight: 820;
letter-spacing: 0;
}
-.creation-home-stage .platform-tab-panel-stack {
+.platform-desktop-shell--workbench .platform-tab-panel-stack {
min-height: 0;
background: transparent;
}
-.creation-home-stage .platform-tab-panel {
+.platform-desktop-shell--workbench .platform-tab-panel {
height: 100%;
overflow: auto;
padding-right: 0;
}
-.creation-home-stage .creation-landing {
+.platform-desktop-shell--workbench .creation-landing {
background: transparent;
}
diff --git a/src/index.test.ts b/src/index.test.ts
index ac3c42f19..5e3ff9310 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -35,16 +35,16 @@ describe('index stylesheet unread dots', () => {
const css = readIndexCss();
expect(css).not.toContain(
- '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
+ '.platform-desktop-shell--workbench .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell,\n.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
);
expect(css).not.toContain(
- '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label',
+ '.platform-desktop-shell--workbench .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label',
);
expect(css).toContain(
- '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
+ '.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell',
);
expect(css).toContain(
- '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label',
+ '.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label',
);
});
From 8b18a654ebe35460bf8ca61a75457ca774eab461 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com>
Date: Mon, 22 Jun 2026 17:22:01 +0800
Subject: [PATCH 5/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=BB=E5=B8=83?=
=?UTF-8?q?=E5=85=83=E7=B4=A0=E5=90=B8=E9=99=84=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增画布元素边缘、中心线与等距吸附计算。
接入图层与生成占位拖拽吸附,并保持多选队形同步偏移。
补充画布吸附模型和交互测试。
更新图片画布编辑器方案文档中的吸附验收口径。
---
...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +-
.../ImageCanvasEditorModel.test.ts | 75 ++++++
.../image-editor/ImageCanvasEditorModel.ts | 244 ++++++++++++++++--
.../image-editor/ImageCanvasEditorTypes.ts | 9 +
.../ImageCanvasInteractionModel.test.ts | 71 ++++-
.../ImageCanvasInteractionModel.ts | 142 +++++++++-
.../useImageCanvasStageInteractions.test.tsx | 46 +++-
.../useImageCanvasStageInteractions.ts | 20 +-
8 files changed, 566 insertions(+), 47 deletions(-)
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({
>
直接拖生成占位
+
+
void;
deleteLayerById?: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
+ selectAllCanvasObjects?: () => void;
+ clearCanvasSelection?: () => void;
+ copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
+ pasteCanvasClipboard?: () => void;
+ duplicateSelectedCanvasLayers?: () => void;
+ moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
+ nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
+ switchTool?: (tool: CanvasTool) => void;
+ updateScaleFromCenter?: (nextScale: number) => void;
+ zoomViewportBy?: (scaleFactor: number) => void;
+ fitLayers?: () => void;
+ toggleSidebarPanel?: (panel: 'assets' | 'layers') => void;
+ toggleMinimap?: () => void;
+ startProjectRename?: () => void;
+ exportCanvasAssets?: () => void;
closeEditorChromePanels?: () => void;
}) {
const [activeTool, setActiveTool] = useState(initialTool);
@@ -128,8 +158,23 @@ function KeyboardShortcutsHarness({
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
+ selectAllCanvasObjects,
+ clearCanvasSelection,
+ copySelectedCanvasLayers,
+ pasteCanvasClipboard,
+ duplicateSelectedCanvasLayers,
+ moveSelectedCanvasLayers,
+ nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool: (tool) => setActiveTool(tool),
+ switchTool: switchTool ?? ((tool) => setActiveTool(tool)),
+ updateScaleFromCenter,
+ zoomViewportBy,
+ fitLayers,
+ toggleSidebarPanel,
+ toggleMinimap,
+ startProjectRename,
+ exportCanvasAssets,
setGenerateDialog: (updater) =>
setGenerateDialogState((currentDialog) =>
applyGenerateDialogUpdater(updater, currentDialog),
@@ -238,6 +283,168 @@ describe('useImageCanvasKeyboardShortcuts', () => {
});
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', metaKey: true });
+ });
+ expect(undoCanvasChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('routes Windows edit shortcuts to selected layer commands', () => {
+ const selectAllCanvasObjects = vi.fn();
+ const copySelectedCanvasLayers = vi.fn();
+ const pasteCanvasClipboard = vi.fn();
+ const duplicateSelectedCanvasLayers = vi.fn();
+ const exportCanvasAssets = vi.fn();
+ render(
+ ,
+ );
+
+ act(() => {
+ fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true });
+ fireEvent.keyDown(window, { key: 'c', code: 'KeyC', ctrlKey: true });
+ fireEvent.keyDown(window, { key: 'v', code: 'KeyV', ctrlKey: true });
+ fireEvent.keyDown(window, { key: 'x', code: 'KeyX', ctrlKey: true });
+ fireEvent.keyDown(window, { key: 'd', code: 'KeyD', ctrlKey: true });
+ fireEvent.keyDown(window, {
+ key: 'S',
+ code: 'KeyS',
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ });
+
+ expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1);
+ expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1);
+ expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true });
+ expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
+ expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1);
+ expect(exportCanvasAssets).toHaveBeenCalledTimes(1);
+ });
+
+ it('routes Windows view and layer ordering shortcuts', () => {
+ const fitLayers = vi.fn();
+ const updateScaleFromCenter = vi.fn();
+ const zoomViewportBy = vi.fn();
+ const moveSelectedCanvasLayers = vi.fn();
+ render(
+ ,
+ );
+
+ act(() => {
+ fireEvent.keyDown(window, { key: '0', code: 'Digit0', ctrlKey: true });
+ fireEvent.keyDown(window, { key: '1', code: 'Digit1', ctrlKey: true });
+ fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true });
+ fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true });
+ fireEvent.keyDown(window, {
+ key: ']',
+ code: 'BracketRight',
+ ctrlKey: true,
+ });
+ fireEvent.keyDown(window, {
+ key: '}',
+ code: 'BracketRight',
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ fireEvent.keyDown(window, {
+ key: '[',
+ code: 'BracketLeft',
+ ctrlKey: true,
+ });
+ fireEvent.keyDown(window, {
+ key: '{',
+ code: 'BracketLeft',
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ });
+
+ expect(fitLayers).toHaveBeenCalledTimes(1);
+ expect(updateScaleFromCenter).toHaveBeenCalledWith(1);
+ expect(zoomViewportBy).toHaveBeenCalledWith(1.16);
+ expect(zoomViewportBy).toHaveBeenCalledWith(0.86);
+ expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(1, 'up');
+ expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(2, 'top');
+ expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(3, 'down');
+ expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(4, 'bottom');
+ });
+
+ it('routes tool, panel and project shortcuts', () => {
+ const switchTool = vi.fn();
+ const toggleSidebarPanel = vi.fn();
+ const toggleMinimap = vi.fn();
+ const startProjectRename = vi.fn();
+ render(
+ ,
+ );
+
+ act(() => {
+ fireEvent.keyDown(window, { key: 'v', code: 'KeyV' });
+ fireEvent.keyDown(window, { key: 'h', code: 'KeyH' });
+ fireEvent.keyDown(window, { key: 'u', code: 'KeyU' });
+ fireEvent.keyDown(window, { key: 'g', code: 'KeyG' });
+ fireEvent.keyDown(window, { key: 'V', code: 'KeyV', shiftKey: true });
+ fireEvent.keyDown(window, { key: 'm', code: 'KeyM' });
+ fireEvent.keyDown(window, { key: '1', code: 'Digit1', altKey: true });
+ fireEvent.keyDown(window, { key: '2', code: 'Digit2', altKey: true });
+ fireEvent.keyDown(window, { key: 'm', code: 'KeyM', altKey: true });
+ fireEvent.keyDown(window, { key: 'F2', code: 'F2' });
+ });
+
+ expect(switchTool).toHaveBeenNthCalledWith(1, 'select');
+ expect(switchTool).toHaveBeenNthCalledWith(2, 'hand');
+ expect(switchTool).toHaveBeenNthCalledWith(3, 'upload');
+ expect(switchTool).toHaveBeenNthCalledWith(4, 'generate');
+ expect(switchTool).toHaveBeenNthCalledWith(5, 'video');
+ expect(switchTool).toHaveBeenNthCalledWith(6, 'music');
+ expect(toggleSidebarPanel).toHaveBeenNthCalledWith(1, 'assets');
+ expect(toggleSidebarPanel).toHaveBeenNthCalledWith(2, 'layers');
+ expect(toggleMinimap).toHaveBeenCalledTimes(1);
+ expect(startProjectRename).toHaveBeenCalledTimes(1);
+ });
+
+ it('nudges selected canvas objects with arrow keys', () => {
+ const nudgeSelectedCanvasObjects = vi.fn();
+ render(
+ ,
+ );
+
+ act(() => {
+ fireEvent.keyDown(window, { key: 'ArrowUp', code: 'ArrowUp' });
+ fireEvent.keyDown(window, {
+ key: 'ArrowRight',
+ code: 'ArrowRight',
+ shiftKey: true,
+ });
+ });
+
+ expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(1, {
+ x: 0,
+ y: -1,
+ });
+ expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(2, {
+ x: 10,
+ y: 0,
+ });
});
it('deletes the selected layer with Backspace outside editable inputs', () => {
diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
index a0aec9310..6d5abf70f 100644
--- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
+++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
@@ -2,8 +2,10 @@ import { type RefObject, useEffect } from 'react';
import type {
CanvasGenerationDialogState,
+ CanvasTool,
GenerateDialogState,
QuickEditPanelState,
+ SidebarPanel,
} from './ImageCanvasEditorTypes';
type UseImageCanvasKeyboardShortcutsOptions = {
@@ -14,10 +16,25 @@ type UseImageCanvasKeyboardShortcutsOptions = {
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
+ selectAllCanvasObjects?: () => void;
+ clearCanvasSelection?: () => void;
+ copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
+ pasteCanvasClipboard?: () => void;
+ duplicateSelectedCanvasLayers?: () => void;
+ moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
+ nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
setActiveTool: (tool: 'select') => void;
+ switchTool?: (tool: CanvasTool) => void;
+ updateScaleFromCenter?: (nextScale: number) => void;
+ zoomViewportBy?: (scaleFactor: number) => void;
+ fitLayers?: () => void;
+ toggleSidebarPanel?: (panel: SidebarPanel) => void;
+ toggleMinimap?: () => void;
+ startProjectRename?: () => void;
+ exportCanvasAssets?: () => void;
setGenerateDialog: (
updater:
| GenerateDialogState
@@ -30,9 +47,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
updater:
| QuickEditPanelState
| null
- | ((
- panel: QuickEditPanelState | null,
- ) => QuickEditPanelState | null),
+ | ((panel: QuickEditPanelState | null) => QuickEditPanelState | null),
) => void;
closeEditorChromePanels: () => void;
setIsSpecMenuOpen: (open: boolean) => void;
@@ -93,6 +108,48 @@ function isCanvasGenerationDialogState(
return Boolean(dialog?.id && isCanvasGenerationPlaceholderDialog(dialog));
}
+function isCtrlShortcut(event: KeyboardEvent) {
+ return event.ctrlKey && !event.altKey && !event.metaKey;
+}
+
+function isKey(event: KeyboardEvent, key: string, code?: string) {
+ return event.key.toLowerCase() === key || event.code === code;
+}
+
+function isPlusKey(event: KeyboardEvent) {
+ return (
+ event.key === '+' ||
+ event.key === '=' ||
+ event.code === 'Equal' ||
+ event.code === 'NumpadAdd'
+ );
+}
+
+function isMinusKey(event: KeyboardEvent) {
+ return (
+ event.key === '-' ||
+ event.code === 'Minus' ||
+ event.code === 'NumpadSubtract'
+ );
+}
+
+function getArrowDelta(event: KeyboardEvent) {
+ const step = event.shiftKey ? 10 : 1;
+ if (event.key === 'ArrowUp') {
+ return { x: 0, y: -step };
+ }
+ if (event.key === 'ArrowDown') {
+ return { x: 0, y: step };
+ }
+ if (event.key === 'ArrowLeft') {
+ return { x: -step, y: 0 };
+ }
+ if (event.key === 'ArrowRight') {
+ return { x: step, y: 0 };
+ }
+ return null;
+}
+
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
@@ -101,8 +158,23 @@ export function useImageCanvasKeyboardShortcuts({
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
+ selectAllCanvasObjects,
+ clearCanvasSelection,
+ copySelectedCanvasLayers,
+ pasteCanvasClipboard,
+ duplicateSelectedCanvasLayers,
+ moveSelectedCanvasLayers,
+ nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool,
+ switchTool,
+ updateScaleFromCenter,
+ zoomViewportBy,
+ fitLayers,
+ toggleSidebarPanel,
+ toggleMinimap,
+ startProjectRename,
+ exportCanvasAssets,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
@@ -177,7 +249,7 @@ export function useImageCanvasKeyboardShortcuts({
const handleKeyDown = (event: KeyboardEvent) => {
if (
- (event.ctrlKey || event.metaKey) &&
+ isCtrlShortcut(event) &&
event.code === 'KeyZ' &&
!isEditableTarget(event)
) {
@@ -189,6 +261,138 @@ export function useImageCanvasKeyboardShortcuts({
}
return;
}
+ if (isCtrlShortcut(event) && !isEditableTarget(event)) {
+ if (event.code === 'KeyA') {
+ event.preventDefault();
+ selectAllCanvasObjects?.();
+ return;
+ }
+ if (event.code === 'KeyC') {
+ event.preventDefault();
+ copySelectedCanvasLayers?.();
+ return;
+ }
+ if (event.code === 'KeyV') {
+ event.preventDefault();
+ pasteCanvasClipboard?.();
+ return;
+ }
+ if (event.code === 'KeyX') {
+ event.preventDefault();
+ copySelectedCanvasLayers?.({ cut: true });
+ return;
+ }
+ if (event.code === 'KeyD') {
+ event.preventDefault();
+ duplicateSelectedCanvasLayers?.();
+ return;
+ }
+ if (event.code === 'KeyS' && event.shiftKey) {
+ event.preventDefault();
+ exportCanvasAssets?.();
+ return;
+ }
+ if (event.key === '0' || event.code === 'Digit0') {
+ event.preventDefault();
+ fitLayers?.();
+ return;
+ }
+ if (event.key === '1' || event.code === 'Digit1') {
+ event.preventDefault();
+ updateScaleFromCenter?.(1);
+ return;
+ }
+ if (isPlusKey(event)) {
+ event.preventDefault();
+ zoomViewportBy?.(1.16);
+ return;
+ }
+ if (isMinusKey(event)) {
+ event.preventDefault();
+ zoomViewportBy?.(0.86);
+ return;
+ }
+ if (event.code === 'BracketRight') {
+ event.preventDefault();
+ moveSelectedCanvasLayers?.(event.shiftKey ? 'top' : 'up');
+ return;
+ }
+ if (event.code === 'BracketLeft') {
+ event.preventDefault();
+ moveSelectedCanvasLayers?.(event.shiftKey ? 'bottom' : 'down');
+ return;
+ }
+ }
+ if (
+ event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !isEditableTarget(event)
+ ) {
+ if (event.code === 'Digit1') {
+ event.preventDefault();
+ toggleSidebarPanel?.('assets');
+ return;
+ }
+ if (event.code === 'Digit2') {
+ event.preventDefault();
+ toggleSidebarPanel?.('layers');
+ return;
+ }
+ if (event.code === 'KeyM') {
+ event.preventDefault();
+ toggleMinimap?.();
+ return;
+ }
+ }
+ if (
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ !isEditableTarget(event)
+ ) {
+ const arrowDelta = getArrowDelta(event);
+ if (arrowDelta) {
+ event.preventDefault();
+ nudgeSelectedCanvasObjects?.(arrowDelta);
+ return;
+ }
+ if (event.key === 'F2') {
+ event.preventDefault();
+ startProjectRename?.();
+ return;
+ }
+ if (!event.repeat && event.code === 'KeyV' && event.shiftKey) {
+ event.preventDefault();
+ switchTool?.('video');
+ return;
+ }
+ if (!event.repeat && !event.shiftKey && isKey(event, 'v', 'KeyV')) {
+ event.preventDefault();
+ switchTool?.('select');
+ return;
+ }
+ if (!event.repeat && !event.shiftKey && isKey(event, 'h', 'KeyH')) {
+ event.preventDefault();
+ switchTool?.('hand');
+ return;
+ }
+ if (!event.repeat && !event.shiftKey && isKey(event, 'u', 'KeyU')) {
+ event.preventDefault();
+ switchTool?.('upload');
+ return;
+ }
+ if (!event.repeat && !event.shiftKey && isKey(event, 'g', 'KeyG')) {
+ event.preventDefault();
+ switchTool?.('generate');
+ return;
+ }
+ if (!event.repeat && !event.shiftKey && isKey(event, 'm', 'KeyM')) {
+ event.preventDefault();
+ switchTool?.('music');
+ return;
+ }
+ }
if (event.key === 'Shift') {
setShiftPressed(true);
}
@@ -238,6 +442,7 @@ export function useImageCanvasKeyboardShortcuts({
}
if (event.key === 'Escape') {
closeTransientEditorPanels();
+ clearCanvasSelection?.();
return;
}
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
@@ -265,13 +470,23 @@ export function useImageCanvasKeyboardShortcuts({
};
}, [
closeEditorChromePanels,
+ clearCanvasSelection,
+ copySelectedCanvasLayers,
+ duplicateSelectedCanvasLayers,
deleteSelectedCanvasObjects,
deleteLayerById,
+ exportCanvasAssets,
+ fitLayers,
generateDialogRef,
+ moveSelectedCanvasLayers,
+ nudgeSelectedCanvasObjects,
+ pasteCanvasClipboard,
requestRemoveCanvasGenerationDialog,
redoCanvasChange,
+ selectAllCanvasObjects,
selectedLayerIdRef,
selectedLayerIdsRef,
+ startProjectRename,
setActiveTool,
setContextMenu,
setGenerateDialog,
@@ -293,6 +508,11 @@ export function useImageCanvasKeyboardShortcuts({
setIsUiDesignSpecMenuOpen,
setQuickEditPanel,
setShiftPressed,
+ switchTool,
+ toggleMinimap,
+ toggleSidebarPanel,
undoCanvasChange,
+ updateScaleFromCenter,
+ zoomViewportBy,
]);
}
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
index 1af39cbe0..99ee9ebd9 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
+++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
@@ -139,12 +139,21 @@ function LayerCommandsHarness({
+
+