diff --git a/TRACKING.md b/TRACKING.md index cda2a93e..47e8139d 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -136,3 +136,4 @@ - 2026-06-17 前端拆分第十八阶段:新增 `useImageCanvasStageInteractions`,把画布舞台 pointer 状态机、选择 / 框选、多选拖拽、生成占位拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态从主视图抽出;主视图继续保留上传 drop、右键菜单、生成提交、项目持久化和工具栏动作分流。新增 hook 单测覆盖多选拖拽、框选、临时抓手、生成占位和小地图分流;主视图从 1802 行降至 1452 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新和未登录上传均弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏保持可见;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;普通滚轮不改变缩放,Ctrl 滚轮从 `146%` 到 `161%`;抓手 / 文字 / 选择工具可连续切换;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,关闭对话框后占位图保留,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。 - 2026-06-17 前端拆分第二十阶段:新增 `ImageCanvasMetadataModalView`,把图片信息弹窗从主视图抽出,承载图片类型、生成输入、参考图、模型、分辨率、Provider、Task 和 Object 信息渲染;主视图只保留 `metadataLayer` 状态和关闭回调。同步修复未登录进入编辑器时项目 / 素材接口抢跑 401、`重置画布视图` 点击事件误传给适合视图函数的问题。新增组件单测覆盖生成图 metadata、上传图 fallback 和关闭回调,新增 hook / 主视图测试覆盖未登录不请求受保护素材 / 工程数据和重置按钮回归;主视图从 1405 行降至 1337 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后直接弹出 `账号入口`,且未登录状态下没有发起 `/api/editor/*` 请求;登录临时开发账号后 `重置画布视图` 无控制台错误,`画布背景设置` 保持 Lovart 式白色浮层,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`,上传素材可加入画布,右上角图片信息按钮可打开不透明白底元数据弹窗,关闭后 `AI画布工具栏` 仍可见。 +- 2026-06-17 前端拆分第二十一阶段:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 36a61206..1a771e0c 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -176,6 +176,13 @@ - 本阶段同步把项目 / 素材初始加载挂到 `AuthGate` 的受保护数据可访问状态之后;未登录进入编辑器只拉起 `账号入口`,不再抢跑 `/api/editor/*` 造成素材或工程读取 401 噪声。 - `重置画布视图` 按钮必须显式调用 `onFitLayers()`,不能把 React click event 作为目标图层数组透传给适合视图逻辑。 +## 第二十一阶段模块 + +- `useImageCanvasKeyboardShortcuts.ts` + - 承载图片画布全局键盘快捷键:Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 按下态、Backspace / Delete 删除选中图层、删除可编辑生成占位、Escape 关闭临时面板,以及 Space 临时抓手。 + - 主视图继续保留各工作流状态和具体副作用,例如图层删除、生成对话框、规格菜单、快速编辑面板和 chrome 面板状态;快捷键 hook 只接收 ref、setter 与回调,不直接读写素材库、路由或 API。 + - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、选中图层删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 @@ -183,8 +190,8 @@ ## 验证计划 -- `npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` +- `npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `git diff --check` -- 浏览器回归 `/editor/canvas`:确认登录弹窗、素材上传、背景设置面板、底部工具栏和画布基础渲染仍正常。 +- 浏览器回归 `/editor/canvas`:确认登录弹窗、素材上传、背景设置面板、底部工具栏、Space 临时抓手、撤销 / 重做和画布基础渲染仍正常。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d3d9da0f..3b6fa36c 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -54,6 +54,7 @@ import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWo import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; +import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; @@ -63,18 +64,6 @@ import { useImageCanvasViewportControls, } from './useImageCanvasViewportControls'; -function isEditableTarget(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; - if (!target) { - return false; - } - return ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable - ); -} - export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); @@ -687,120 +676,31 @@ export function ImageCanvasEditorView() { onCloseImageContextMenu: () => setImageContextMenu(null), }); resetCanvasInteractionStateRef.current = clearActiveInteraction; + const deleteLayerByIdFromShortcut = useCallback( + (layerId: string | null) => deleteLayerByIdRef.current(layerId), + [], + ); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - (event.ctrlKey || event.metaKey) && - event.code === 'KeyZ' && - !isEditableTarget(event) - ) { - event.preventDefault(); - if (event.shiftKey) { - redoCanvasChange(); - } else { - undoCanvasChange(); - } - return; - } - if (event.key === 'Shift') { - setShiftPressed(true); - } - if ( - (event.key === 'Backspace' || event.key === 'Delete') && - !event.repeat && - !isEditableTarget(event) - ) { - const currentDialog = generateDialogRef.current; - const currentSelectedLayerId = selectedLayerIdRef.current; - if (currentSelectedLayerId) { - event.preventDefault(); - deleteLayerByIdRef.current(currentSelectedLayerId); - return; - } - if ( - currentDialog?.placeholder && - currentDialog.status !== 'generating' && - (currentDialog.mode === 'generate' || - currentDialog.mode === 'spec' || - currentDialog.mode === 'character' || - currentDialog.mode === 'icon') - ) { - event.preventDefault(); - setGenerateDialog(null); - setActiveTool('select'); - setIsCharacterSpecMenuOpen(false); - setIsPickingCharacterSpecFromCanvas(false); - setIsIconSpecMenuOpen(false); - setIsPickingIconSpecFromCanvas(false); - return; - } - } - if (event.key === 'Escape') { - closeEditorChromePanels(); - setIsSpecMenuOpen(false); - setImageContextMenu(null); - setContextMenu(null); - setQuickEditPanel((currentPanel) => - currentPanel?.status === 'generating' ? currentPanel : null, - ); - setIsCharacterSpecMenuOpen(false); - setIsPickingCharacterSpecFromCanvas(false); - setIsIconSpecMenuOpen(false); - setIsPickingIconSpecFromCanvas(false); - setGenerateDialog((currentDialog) => { - if (!currentDialog || currentDialog.status === 'generating') { - return currentDialog; - } - if ( - currentDialog.mode === 'generate' || - currentDialog.mode === 'spec' - ) { - return { - ...currentDialog, - composerOpen: false, - }; - } - if (currentDialog.mode === 'character') { - return currentDialog; - } - if (currentDialog.mode === 'icon') { - return currentDialog; - } - return null; - }); - return; - } - if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { - return; - } - event.preventDefault(); - setIsSpacePanning(true); - }; - const handleKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - setShiftPressed(false); - } - if (event.code !== 'Space') { - return; - } - event.preventDefault(); - setIsSpacePanning(false); - }; - - window.addEventListener('keydown', handleKeyDown); - window.addEventListener('keyup', handleKeyUp); - return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('keyup', handleKeyUp); - }; - }, [ - closeEditorChromePanels, + useImageCanvasKeyboardShortcuts({ + generateDialogRef, + selectedLayerIdRef, redoCanvasChange, + undoCanvasChange, + deleteLayerById: deleteLayerByIdFromShortcut, + setActiveTool, + setGenerateDialog, + setImageContextMenu, + setContextMenu, + setQuickEditPanel, + closeEditorChromePanels, + setIsSpecMenuOpen, + setIsCharacterSpecMenuOpen, + setIsPickingCharacterSpecFromCanvas, + setIsIconSpecMenuOpen, + setIsPickingIconSpecFromCanvas, setIsSpacePanning, setShiftPressed, - undoCanvasChange, - ]); + }); useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx new file mode 100644 index 00000000..5071864f --- /dev/null +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -0,0 +1,319 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + CanvasTool, + GenerateDialogState, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; +import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; + +function createPlaceholderDialog( + mode: GenerateDialogState['mode'] = 'generate', +): GenerateDialogState { + return { + id: 'dialog-1', + mode, + prompt: '生成一张图片', + status: 'idle', + composerOpen: true, + placeholder: { + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }; +} + +function applyGenerateDialogUpdater( + updater: + | GenerateDialogState + | null + | ((dialog: GenerateDialogState | null) => GenerateDialogState | null), + currentDialog: GenerateDialogState | null, +) { + return typeof updater === 'function' ? updater(currentDialog) : updater; +} + +function applyQuickEditPanelUpdater( + updater: + | QuickEditPanelState + | null + | ((panel: QuickEditPanelState | null) => QuickEditPanelState | null), + currentPanel: QuickEditPanelState | null, +) { + return typeof updater === 'function' ? updater(currentPanel) : updater; +} + +function KeyboardShortcutsHarness({ + selectedLayerId = null, + initialGenerateDialog = null, + initialQuickEditPanel = null, + initialTool = 'select', + undoCanvasChange = vi.fn(), + redoCanvasChange = vi.fn(), + deleteLayerById = vi.fn(), + closeEditorChromePanels = vi.fn(), +}: { + selectedLayerId?: string | null; + initialGenerateDialog?: GenerateDialogState | null; + initialQuickEditPanel?: QuickEditPanelState | null; + initialTool?: CanvasTool; + undoCanvasChange?: () => void; + redoCanvasChange?: () => void; + deleteLayerById?: (layerId: string | null) => void; + closeEditorChromePanels?: () => void; +}) { + const [activeTool, setActiveTool] = useState(initialTool); + const [generateDialog, setGenerateDialogState] = + useState(initialGenerateDialog); + const [quickEditPanel, setQuickEditPanelState] = + useState(initialQuickEditPanel); + const [imageContextMenuOpen, setImageContextMenuOpen] = useState(true); + const [contextMenuOpen, setContextMenuOpen] = useState(true); + const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true); + const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true); + const [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] = + useState(true); + const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true); + const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = + useState(true); + const [isSpacePanning, setIsSpacePanning] = useState(false); + const [shiftPressed, setShiftPressed] = useState(false); + const generateDialogRef = useRef(generateDialog); + const selectedLayerIdRef = useRef(selectedLayerId); + generateDialogRef.current = generateDialog; + selectedLayerIdRef.current = selectedLayerId; + + useImageCanvasKeyboardShortcuts({ + generateDialogRef, + selectedLayerIdRef, + redoCanvasChange, + undoCanvasChange, + deleteLayerById, + setActiveTool: (tool) => setActiveTool(tool), + setGenerateDialog: (updater) => + setGenerateDialogState((currentDialog) => + applyGenerateDialogUpdater(updater, currentDialog), + ), + setImageContextMenu: () => setImageContextMenuOpen(false), + setContextMenu: () => setContextMenuOpen(false), + setQuickEditPanel: (updater) => + setQuickEditPanelState((currentPanel) => + applyQuickEditPanelUpdater(updater, currentPanel), + ), + closeEditorChromePanels, + setIsSpecMenuOpen, + setIsCharacterSpecMenuOpen, + setIsPickingCharacterSpecFromCanvas, + setIsIconSpecMenuOpen, + setIsPickingIconSpecFromCanvas, + setIsSpacePanning, + setShiftPressed, + }); + + return ( +
+ + {activeTool} + + {generateDialog + ? `${generateDialog.mode}:${generateDialog.status}:${String( + generateDialog.composerOpen, + )}` + : 'none'} + + + {quickEditPanel ? quickEditPanel.status : 'none'} + + {String(imageContextMenuOpen)} + {String(contextMenuOpen)} + {String(isSpecMenuOpen)} + + {String(isCharacterSpecMenuOpen)} + + + {String(isPickingCharacterSpecFromCanvas)} + + {String(isIconSpecMenuOpen)} + + {String(isPickingIconSpecFromCanvas)} + + {String(isSpacePanning)} + {String(shiftPressed)} +
+ ); +} + +describe('useImageCanvasKeyboardShortcuts', () => { + it('routes undo and redo shortcuts while ignoring editable inputs', () => { + const undoCanvasChange = vi.fn(); + const redoCanvasChange = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); + }); + expect(undoCanvasChange).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.keyDown(window, { + key: 'Z', + code: 'KeyZ', + ctrlKey: true, + shiftKey: true, + }); + }); + expect(redoCanvasChange).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + }); + }); + expect(undoCanvasChange).toHaveBeenCalledTimes(1); + }); + + it('deletes the selected layer with Backspace outside editable inputs', () => { + const deleteLayerById = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); + }); + + expect(deleteLayerById).toHaveBeenCalledWith('layer-selected'); + }); + + it('removes editable generation placeholders with Backspace', () => { + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); + }); + + expect(screen.getByTestId('generate-dialog').textContent).toBe('none'); + expect(screen.getByTestId('active-tool').textContent).toBe('select'); + expect(screen.getByTestId('character-menu').textContent).toBe('false'); + expect(screen.getByTestId('character-picking').textContent).toBe('false'); + expect(screen.getByTestId('icon-menu').textContent).toBe('false'); + expect(screen.getByTestId('icon-picking').textContent).toBe('false'); + }); + + it('closes transient editor panels with Escape and collapses idle composers', () => { + const closeEditorChromePanels = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + }); + + expect(closeEditorChromePanels).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('generate-dialog').textContent).toBe( + 'generate:idle:false', + ); + expect(screen.getByTestId('quick-edit').textContent).toBe('none'); + expect(screen.getByTestId('image-context').textContent).toBe('false'); + expect(screen.getByTestId('context-menu').textContent).toBe('false'); + expect(screen.getByTestId('spec-menu').textContent).toBe('false'); + expect(screen.getByTestId('character-menu').textContent).toBe('false'); + expect(screen.getByTestId('icon-menu').textContent).toBe('false'); + }); + + it('keeps generating panels open when Escape closes transient chrome', () => { + const generatingDialog = createPlaceholderDialog('generate'); + generatingDialog.status = 'generating'; + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + }); + + expect(screen.getByTestId('generate-dialog').textContent).toBe( + 'generate:generating:true', + ); + expect(screen.getByTestId('quick-edit').textContent).toBe('generating'); + }); + + it('toggles temporary panning with Space outside editable inputs', () => { + render(); + + act(() => { + fireEvent.keyDown(window, { key: ' ', code: 'Space' }); + }); + expect(screen.getByTestId('space-panning').textContent).toBe('true'); + + act(() => { + fireEvent.keyUp(window, { key: ' ', code: 'Space' }); + }); + expect(screen.getByTestId('space-panning').textContent).toBe('false'); + + act(() => { + fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), { + key: ' ', + code: 'Space', + }); + }); + expect(screen.getByTestId('space-panning').textContent).toBe('false'); + }); + + it('tracks Shift key state', () => { + render(); + + act(() => { + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + }); + expect(screen.getByTestId('shift-pressed').textContent).toBe('true'); + + act(() => { + fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + }); + expect(screen.getByTestId('shift-pressed').textContent).toBe('false'); + }); +}); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts new file mode 100644 index 00000000..eca38901 --- /dev/null +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -0,0 +1,205 @@ +import { type RefObject, useEffect } from 'react'; + +import type { + GenerateDialogState, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; + +type UseImageCanvasKeyboardShortcutsOptions = { + generateDialogRef: RefObject; + selectedLayerIdRef: RefObject; + redoCanvasChange: () => void; + undoCanvasChange: () => void; + deleteLayerById: (layerId: string | null) => void; + setActiveTool: (tool: 'select') => void; + setGenerateDialog: ( + updater: + | GenerateDialogState + | null + | ((dialog: GenerateDialogState | null) => GenerateDialogState | null), + ) => void; + setImageContextMenu: (menu: null) => void; + setContextMenu: (menu: null) => void; + setQuickEditPanel: ( + updater: + | QuickEditPanelState + | null + | (( + panel: QuickEditPanelState | null, + ) => QuickEditPanelState | null), + ) => void; + closeEditorChromePanels: () => void; + setIsSpecMenuOpen: (open: boolean) => void; + setIsCharacterSpecMenuOpen: (open: boolean) => void; + setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void; + setIsIconSpecMenuOpen: (open: boolean) => void; + setIsPickingIconSpecFromCanvas: (picking: boolean) => void; + setIsSpacePanning: (panning: boolean) => void; + setShiftPressed: (pressed: boolean) => void; +}; + +function isEditableTarget(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + if (!target) { + return false; + } + return ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ); +} + +function isCanvasGenerationPlaceholderDialog( + dialog: GenerateDialogState | null, +) { + return ( + Boolean(dialog?.placeholder) && + dialog?.status !== 'generating' && + (dialog?.mode === 'generate' || + dialog?.mode === 'spec' || + dialog?.mode === 'character' || + dialog?.mode === 'icon') + ); +} + +export function useImageCanvasKeyboardShortcuts({ + generateDialogRef, + selectedLayerIdRef, + redoCanvasChange, + undoCanvasChange, + deleteLayerById, + setActiveTool, + setGenerateDialog, + setImageContextMenu, + setContextMenu, + setQuickEditPanel, + closeEditorChromePanels, + setIsSpecMenuOpen, + setIsCharacterSpecMenuOpen, + setIsPickingCharacterSpecFromCanvas, + setIsIconSpecMenuOpen, + setIsPickingIconSpecFromCanvas, + setIsSpacePanning, + setShiftPressed, +}: UseImageCanvasKeyboardShortcutsOptions) { + useEffect(() => { + const closeTransientEditorPanels = () => { + closeEditorChromePanels(); + setIsSpecMenuOpen(false); + setImageContextMenu(null); + setContextMenu(null); + setQuickEditPanel((currentPanel) => + currentPanel?.status === 'generating' ? currentPanel : null, + ); + setIsCharacterSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsIconSpecMenuOpen(false); + setIsPickingIconSpecFromCanvas(false); + setGenerateDialog((currentDialog) => { + if (!currentDialog || currentDialog.status === 'generating') { + return currentDialog; + } + if (currentDialog.mode === 'generate' || currentDialog.mode === 'spec') { + return { + ...currentDialog, + composerOpen: false, + }; + } + if (currentDialog.mode === 'character') { + return currentDialog; + } + if (currentDialog.mode === 'icon') { + return currentDialog; + } + return null; + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + event.code === 'KeyZ' && + !isEditableTarget(event) + ) { + event.preventDefault(); + if (event.shiftKey) { + redoCanvasChange(); + } else { + undoCanvasChange(); + } + return; + } + if (event.key === 'Shift') { + setShiftPressed(true); + } + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + !event.repeat && + !isEditableTarget(event) + ) { + const currentSelectedLayerId = selectedLayerIdRef.current; + if (currentSelectedLayerId) { + event.preventDefault(); + deleteLayerById(currentSelectedLayerId); + return; + } + if (isCanvasGenerationPlaceholderDialog(generateDialogRef.current)) { + event.preventDefault(); + setGenerateDialog(null); + setActiveTool('select'); + setIsCharacterSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsIconSpecMenuOpen(false); + setIsPickingIconSpecFromCanvas(false); + return; + } + } + if (event.key === 'Escape') { + closeTransientEditorPanels(); + return; + } + if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { + return; + } + event.preventDefault(); + setIsSpacePanning(true); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + setShiftPressed(false); + } + if (event.code !== 'Space') { + return; + } + event.preventDefault(); + setIsSpacePanning(false); + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [ + closeEditorChromePanels, + deleteLayerById, + generateDialogRef, + redoCanvasChange, + selectedLayerIdRef, + setActiveTool, + setContextMenu, + setGenerateDialog, + setImageContextMenu, + setIsCharacterSpecMenuOpen, + setIsIconSpecMenuOpen, + setIsPickingCharacterSpecFromCanvas, + setIsPickingIconSpecFromCanvas, + setIsSpacePanning, + setIsSpecMenuOpen, + setQuickEditPanel, + setShiftPressed, + undoCanvasChange, + ]); +}