修复画布生成器偶发无法打开
同步生成器 active/inactive ref 与 state 激活已有生成器时清理选参考图和菜单临时状态 窗口失焦时释放 Shift 与空格按住态 补充生成器状态与快捷键回归测试 记录图片画布生成器排障经验
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user