diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 27a992727..ab462ce99 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -167,6 +167,14 @@ - 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +## 图片画布生成器全体点不开先查卡住的临时交互状态 + +- 现象:特定操作后,画布中已有生成器点击不再显示设定对话框,而且不是单个生成器坏掉;新建生成器或刷新页面后恢复。 +- 原因:旧生成器激活依赖全局交互状态;如果 `Shift` / 空格按住态因为窗口失焦漏掉 `keyup`,或“从画布选择参考图”等临时 picking / 菜单状态没有在激活旧生成器时清理,后续点击会被当成多选或选参考图而短路。 +- 处理:窗口 `blur` / 页面隐藏时释放 `Shift` 和空格按住态;激活已有 generation dialog 时同步清理参考图 picking、规格 / 参考菜单和右键菜单;active / inactive 生成器状态的 ref 与 React state 必须同事件周期同步。 +- 验证:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx -- --runInBand`,并跑 `ImageCanvasEditorView.test.tsx` 确认真实组件链路仍能激活生成器。 +- 关联:`src/components/image-editor/useCanvasGenerationDialogs.ts`、`src/components/image-editor/useImageCanvasKeyboardShortcuts.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`。 + ## 图片画布素材多时拖拽卡顿先查等距吸附候选规模 - 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 10ae75459..d74f18dbb 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -310,6 +310,7 @@ export function ImageCanvasEditorView({ const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); + const closeGenerationTransientStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); const publicationToolWrapRef = useRef(null); @@ -649,9 +650,11 @@ export function ImageCanvasEditorView({ }); const handleActivateCanvasGenerationDialog = useCallback(() => { + closeGenerationTransientStateRef.current(); setSelectedLayerId(null); setSelectedLayerIds([]); setImageContextMenu(null); + setContextMenu(null); }, []); const { generateDialog, @@ -1245,6 +1248,7 @@ export function ImageCanvasEditorView({ setCharacterAnimationPanel, setIsSpecMenuOpen, setIsGenerationReferenceMenuOpen, + setIsPublicationReferenceMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, isPickingGenerationReferenceFromCanvas, @@ -1299,6 +1303,24 @@ export function ImageCanvasEditorView({ generationComposerNode, switchGenerationTool, } = generationSurface; + closeGenerationTransientStateRef.current = () => { + setIsSpecMenuOpen(false); + setIsGenerationReferenceMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingGenerationReferenceFromCanvas(false); + setIsPickingQuickEditReferenceFromCanvas(false); + setIsPickingPublicationReferenceFromCanvas(false); + setIsCharacterSpecMenuOpen(false); + setIsCharacterReferenceMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); + setIsIconSpecMenuOpen(false); + setIsPickingIconSpecFromCanvas(false); + setIsUiDesignSpecMenuOpen(false); + setIsPickingUiDesignSpecFromCanvas(false); + setImageContextMenu(null); + setContextMenu(null); + }; setQuickEditPanelRef.current = setQuickEditPanel; appendUiAssetExtractionReferencesRef.current = appendUiAssetExtractionReferences; diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index 1a504564a..e845a4a4c 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -226,4 +226,58 @@ describe('useCanvasGenerationDialogs', () => { }), ]); }); + + it('keeps refs in sync when an archived dialog is activated and immediately updated', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('generate', 'first'), + ); + }); + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('character', 'second'), + ); + }); + + const archivedFirstDialog = result.current.inactiveGenerateDialogs[0]; + expect(archivedFirstDialog).toEqual( + expect.objectContaining({ prompt: 'first' }), + ); + + let activeDialogRefId: string | undefined; + act(() => { + result.current.activateCanvasGenerationDialog(archivedFirstDialog!); + activeDialogRefId = result.current.generateDialogRef.current?.id; + result.current.updateCanvasGenerationDialogById( + archivedFirstDialog!.id, + (dialog) => ({ + ...dialog, + placeholder: dialog.placeholder + ? { + ...dialog.placeholder, + x: 128, + } + : dialog.placeholder, + }), + ); + }); + + expect(activeDialogRefId).toBe(archivedFirstDialog!.id); + expect(result.current.activeCanvasGenerationDialog).toEqual( + expect.objectContaining({ + id: archivedFirstDialog!.id, + prompt: 'first', + composerOpen: true, + placeholder: expect.objectContaining({ x: 128 }), + }), + ); + expect(result.current.inactiveGenerateDialogs).toEqual([ + expect.objectContaining({ + prompt: 'second', + composerOpen: false, + }), + ]); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index 28e51f060..21ab352d5 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -70,13 +70,17 @@ export function useCanvasGenerationDialogs({ const setGenerateDialog = useCallback< Dispatch> >((nextDialogOrUpdater) => { - setGenerateDialogState((currentDialog) => { - const nextDialog = - typeof nextDialogOrUpdater === 'function' - ? nextDialogOrUpdater(currentDialog) - : nextDialogOrUpdater; - return withGenerationTimestamps(nextDialog, currentDialog); - }); + const currentDialog = generateDialogRef.current; + const nextDialog = + typeof nextDialogOrUpdater === 'function' + ? nextDialogOrUpdater(currentDialog) + : nextDialogOrUpdater; + const nextDialogWithTimestamps = withGenerationTimestamps( + nextDialog, + currentDialog, + ); + generateDialogRef.current = nextDialogWithTimestamps; + setGenerateDialogState(nextDialogWithTimestamps); }, []); const canvasGenerationDialogs = useMemo( () => @@ -96,17 +100,19 @@ export function useCanvasGenerationDialogs({ if (!isCanvasGenerationDialog(currentDialog)) { return; } - setInactiveGenerateDialogs((currentDialogs) => - currentDialogs.some((dialog) => dialog.id === currentDialog.id) - ? currentDialogs - : [ - ...currentDialogs, - { - ...currentDialog, - composerOpen: false, - }, - ], - ); + const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some( + (dialog) => dialog.id === currentDialog.id, + ) + ? inactiveGenerateDialogsRef.current + : [ + ...inactiveGenerateDialogsRef.current, + { + ...currentDialog, + composerOpen: false, + }, + ]; + inactiveGenerateDialogsRef.current = nextInactiveDialogs; + setInactiveGenerateDialogs(nextInactiveDialogs); }, []); const openCanvasGenerationDialog = useCallback( @@ -198,26 +204,27 @@ export function useCanvasGenerationDialogs({ const activateCanvasGenerationDialog = useCallback( (targetDialog: CanvasGenerationDialogState) => { - setInactiveGenerateDialogs((currentDialogs) => { - const nextDialogs = currentDialogs.filter( - (dialog) => dialog.id !== targetDialog.id, - ); - const currentDialog = generateDialogRef.current; - if ( - isCanvasGenerationDialog(currentDialog) && - currentDialog.id !== targetDialog.id - ) { - nextDialogs.push({ - ...currentDialog, - composerOpen: false, - }); - } - return nextDialogs; - }); - setGenerateDialogState({ + const currentDialog = generateDialogRef.current; + const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter( + (dialog) => dialog.id !== targetDialog.id, + ); + if ( + isCanvasGenerationDialog(currentDialog) && + currentDialog.id !== targetDialog.id + ) { + nextInactiveDialogs.push({ + ...currentDialog, + composerOpen: false, + }); + } + const nextActiveDialog = { ...targetDialog, composerOpen: true, - }); + }; + inactiveGenerateDialogsRef.current = nextInactiveDialogs; + generateDialogRef.current = nextActiveDialog; + setInactiveGenerateDialogs(nextInactiveDialogs); + setGenerateDialogState(nextActiveDialog); onActivate?.(); }, [onActivate], @@ -238,22 +245,22 @@ export function useCanvasGenerationDialogs({ [...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ?? dialogs[dialogs.length - 1] ?? null; - setGenerateDialogState( - activeDialog - ? { - ...activeDialog, - composerOpen: activeDialog.composerOpen !== false, - } - : null, - ); - setInactiveGenerateDialogs( - dialogs - .filter((dialog) => dialog.id !== activeDialog?.id) - .map((dialog) => ({ - ...dialog, - composerOpen: false, - })), - ); + const nextActiveDialog = activeDialog + ? { + ...activeDialog, + composerOpen: activeDialog.composerOpen !== false, + } + : null; + const nextInactiveDialogs = dialogs + .filter((dialog) => dialog.id !== activeDialog?.id) + .map((dialog) => ({ + ...dialog, + composerOpen: false, + })); + generateDialogRef.current = nextActiveDialog; + inactiveGenerateDialogsRef.current = nextInactiveDialogs; + setGenerateDialogState(nextActiveDialog); + setInactiveGenerateDialogs(nextInactiveDialogs); }, [], ); @@ -263,14 +270,17 @@ export function useCanvasGenerationDialogs({ const keepDialog = (dialog: CanvasGenerationDialogState) => dialog.sourceLayerId !== targetLayerId && dialog.generatedLayerId !== targetLayerId; - setGenerateDialogState((currentDialog) => + const currentDialog = generateDialogRef.current; + const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) ? null - : currentDialog, - ); - setInactiveGenerateDialogs((currentDialogs) => - currentDialogs.filter(keepDialog), - ); + : currentDialog; + const nextInactiveDialogs = + inactiveGenerateDialogsRef.current.filter(keepDialog); + generateDialogRef.current = nextActiveDialog; + inactiveGenerateDialogsRef.current = nextInactiveDialogs; + setGenerateDialogState(nextActiveDialog); + setInactiveGenerateDialogs(nextInactiveDialogs); }, [], ); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 5ef9250a2..3ac95d3ab 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -466,6 +466,23 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(startProjectRename).toHaveBeenCalledTimes(1); }); + it('releases held modifier interaction state when the window loses focus', () => { + render(); + + act(() => { + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.keyDown(window, { key: ' ', code: 'Space' }); + }); + expect(screen.getByTestId('shift-pressed').textContent).toBe('true'); + expect(screen.getByTestId('space-panning').textContent).toBe('true'); + + act(() => { + fireEvent.blur(window); + }); + expect(screen.getByTestId('shift-pressed').textContent).toBe('false'); + expect(screen.getByTestId('space-panning').textContent).toBe('false'); + }); + it('nudges selected canvas objects with arrow keys', () => { const nudgeSelectedCanvasObjects = vi.fn(); render( diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index 1066833c7..f13627a3b 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -470,12 +470,25 @@ export function useImageCanvasKeyboardShortcuts({ event.preventDefault(); setIsSpacePanning(false); }; + const resetPressedInteractionState = () => { + setShiftPressed(false); + setIsSpacePanning(false); + }; + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') { + resetPressedInteractionState(); + } + }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); + window.addEventListener('blur', resetPressedInteractionState); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); + window.removeEventListener('blur', resetPressedInteractionState); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [ closeEditorChromePanels,