统一生成占位右键删除保护

让 generation 占位右键删除在历史和选择副作用前进入统一请求入口。

保留未收口完美像素身份并恢复普通生成中的删除确认。

补充层命令定向测试并同步技术方案与决策记录。
This commit is contained in:
2026-08-04 11:04:41 +00:00
parent 91a8b59cc4
commit aed9a0c4da
5 changed files with 125 additions and 47 deletions
@@ -1801,6 +1801,48 @@ export function ImageCanvasEditorView({
},
[openLayerGenerationDialog],
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
setActiveTool('select');
},
[
captureCanvasHistory,
removeCanvasGenerationDialogById,
setActiveTool,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
activateCanvasGenerationDialog(dialog);
showGenerationWarning(
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
);
return;
}
if (dialog.status === 'generating') {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[
activateCanvasGenerationDialog,
removeCanvasGenerationDialog,
showGenerationWarning,
],
);
const contextMenuLayer =
contextMenu?.kind === 'layer'
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
@@ -1842,6 +1884,7 @@ export function ImageCanvasEditorView({
selectSingleLayer,
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
exportLayerImage,
});
const {
@@ -1940,26 +1983,6 @@ export function ImageCanvasEditorView({
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
[],
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
setActiveTool('select');
},
[
captureCanvasHistory,
removeCanvasGenerationDialogById,
setActiveTool,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
useInlineGenerationPlaceholderExpiry({
@@ -1969,28 +1992,6 @@ export function ImageCanvasEditorView({
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
});
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
activateCanvasGenerationDialog(dialog);
showGenerationWarning(
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
);
return;
}
if (dialog.status === 'generating') {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[
activateCanvasGenerationDialog,
removeCanvasGenerationDialog,
showGenerationWarning,
],
);
const confirmRemoveGeneratingDialog = useCallback(() => {
const dialog = pendingGenerationDeleteDialog;
if (!dialog) {
@@ -109,8 +109,10 @@ function LayerCommandsHarness({
exportLayerImage = vi.fn(),
onDeleteLayerSideEffects = vi.fn(),
onDeleteGenerationDialogSideEffects = vi.fn(),
onRequestDeleteGenerationDialog,
onCaptureCanvasHistory = vi.fn(),
initialLayers,
initialContextMenu,
initialSelectedLayerId = 'first',
initialSelectedLayerIds = ['first', 'second'],
initialCanvasGenerationDialogs,
@@ -121,8 +123,12 @@ function LayerCommandsHarness({
) => void;
onDeleteLayerSideEffects?: (layerId: string) => void;
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
onRequestDeleteGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void;
initialLayers?: CanvasLayer[];
initialContextMenu?: CanvasContextMenuState;
initialSelectedLayerId?: string | null;
initialSelectedLayerIds?: string[];
initialCanvasGenerationDialogs?: CanvasGenerationDialogState[];
@@ -163,7 +169,7 @@ function LayerCommandsHarness({
);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>('first');
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
{
initialContextMenu ?? {
kind: 'layer',
layerId: 'first',
x: 0,
@@ -202,6 +208,7 @@ function LayerCommandsHarness({
selectSingleLayer,
onDeleteLayerSideEffects,
onDeleteGenerationDialogSideEffects,
onRequestDeleteGenerationDialog,
exportLayerImage,
});
@@ -666,6 +673,48 @@ describe('useImageCanvasLayerCommands', () => {
},
);
it('delegates a generation-dialog context delete before history and selection side effects', () => {
const dialog = createDurablePerfectPixelDialog(
'perfect-pixel-context',
'pending-confirmation',
);
const selectionId = `generation-dialog:${dialog.id}`;
const onCaptureCanvasHistory = vi.fn();
const onDeleteGenerationDialogSideEffects = vi.fn();
const onRequestDeleteGenerationDialog = vi.fn();
render(
<LayerCommandsHarness
initialContextMenu={{
kind: 'generation-dialog',
dialogId: dialog.id,
x: 0,
y: 0,
canvasPoint: { x: 0, y: 0 },
}}
initialSelectedLayerId={selectionId}
initialSelectedLayerIds={[selectionId]}
initialCanvasGenerationDialogs={[dialog]}
onCaptureCanvasHistory={onCaptureCanvasHistory}
onDeleteGenerationDialogSideEffects={
onDeleteGenerationDialogSideEffects
}
onRequestDeleteGenerationDialog={onRequestDeleteGenerationDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '删除右键目标' }));
expect(onRequestDeleteGenerationDialog).toHaveBeenCalledTimes(1);
expect(onRequestDeleteGenerationDialog).toHaveBeenCalledWith(dialog);
expect(screen.getByTestId('history').textContent).toBe('0');
expect(screen.getByTestId('selection').textContent).toBe(
`${selectionId}:${selectionId}`,
);
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(onCaptureCanvasHistory).not.toHaveBeenCalled();
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
});
it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => {
const dialog = createDurablePerfectPixelDialog(
'perfect-pixel-pending',
@@ -57,6 +57,9 @@ type LayerCommandsOptions = {
selectSingleLayer: (layerId: string | null) => void;
onDeleteLayerSideEffects: (targetLayerId: string) => void;
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
onRequestDeleteGenerationDialog?: (
targetDialog: CanvasGenerationDialogState,
) => void;
exportLayerImage: (
layer: CanvasLayer | null,
options?: { mode?: ImageSequenceExportMode },
@@ -146,6 +149,7 @@ export function useImageCanvasLayerCommands({
selectSingleLayer,
onDeleteLayerSideEffects,
onDeleteGenerationDialogSideEffects,
onRequestDeleteGenerationDialog,
exportLayerImage,
}: LayerCommandsOptions) {
const [canvasClipboard, setCanvasClipboard] =
@@ -527,12 +531,27 @@ export function useImageCanvasLayerCommands({
const deleteContextLayers = useCallback(() => {
const targetIds = getContextTargetLayerIds();
const targetLayerIds = getSelectedLayerIds(targetIds);
const targetDialogIds = targetIds
const targetDialogs = targetIds
.map(getCanvasGenerationDialogIdFromSelectionId)
.filter((dialogId): dialogId is string => Boolean(dialogId))
.filter((dialogId) =>
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
);
.flatMap((dialogId) => {
const dialog = canvasGenerationDialogs.find(
(candidate) => candidate.id === dialogId,
);
return dialog ? [dialog] : [];
});
if (
!targetLayerIds.length &&
targetDialogs.length &&
onRequestDeleteGenerationDialog
) {
closeContextMenus();
targetDialogs.forEach((dialog) => {
onRequestDeleteGenerationDialog(dialog);
});
return;
}
const targetDialogIds = targetDialogs.map((dialog) => dialog.id);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
@@ -566,6 +585,7 @@ export function useImageCanvasLayerCommands({
getContextTargetLayerIds,
onDeleteGenerationDialogSideEffects,
onDeleteLayerSideEffects,
onRequestDeleteGenerationDialog,
selectSingleLayer,
setHoveredLayerId,
setLayers,