新增画布快捷键弹窗
添加画布右上角快捷键入口和独立弹窗 补齐 Windows 画布快捷键与选中图层命令 更新快捷键单测和画布文档
This commit is contained in:
@@ -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>` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
|
||||
|
||||
@@ -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 行为漂移。
|
||||
|
||||
## 第二十二阶段模块
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
submitProjectRename: vi.fn(),
|
||||
resetProjectRenameError: vi.fn(),
|
||||
exportCanvasAssets: vi.fn(),
|
||||
onOpenShortcuts: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。
|
||||
</PlatformDangerConfirmDialog>
|
||||
<ImageCanvasShortcutDialogView
|
||||
open={isShortcutDialogOpen}
|
||||
onClose={() => setIsShortcutDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<ImageCanvasShortcutDialogView open onClose={vi.fn()} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="画布快捷键"
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
panelClassName="image-canvas-editor__shortcut-modal"
|
||||
bodyClassName="image-canvas-editor__shortcut-modal-body"
|
||||
>
|
||||
<div className="image-canvas-editor__shortcut-sections">
|
||||
{IMAGE_CANVAS_SHORTCUT_SECTIONS.map((section) => (
|
||||
<section
|
||||
key={section.title}
|
||||
className="image-canvas-editor__shortcut-section"
|
||||
aria-labelledby={`image-canvas-shortcut-${section.title}`}
|
||||
>
|
||||
<h3 id={`image-canvas-shortcut-${section.title}`}>
|
||||
{section.title}
|
||||
</h3>
|
||||
<dl className="image-canvas-editor__shortcut-list">
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={`${section.title}-${item.action}`}
|
||||
className="image-canvas-editor__shortcut-row"
|
||||
>
|
||||
<dt>
|
||||
{item.action}
|
||||
{item.status === 'new' ? <span>新增</span> : null}
|
||||
</dt>
|
||||
<dd>
|
||||
{item.keys.map((key, index) => (
|
||||
<span key={`${item.action}-${key}-${index}`}>
|
||||
{index > 0 ? (
|
||||
<span
|
||||
className="image-canvas-editor__shortcut-plus"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
) : null}
|
||||
<kbd>{key}</kbd>
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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()}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
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({
|
||||
<span>画布</span>
|
||||
</div>
|
||||
<div className="image-canvas-editor__topbar-actions">
|
||||
<EditorIconButton
|
||||
label="查看画布快捷键"
|
||||
title="快捷键"
|
||||
icon={Keyboard}
|
||||
onClick={onOpenShortcuts}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="下载画布素材"
|
||||
title="下载画布素材"
|
||||
|
||||
@@ -62,7 +62,22 @@ function KeyboardShortcutsHarness({
|
||||
redoCanvasChange = vi.fn(),
|
||||
deleteLayerById = vi.fn(),
|
||||
deleteSelectedCanvasObjects = vi.fn(),
|
||||
selectAllCanvasObjects = vi.fn(),
|
||||
clearCanvasSelection = vi.fn(),
|
||||
copySelectedCanvasLayers = vi.fn(),
|
||||
pasteCanvasClipboard = vi.fn(),
|
||||
duplicateSelectedCanvasLayers = vi.fn(),
|
||||
moveSelectedCanvasLayers = vi.fn(),
|
||||
nudgeSelectedCanvasObjects = vi.fn(),
|
||||
requestRemoveCanvasGenerationDialog = vi.fn(),
|
||||
switchTool,
|
||||
updateScaleFromCenter = vi.fn(),
|
||||
zoomViewportBy = vi.fn(),
|
||||
fitLayers = vi.fn(),
|
||||
toggleSidebarPanel = vi.fn(),
|
||||
toggleMinimap = vi.fn(),
|
||||
startProjectRename = vi.fn(),
|
||||
exportCanvasAssets = vi.fn(),
|
||||
closeEditorChromePanels = vi.fn(),
|
||||
}: {
|
||||
selectedLayerId?: string | null;
|
||||
@@ -74,9 +89,24 @@ function KeyboardShortcutsHarness({
|
||||
redoCanvasChange?: () => 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<CanvasTool>(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(
|
||||
<KeyboardShortcutsHarness
|
||||
selectAllCanvasObjects={selectAllCanvasObjects}
|
||||
copySelectedCanvasLayers={copySelectedCanvasLayers}
|
||||
pasteCanvasClipboard={pasteCanvasClipboard}
|
||||
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
|
||||
exportCanvasAssets={exportCanvasAssets}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<KeyboardShortcutsHarness
|
||||
fitLayers={fitLayers}
|
||||
updateScaleFromCenter={updateScaleFromCenter}
|
||||
zoomViewportBy={zoomViewportBy}
|
||||
moveSelectedCanvasLayers={moveSelectedCanvasLayers}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<KeyboardShortcutsHarness
|
||||
switchTool={switchTool}
|
||||
toggleSidebarPanel={toggleSidebarPanel}
|
||||
toggleMinimap={toggleMinimap}
|
||||
startProjectRename={startProjectRename}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<KeyboardShortcutsHarness
|
||||
nudgeSelectedCanvasObjects={nudgeSelectedCanvasObjects}
|
||||
/>,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -139,12 +139,21 @@ function LayerCommandsHarness({
|
||||
<button type="button" onClick={() => commands.copyContextLayers()}>
|
||||
复制
|
||||
</button>
|
||||
<button type="button" onClick={() => commands.copySelectedLayers()}>
|
||||
复制选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commands.copyContextLayers({ cut: true })}
|
||||
>
|
||||
剪切
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commands.copySelectedLayers({ cut: true })}
|
||||
>
|
||||
剪切选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commands.pasteCanvasClipboard({ x: 500, y: 600 })}
|
||||
@@ -154,9 +163,15 @@ function LayerCommandsHarness({
|
||||
<button type="button" onClick={() => commands.duplicateContextLayers()}>
|
||||
创建副本
|
||||
</button>
|
||||
<button type="button" onClick={() => commands.duplicateSelectedLayers()}>
|
||||
创建选中副本
|
||||
</button>
|
||||
<button type="button" onClick={() => commands.moveContextLayers('top')}>
|
||||
置顶
|
||||
</button>
|
||||
<button type="button" onClick={() => commands.moveSelectedLayers('top')}>
|
||||
选中置顶
|
||||
</button>
|
||||
<button type="button" onClick={() => commands.groupContextLayers()}>
|
||||
创建组
|
||||
</button>
|
||||
@@ -258,6 +273,40 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies, cuts, duplicates, and moves selected layers for keyboard shortcuts', () => {
|
||||
const onDeleteLayerSideEffects = vi.fn();
|
||||
render(
|
||||
<LayerCommandsHarness
|
||||
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<LayerCommandsHarness exportLayerImage={exportLayerImage} />);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+112
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user