From 820e179be59288d285dd70bdbe4a5e0a86d1aa32 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 17:58:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=BB=E5=B8=83=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加画布右上角快捷键入口和独立弹窗 补齐 Windows 画布快捷键与选中图层命令 更新快捷键单测和画布文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 6 +- .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorView.tsx | 152 +++++++++++- .../ImageCanvasShortcutDialogView.test.tsx | 21 ++ .../ImageCanvasShortcutDialogView.tsx | 65 +++++ .../image-editor/ImageCanvasShortcutModel.ts | 80 ++++++ .../ImageCanvasTopbarView.test.tsx | 7 + .../image-editor/ImageCanvasTopbarView.tsx | 17 +- .../useImageCanvasKeyboardShortcuts.test.tsx | 207 ++++++++++++++++ .../useImageCanvasKeyboardShortcuts.ts | 228 +++++++++++++++++- .../useImageCanvasLayerCommands.test.tsx | 49 ++++ .../useImageCanvasLayerCommands.ts | 99 ++++++++ src/index.css | 112 +++++++++ 14 files changed, 1028 insertions(+), 19 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx create mode 100644 src/components/image-editor/ImageCanvasShortcutDialogView.tsx create mode 100644 src/components/image-editor/ImageCanvasShortcutModel.ts diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index b27a1be23..1731cea52 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -27,7 +27,7 @@ - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。 -- 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 +- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销重做、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 @@ -90,6 +90,7 @@ - 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 +- 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 16e136c58..fd05d28d6 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -181,9 +181,11 @@ ## 第二十一阶段模块 - `useImageCanvasKeyboardShortcuts.ts` - - 承载图片画布全局键盘快捷键:Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 按下态、Backspace / Delete 删除选中图层、删除可编辑生成占位、Escape 关闭临时面板,以及 Space 临时抓手。 + - 承载图片画布全局键盘快捷键:Windows 下支持 Ctrl+Z / Ctrl+Shift+Z 撤销重做、Ctrl+A/C/V/X/D 全选 / 复制 / 粘贴 / 剪切 / 复制一份、Ctrl+0/1/+/- 视图控制、V/H/U/G/Shift+V/M 工具切换、Alt+1/Alt+2/Alt+M 面板切换、Ctrl+] / Ctrl+[ / Ctrl+Shift+] / Ctrl+Shift+[ 层级调整、方向键微移、Backspace / Delete 删除选中元素、Escape 关闭临时面板并取消选择,以及 Space 临时抓手。 - 主视图继续保留各工作流状态和具体副作用,例如图层删除、生成对话框、规格菜单、快速编辑面板和 chrome 面板状态;快捷键 hook 只接收 ref、setter 与回调,不直接读写素材库、路由或 API。 - - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、选中图层删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 + - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、编辑命令、视图 / 层级命令、工具 / 面板命令、方向键移动、选中元素删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 +- `ImageCanvasShortcutDialogView.tsx` / `ImageCanvasShortcutModel.ts` + - 承载右上角快捷键入口打开后的独立弹窗和 Windows 快捷键表;快捷键文案、键帽和“新增”状态集中在模型中维护,避免 UI 表格和 hook 行为漂移。 ## 第二十二阶段模块 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 18ad713ed..b2d79c05d 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -106,6 +106,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { submitProjectRename: vi.fn(), resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), + onOpenShortcuts: vi.fn(), }; } diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 48b2d330a..7353db051 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -21,6 +21,7 @@ import { resolveContextMenuPosition, } from './ImageCanvasEditorModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; +import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; import type { AssetPointerDragState, CanvasAssetKind, @@ -34,6 +35,11 @@ import type { } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept } from './ImageCanvasFileModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; +import { + getCanvasGenerationSelectionId, + getSelectedGenerationDialogIds, + getSelectedLayerIds, +} from './ImageCanvasSelectionModel'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; import { @@ -101,6 +107,7 @@ export function ImageCanvasEditorView() { const [uploadDropTarget, setUploadDropTarget] = useState< 'canvas' | 'assets' | null >(null); + const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false); const captureViewportHistory = useCallback(() => { captureCanvasHistoryRef.current(); }, []); @@ -695,8 +702,11 @@ export function ImageCanvasEditorView() { canvasClipboard, pasteCanvasClipboard, copyContextLayers, + copySelectedLayers, duplicateContextLayers, + duplicateSelectedLayers, moveContextLayers, + moveSelectedLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, @@ -848,6 +858,117 @@ export function ImageCanvasEditorView() { }, [captureCanvasHistory, persistUpdatedLayerResource], ); + const selectAllCanvasObjects = useCallback(() => { + const layerSelectionIds = layersRef.current + .filter((layer) => !layer.hidden) + .map((layer) => layer.id); + const layerIds = new Set(layersRef.current.map((layer) => layer.id)); + const generationSelectionIds = canvasGenerationDialogsRef.current + .filter((dialog) => { + if (!dialog.placeholder) { + return false; + } + return !( + dialog.generatedLayerId && layerIds.has(dialog.generatedLayerId) + ); + }) + .map((dialog) => getCanvasGenerationSelectionId(dialog.id)); + const nextSelectionIds = [...layerSelectionIds, ...generationSelectionIds]; + setSelectedLayerIds(nextSelectionIds); + setSelectedLayerId(layerSelectionIds[0] ?? null); + setImageContextMenu(null); + setContextMenu(null); + setGenerateDialog((currentDialog) => + isCanvasGenerationComposerVisible(currentDialog) + ? { + ...currentDialog, + composerOpen: false, + } + : currentDialog, + ); + }, [setGenerateDialog]); + const clearCanvasSelection = useCallback(() => { + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + }, []); + const nudgeSelectedCanvasObjects = useCallback( + (delta: { x: number; y: number }) => { + const selectionIds = selectedLayerIdsRef.current.length + ? selectedLayerIdsRef.current + : selectedLayerIdRef.current + ? [selectedLayerIdRef.current] + : []; + const targetLayerIds = getSelectedLayerIds(selectionIds); + const targetDialogIds = getSelectedGenerationDialogIds(selectionIds); + if (!targetLayerIds.length && !targetDialogIds.length) { + return; + } + captureCanvasHistory(); + if (targetLayerIds.length) { + setLayers((currentLayers) => + currentLayers.map((layer) => + targetLayerIds.includes(layer.id) + ? { + ...layer, + x: layer.x + delta.x, + y: layer.y + delta.y, + } + : layer, + ), + ); + } + targetDialogIds.forEach((dialogId) => { + updateCanvasGenerationDialogById(dialogId, (dialog) => + dialog.placeholder + ? { + ...dialog, + placeholder: { + ...dialog.placeholder, + x: dialog.placeholder.x + delta.x, + y: dialog.placeholder.y + delta.y, + }, + } + : dialog, + ); + }); + setImageContextMenu(null); + setContextMenu(null); + }, + [captureCanvasHistory, updateCanvasGenerationDialogById], + ); + const exportCanvasAssetsFromShortcut = useCallback(() => { + if (!layersRef.current.some((layer) => layer.src.trim().length > 0)) { + return; + } + void exportCanvasAssets(); + }, [exportCanvasAssets]); + const zoomViewportBy = useCallback( + (scaleFactor: number) => { + updateScaleFromCenter(viewportRef.current.scale * scaleFactor); + }, + [updateScaleFromCenter], + ); + const switchTool = useCallback( + (tool: CanvasTool) => { + clearActiveInteraction(); + if (tool === 'upload') { + requestUpload('asset', { addToCanvas: true }); + return; + } + if (switchGenerationTool(tool)) { + return; + } + setActiveTool(tool); + }, + [ + clearActiveInteraction, + requestUpload, + setActiveTool, + switchGenerationTool, + ], + ); useImageCanvasKeyboardShortcuts({ generateDialogRef, @@ -857,8 +978,23 @@ export function ImageCanvasEditorView() { undoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, deleteSelectedCanvasObjects: deleteSelectedLayer, + selectAllCanvasObjects, + clearCanvasSelection, + copySelectedCanvasLayers: copySelectedLayers, + pasteCanvasClipboard, + duplicateSelectedCanvasLayers: duplicateSelectedLayers, + moveSelectedCanvasLayers: moveSelectedLayers, + nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, setActiveTool, + switchTool, + updateScaleFromCenter, + zoomViewportBy, + fitLayers, + toggleSidebarPanel, + toggleMinimap, + startProjectRename, + exportCanvasAssets: exportCanvasAssetsFromShortcut, setGenerateDialog, setImageContextMenu, setContextMenu, @@ -934,17 +1070,6 @@ export function ImageCanvasEditorView() { deleteLayerByIdRef.current = deleteLayerById; - const switchTool = (tool: CanvasTool) => { - clearActiveInteraction(); - if (tool === 'upload') { - requestUpload('asset', { addToCanvas: true }); - return; - } - if (switchGenerationTool(tool)) { - return; - } - setActiveTool(tool); - }; const assetDragPreview = assetPointerDrag?.active ? { x: assetPointerDrag.currentClientX, @@ -1027,6 +1152,7 @@ export function ImageCanvasEditorView() { submitProjectRename, resetProjectRenameError, exportCanvasAssets, + onOpenShortcuts: () => setIsShortcutDialogOpen(true), }; const stageProps = { canvasViewportRef, @@ -1153,6 +1279,10 @@ export function ImageCanvasEditorView() { > 删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。 + setIsShortcutDialogOpen(false)} + /> ); } diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx new file mode 100644 index 000000000..53dd3356e --- /dev/null +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx @@ -0,0 +1,21 @@ +/* @vitest-environment jsdom */ + +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; + +describe('ImageCanvasShortcutDialogView', () => { + it('renders the Windows canvas shortcut table', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: '画布快捷键' }); + + expect(within(dialog).getByRole('heading', { name: '编辑' })).toBeTruthy(); + expect(within(dialog).getByText('全选画布元素')).toBeTruthy(); + expect(within(dialog).getAllByText('Ctrl').length).toBeGreaterThan(0); + expect(within(dialog).getByText('A')).toBeTruthy(); + expect(within(dialog).getByText('生成视频')).toBeTruthy(); + expect(within(dialog).getAllByText('新增').length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx new file mode 100644 index 000000000..265d88def --- /dev/null +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx @@ -0,0 +1,65 @@ +import { UnifiedModal } from '../common/UnifiedModal'; +import { IMAGE_CANVAS_SHORTCUT_SECTIONS } from './ImageCanvasShortcutModel'; + +type ImageCanvasShortcutDialogViewProps = { + open: boolean; + onClose: () => void; +}; + +export function ImageCanvasShortcutDialogView({ + open, + onClose, +}: ImageCanvasShortcutDialogViewProps) { + return ( + +
+ {IMAGE_CANVAS_SHORTCUT_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.items.map((item) => ( +
+
+ {item.action} + {item.status === 'new' ? 新增 : null} +
+
+ {item.keys.map((key, index) => ( + + {index > 0 ? ( + + ) : null} + {key} + + ))} +
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/image-editor/ImageCanvasShortcutModel.ts b/src/components/image-editor/ImageCanvasShortcutModel.ts new file mode 100644 index 000000000..bddf89e5d --- /dev/null +++ b/src/components/image-editor/ImageCanvasShortcutModel.ts @@ -0,0 +1,80 @@ +export type ImageCanvasShortcutStatus = 'ready' | 'new'; + +export type ImageCanvasShortcutItem = { + action: string; + keys: string[]; + status: ImageCanvasShortcutStatus; +}; + +export type ImageCanvasShortcutSection = { + title: string; + items: ImageCanvasShortcutItem[]; +}; + +export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [ + { + title: '编辑', + items: [ + { action: '撤销', keys: ['Ctrl', 'Z'], status: 'ready' }, + { action: '重做', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' }, + { action: '全选画布元素', keys: ['Ctrl', 'A'], status: 'new' }, + { action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' }, + { action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' }, + { action: '剪切选中图层', keys: ['Ctrl', 'X'], status: 'new' }, + { action: '复制一份选中图层', keys: ['Ctrl', 'D'], status: 'new' }, + { action: '删除选中元素', keys: ['Delete / Backspace'], status: 'ready' }, + { action: '取消选择或关闭弹层', keys: ['Esc'], status: 'ready' }, + ], + }, + { + title: '视图', + items: [ + { action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' }, + { action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' }, + { action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' }, + { action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' }, + { action: '放大', keys: ['Ctrl', '+'], status: 'new' }, + { action: '缩小', keys: ['Ctrl', '-'], status: 'new' }, + { action: '临时抓手拖动画布', keys: ['Space', '拖拽'], status: 'ready' }, + ], + }, + { + title: '工具', + items: [ + { action: '选择工具', keys: ['V'], status: 'new' }, + { action: '抓手工具', keys: ['H'], status: 'new' }, + { action: '上传素材', keys: ['U'], status: 'new' }, + { action: '生成图片', keys: ['G'], status: 'new' }, + { action: '生成视频', keys: ['Shift', 'V'], status: 'new' }, + { action: '生成音乐', keys: ['M'], status: 'new' }, + ], + }, + { + title: '图层', + items: [ + { action: '多选元素', keys: ['Shift', '点击 / 拖拽'], status: 'ready' }, + { action: '微移选中图层', keys: ['方向键'], status: 'new' }, + { action: '大步移动选中图层', keys: ['Shift', '方向键'], status: 'new' }, + { action: '上移图层层级', keys: ['Ctrl', ']'], status: 'new' }, + { action: '下移图层层级', keys: ['Ctrl', '['], status: 'new' }, + { action: '置顶图层', keys: ['Ctrl', 'Shift', ']'], status: 'new' }, + { action: '置底图层', keys: ['Ctrl', 'Shift', '['], status: 'new' }, + { action: '激活生成占位', keys: ['双击生成占位'], status: 'ready' }, + { + action: '选择音频 / 视频图层', + keys: ['Enter / Space'], + status: 'ready', + }, + ], + }, + { + title: '面板与项目', + items: [ + { action: '打开素材面板', keys: ['Alt', '1'], status: 'new' }, + { action: '打开图层面板', keys: ['Alt', '2'], status: 'new' }, + { action: '切换小地图', keys: ['Alt', 'M'], status: 'new' }, + { action: '下载画布素材', keys: ['Ctrl', 'Shift', 'S'], status: 'new' }, + { action: '重命名项目', keys: ['F2'], status: 'new' }, + ], + }, +]; diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 75827629b..2648957eb 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -46,6 +46,7 @@ function renderTopbar( submitProjectRename: vi.fn(), resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), + onOpenShortcuts: vi.fn(), ...overrides, }; @@ -66,6 +67,10 @@ describe('ImageCanvasTopbarView', () => { fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); expect(props.startProjectRename).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: '查看画布快捷键' })); + + expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1); }); it('submits, resets, and cancels project rename edits', () => { @@ -108,6 +113,7 @@ describe('ImageCanvasTopbarView', () => { submitProjectRename={vi.fn()} resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} + onOpenShortcuts={vi.fn()} />, ); @@ -136,6 +142,7 @@ describe('ImageCanvasTopbarView', () => { submitProjectRename={vi.fn()} resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} + onOpenShortcuts={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 6fbf47dd4..3e02ab576 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -1,4 +1,11 @@ -import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; +import { + Check, + ChevronLeft, + Download, + Keyboard, + Pencil, + X, +} from 'lucide-react'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -22,6 +29,7 @@ export type ImageCanvasTopbarViewProps = { submitProjectRename: (projectId: string | null) => void; resetProjectRenameError: () => void; exportCanvasAssets: () => void | Promise; + onOpenShortcuts: () => void; }; export function ImageCanvasTopbarView({ @@ -40,6 +48,7 @@ export function ImageCanvasTopbarView({ submitProjectRename, resetProjectRenameError, exportCanvasAssets, + onOpenShortcuts, }: ImageCanvasTopbarViewProps) { const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, @@ -126,6 +135,12 @@ export function ImageCanvasTopbarView({ 画布
+ 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({ + + + + @@ -258,6 +273,40 @@ describe('useImageCanvasLayerCommands', () => { ); }); + it('copies, cuts, duplicates, and moves selected layers for keyboard shortcuts', () => { + const onDeleteLayerSideEffects = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); + + fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); + expect(screen.getByTestId('layers').textContent).toContain( + 'first 副本:42:4', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'second 副本:192:5', + ); + expect(screen.getByTestId('selection').textContent).toContain( + 'layer-copy-', + ); + + fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); + fireEvent.click(screen.getByRole('button', { name: '选中置顶' })); + expect(screen.getByTestId('layers').textContent).toContain('third:third'); + + fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); + expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); + expect(screen.getByTestId('layers').textContent).not.toContain('third'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third'); + }); + it('applies layer commands and clears menus without owning menu positioning', () => { const exportLayerImage = vi.fn(); render(); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 2bc968522..47aac3c18 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -85,6 +85,14 @@ export function useImageCanvasLayerCommands({ resolveContextTargetLayerIds(menu, selectedLayerIds), [contextMenu, selectedLayerIds], ); + const getSelectedTargetLayerIds = useCallback(() => { + const targetIds = selectedLayerIds.length + ? selectedLayerIds + : selectedLayerId + ? [selectedLayerId] + : []; + return getSelectedLayerIds(targetIds); + }, [selectedLayerId, selectedLayerIds]); const duplicateLayersToPoint = useCallback( ( @@ -172,6 +180,47 @@ export function useImageCanvasLayerCommands({ ], ); + const copySelectedLayers = useCallback( + (options: { cut?: boolean } = {}) => { + const targetIds = getSelectedTargetLayerIds(); + const clipboard = createCanvasLayerClipboard( + layers, + targetIds, + options.cut ? 'cut' : 'copy', + ); + if (!clipboard) { + return; + } + setCanvasClipboard(clipboard); + if (options.cut) { + captureCanvasHistory(); + setLayers((currentLayers) => + removeCanvasLayers(currentLayers, targetIds), + ); + selectSingleLayer(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) + ? null + : currentLayer, + ); + targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); + } + setContextMenu(null); + setImageContextMenu(null); + }, + [ + captureCanvasHistory, + getSelectedTargetLayerIds, + layers, + onDeleteLayerSideEffects, + selectSingleLayer, + setContextMenu, + setImageContextMenu, + setLayers, + setMetadataLayer, + ], + ); + const duplicateContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); @@ -195,6 +244,31 @@ export function useImageCanvasLayerCommands({ setSelectedLayerIds, ]); + const duplicateSelectedLayers = useCallback(() => { + const targetIds = getSelectedTargetLayerIds(); + const targetLayers = getCanvasLayersByIds(layers, targetIds); + const nextLayers = duplicateLayersToPoint(targetLayers); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setContextMenu(null); + setImageContextMenu(null); + }, [ + captureCanvasHistory, + duplicateLayersToPoint, + getSelectedTargetLayerIds, + layers, + setContextMenu, + setImageContextMenu, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + ]); + const updateContextLayers = useCallback( (updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => { const targetIds = getContextTargetLayerIds(); @@ -225,6 +299,28 @@ export function useImageCanvasLayerCommands({ [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], ); + const moveSelectedLayers = useCallback( + (mode: CanvasLayerMoveMode) => { + const targetIds = getSelectedTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + moveCanvasLayers(currentLayers, targetIds, mode), + ); + setContextMenu(null); + setImageContextMenu(null); + }, + [ + captureCanvasHistory, + getSelectedTargetLayerIds, + setContextMenu, + setImageContextMenu, + setLayers, + ], + ); + const groupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { @@ -439,9 +535,12 @@ export function useImageCanvasLayerCommands({ getContextTargetLayerIds, pasteCanvasClipboard, copyContextLayers, + copySelectedLayers, duplicateContextLayers, + duplicateSelectedLayers, updateContextLayers, moveContextLayers, + moveSelectedLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, diff --git a/src/index.css b/src/index.css index 995366226..fdae31e92 100644 --- a/src/index.css +++ b/src/index.css @@ -4793,6 +4793,105 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.45rem; } +.image-canvas-editor__shortcut-modal { + border-radius: 1.05rem; +} + +.image-canvas-editor__shortcut-modal-body { + background: #f8fafc; +} + +.image-canvas-editor__shortcut-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.image-canvas-editor__shortcut-section { + display: grid; + min-width: 0; + gap: 0.48rem; + border: 1px solid #dbe4f0; + border-radius: 0.65rem; + background: #ffffff; + padding: 0.72rem; +} + +.image-canvas-editor__shortcut-section h3 { + margin: 0; + color: #0f172a; + font-size: 0.86rem; + font-weight: 900; +} + +.image-canvas-editor__shortcut-list { + display: grid; + gap: 0.36rem; + margin: 0; +} + +.image-canvas-editor__shortcut-row { + display: grid; + grid-template-columns: minmax(7rem, 1fr) minmax(6rem, auto); + align-items: center; + gap: 0.6rem; + min-height: 2rem; +} + +.image-canvas-editor__shortcut-row dt { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.35rem; + color: #334155; + font-size: 0.78rem; + font-weight: 780; +} + +.image-canvas-editor__shortcut-row dt > span { + flex: 0 0 auto; + border-radius: 999px; + background: #e0f2fe; + padding: 0.08rem 0.34rem; + color: #0369a1; + font-size: 0.62rem; + font-weight: 900; +} + +.image-canvas-editor__shortcut-row dd { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 0.2rem; + margin: 0; +} + +.image-canvas-editor__shortcut-row kbd { + display: inline-grid; + min-width: 1.65rem; + min-height: 1.45rem; + place-items: center; + border: 1px solid #cbd5e1; + border-bottom-color: #94a3b8; + border-radius: 0.35rem; + background: linear-gradient(#ffffff, #eef2f7); + padding: 0 0.42rem; + color: #0f172a; + font-family: inherit; + font-size: 0.72rem; + font-weight: 850; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.16); + white-space: nowrap; +} + +.image-canvas-editor__shortcut-plus { + margin-right: 0.2rem; + color: #64748b; + font-size: 0.72rem; + font-weight: 900; +} + .image-canvas-editor__project-back-button { width: 2.25rem; height: 2.25rem; @@ -7966,6 +8065,19 @@ button.image-canvas-editor__reference-chip:disabled { min-height: 3.2rem; } + .image-canvas-editor__shortcut-sections { + grid-template-columns: 1fr; + } + + .image-canvas-editor__shortcut-row { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .image-canvas-editor__shortcut-row dd { + justify-content: flex-start; + } + .image-canvas-editor__title-block h1 { font-size: 0.84rem; }