修复画布生成器偶发无法打开

同步生成器 active/inactive ref 与 state

激活已有生成器时清理选参考图和菜单临时状态

窗口失焦时释放 Shift 与空格按住态

补充生成器状态与快捷键回归测试

记录图片画布生成器排障经验
This commit is contained in:
2026-07-07 16:22:09 +08:00
parent b743152c6a
commit 9b49085869
6 changed files with 182 additions and 58 deletions
@@ -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 明显卡顿,关闭或绕开吸附后体感恢复。
@@ -310,6 +310,7 @@ export function ImageCanvasEditorView({
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const captureCanvasHistoryRef = useRef<() => void>(() => {});
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(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;
@@ -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,
}),
]);
});
});
@@ -70,13 +70,17 @@ export function useCanvasGenerationDialogs({
const setGenerateDialog = useCallback<
Dispatch<SetStateAction<GenerateDialogState | null>>
>((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);
},
[],
);
@@ -466,6 +466,23 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(startProjectRename).toHaveBeenCalledTimes(1);
});
it('releases held modifier interaction state when the window loses focus', () => {
render(<KeyboardShortcutsHarness />);
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(
@@ -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,