新增画布快捷键弹窗

添加画布右上角快捷键入口和独立弹窗

补齐 Windows 画布快捷键与选中图层命令

更新快捷键单测和画布文档
This commit is contained in:
2026-06-22 17:58:00 +08:00
parent 8b18a654eb
commit 820e179be5
14 changed files with 1028 additions and 19 deletions
@@ -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
View File
@@ -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;
}