From 5925b396d09d10fa50039f45a751c7903739515d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 5 Aug 2026 04:50:37 +0000 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E5=8D=A0=E4=BD=8D=E7=9A=84=E7=94=A8=E6=88=B7=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 低层删除不再对未收口 operation 抗命,随源图层与多选删除同等对待 删除确认判据收敛为 requiresGenerationDeleteConfirmation,免费的完美像素不再弹泥点确认 标记 operation 失效时一并丢弃快照,消除重试消失但占位仍在的矛盾态 翻转钉死删除保护的用例,改为覆盖删除后结果仍落库并走 asset-only 提示 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 11 ++ .../image-editor/ImageCanvasEditorView.tsx | 23 +-- .../useCanvasGenerationDialogs.test.tsx | 152 ++++++++++++------ .../useCanvasGenerationDialogs.ts | 30 ++-- .../useImageCanvasGenerationWorkflow.test.tsx | 36 ++--- .../useImageCanvasGenerationWorkflow.ts | 32 ++-- .../useImageCanvasLayerCommands.test.tsx | 32 ++-- .../useImageCanvasLayerCommands.ts | 9 +- 8 files changed, 190 insertions(+), 135 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 81a2ab995..1aeb57634 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6370,3 +6370,14 @@ - 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。 - 验证方式:前端覆盖 `serializeLayer` 产物不含 `model` / `provider`,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废 + +- 背景:2026-08-04「完美像素第二批:object-only 上传与 GET-only unknown 收口」把「未收口的完美像素 operation」定为不可删除,理由是结果 unknown 时要保留 identity 供原样重试;同日「confirm 后 strict 保存失败保留原 operation」又把 POST 尚未发出的 `failed` 一并纳入,理由是从源图重开会重新执行 ticket / PUT / confirm,让上一次已 confirm 的源图对象失去引用。两条叠加后,右键删除、Delete 快捷键、随源图层删除、多选删除全部豁免该占位,到期清理也不覆盖它,用户画布上出现了删不掉的元素。 +- 缺陷判定:删除占位**不撤销任何在途请求**——完美像素没有取消接口,结果照常落库并进素材库;服务端 completion 发现 dialog 已不在会返回 `DialogMissing`,客户端本就有对应提示。封锁买到的只是「结果自动回填画布」这一便利,代价却是用户文档不可编辑。至于孤儿源图对象,「confirm 后 strict 保存失败保留原 operation」自己的「剩余边界」一节已把同类残留定性为「影响限于不可达的源图存储与垃圾记录累积……不作为本 PR 的合并阻断项」;为避免同一种残留而禁止用户删除自己画布上的元素,权衡不自洽。 +- 决策:本条取代上述两条中关于**删除**的条款。完美像素占位在 `generating` / `pending-confirmation` / `failed` 任何状态都可删,且不弹确认。低层 `removeCanvasGenerationDialogById` 恢复为无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因;随源图层删除不再豁免;Delete 快捷键与多选删除不再过滤该目标。删除确认的判据收敛为具名的 `requiresGenerationDeleteConfirmation`:现成弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立,而完美像素 `generation_cost_mud_points = 0`。运行时标记 `perfectPixelOperationInvalid` 时必须同时丢弃 `perfectPixelOperation`,与 `hydrateCanvasGenerationDialog` 口径一致,不再留下「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。 +- 保留不变:保留 operation identity 供「在原占位原样重试」的能力不变,重试仍复用同一 request、source objectKey 与 dialog / operation / task identity。从源图重新发起仍被 `existingOperation` 闸拦住,另行处理。带 operation 的占位继续豁免 inline 占位 TTL 到期清理——用户主动删除与系统替用户删除是两回事。 +- 已知后果:删掉 `pending-confirmation` 占位后,刷新恢复不再对账这条 operation;结果若已生成只会出现在素材库,不回填画布。这是用户主动放弃的结果,不是回归,不得据此判定为缺陷。 +- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。 +- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d8e36803b..56d0c4791 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -79,7 +79,7 @@ import { } from './ImageCanvasSelectionModel'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; import { - isUnsettledPerfectPixelOperationDialog, + requiresGenerationDeleteConfirmation, useCanvasGenerationDialogs, } from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; @@ -1719,31 +1719,14 @@ export function ImageCanvasEditorView({ ); const requestRemoveCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogState) => { - if (isUnsettledPerfectPixelOperationDialog(dialog)) { - const previousSelectedLayerId = selectedLayerIdRef.current; - const previousSelectedLayerIds = [...selectedLayerIdsRef.current]; - activateCanvasGenerationDialog(dialog); - setSelectedLayerId(previousSelectedLayerId); - setSelectedLayerIds(previousSelectedLayerIds); - showGenerationWarning( - '完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。', - ); - return; - } - if (dialog.status === 'generating') { + if (requiresGenerationDeleteConfirmation(dialog)) { activateCanvasGenerationDialog(dialog); setPendingGenerationDeleteDialog(dialog); return; } removeCanvasGenerationDialog(dialog.id); }, - [ - activateCanvasGenerationDialog, - removeCanvasGenerationDialog, - setSelectedLayerId, - setSelectedLayerIds, - showGenerationWarning, - ], + [activateCanvasGenerationDialog, removeCanvasGenerationDialog], ); const contextMenuLayer = contextMenu?.kind === 'layer' diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index 24f3db540..6b848b868 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -4,7 +4,10 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + requiresGenerationDeleteConfirmation, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; function createDialog( mode: CanvasGenerationDialogState['mode'], @@ -26,6 +29,45 @@ function createDialog( }; } +function durablePerfectPixelDialog( + dialogId: string, + status: 'generating' | 'pending-confirmation' | 'failed', +): CanvasGenerationDialogState { + const submittedAt = 1_700_000_000_000; + return { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status, + composerOpen: true, + sourceLayerId: 'layer-source', + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }, + }; +} + describe('useCanvasGenerationDialogs', () => { it('archives, activates, updates, and removes canvas generation dialogs', () => { const onActivate = vi.fn(); @@ -306,69 +348,73 @@ describe('useCanvasGenerationDialogs', () => { ]); }); - it('preserves unsettled durable perfect-pixel operations across ordinary delete paths', () => { + // 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口, + // 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出 + // 「历史记了一笔、占位还在」的伪历史。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'deletes an unsettled durable perfect-pixel operation by id while %s', + (status) => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + durablePerfectPixelDialog(dialogId, status), + ]); + }); + act(() => { + result.current.removeCanvasGenerationDialogById(dialogId); + }); + + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); + }, + ); + + // 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除 + // 占位不撤销在途请求,所以它在任何状态都直接删。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'never asks for delete confirmation on a perfect-pixel placeholder while %s', + (status) => { + expect( + requiresGenerationDeleteConfirmation( + durablePerfectPixelDialog('perfect-pixel-confirm', status), + ), + ).toBe(false); + }, + ); + + it('still asks for delete confirmation on an ordinary generating placeholder', () => { + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-1', + ...createDialog('generate', '一只猫'), + status: 'generating', + }), + ).toBe(true); + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-2', + ...createDialog('generate', '一只猫'), + status: 'failed', + }), + ).toBe(false); + }); + + it('drops unsettled durable perfect-pixel operations together with their source layer', () => { const { result } = renderHook(() => useCanvasGenerationDialogs()); const dialogId = 'perfect-pixel-durable'; - const submittedAt = 1_700_000_000_000; act(() => { result.current.restoreCanvasGenerationDialogs([ - { - id: dialogId, - mode: 'quick-edit', - prompt: '完美像素', - status: 'failed', - composerOpen: true, - sourceLayerId: 'layer-source', - perfectPixelOperation: { - version: 1, - kind: 'perfect-pixel', - operationId: dialogId, - taskId: `pixel-art-snap-${dialogId}`, - request: { - sourceImageSrc: 'generated-images/editor/source.png', - projectId: 'project-1', - canvasCompletion: { - dialogId, - title: '源图 · 完美像素', - placeholder: { - x: 0, - y: 0, - width: 320, - height: 240, - originalWidth: 320, - originalHeight: 240, - }, - }, - }, - submittedAt, - reconcileUntil: submittedAt + 75_000, - }, - }, + durablePerfectPixelDialog(dialogId, 'pending-confirmation'), ]); }); - act(() => { - result.current.removeCanvasGenerationDialogById(dialogId); result.current.removeCanvasGenerationDialogsByLayerId('layer-source'); }); - expect(result.current.activeCanvasGenerationDialog).toEqual( - expect.objectContaining({ - id: dialogId, - status: 'failed', - perfectPixelOperation: expect.objectContaining({ - operationId: dialogId, - }), - }), - ); - act(() => { - result.current.updateCanvasGenerationDialogById(dialogId, (dialog) => ({ - ...dialog, - status: 'idle', - })); - result.current.removeCanvasGenerationDialogById(dialogId); - }); expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index ae9215990..d0f19329d 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -24,15 +24,18 @@ export type CanvasGenerationDialogDraft = Omit< id?: string; }; -export function isUnsettledPerfectPixelOperationDialog( +/** + * 中文注释:删除生成占位前是否需要二次确认。 + * + * 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素 + * `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常 + * 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。 + * 所以完美像素占位在任何状态都直接删,不额外解释。 + */ +export function requiresGenerationDeleteConfirmation( dialog: CanvasGenerationDialogState, ) { - return ( - Boolean(dialog.perfectPixelOperation) && - (dialog.status === 'generating' || - dialog.status === 'pending-confirmation' || - dialog.status === 'failed') - ); + return dialog.status === 'generating' && !dialog.perfectPixelOperation; } function withGenerationTimestamps( @@ -227,11 +230,13 @@ export function useCanvasGenerationDialogs({ [], ); + // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 + // ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在 + // 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利, + // 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。 const removeCanvasGenerationDialogById = useCallback( (dialogId: string) => { - updateCanvasGenerationDialogById(dialogId, (dialog) => - isUnsettledPerfectPixelOperationDialog(dialog) ? dialog : null, - ); + updateCanvasGenerationDialogById(dialogId, () => null); }, [updateCanvasGenerationDialogById], ); @@ -312,9 +317,8 @@ export function useCanvasGenerationDialogs({ const removeCanvasGenerationDialogsByLayerId = useCallback( (targetLayerId: string) => { const keepDialog = (dialog: CanvasGenerationDialogState) => - isUnsettledPerfectPixelOperationDialog(dialog) || - (dialog.sourceLayerId !== targetLayerId && - dialog.generatedLayerId !== targetLayerId); + dialog.sourceLayerId !== targetLayerId && + dialog.generatedLayerId !== targetLayerId; const currentDialog = generateDialogRef.current; const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 42c1222b9..f66197146 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -3373,7 +3373,9 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog').textContent).toBe('-'); }); - it('keeps the durable operation when deletion is attempted and applies a later GET result', async () => { + // 中文注释:删除占位不撤销在途请求。结果照常落库,但既然用户已经把占位拿掉,就不再回填画布, + // 只提示结果已进素材库。 + it('deletes the durable operation and reports a later applied GET result as asset-only', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; let rejectPerfectPixel: ((error: unknown) => void) | undefined; @@ -3408,22 +3410,17 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); + expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); rejectPerfectPixel?.(new Error('网络中断')); await waitFor(() => { - expect(applyProjectSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: 'project-1', - }), - { - type: 'perfect-pixel', - count: 1, - }, + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。', ); }); - expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); }); it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => { @@ -3794,7 +3791,9 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toBe('空闲'); }); - it('refuses to delete a durable in-flight operation and keeps a later known failure visible', async () => { + // 中文注释:占位属于用户文档,在途也允许删除——删除不撤销任何请求。占位没了之后,已知失败 + // 没有地方可挂,必须退回全局提示,不能静默。 + it('deletes a durable in-flight operation and reports the later known failure globally', async () => { let rejectPerfectPixel: ((error: unknown) => void) | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( () => @@ -3824,7 +3823,7 @@ describe('useImageCanvasGenerationWorkflow', () => { }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); + expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); // 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。 rejectPerfectPixel?.( @@ -3836,12 +3835,12 @@ describe('useImageCanvasGenerationWorkflow', () => { ); await waitFor(() => { - expect(screen.getByTestId('dialog-error').textContent).toBe( + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素画布占位不存在或尚未保存,请重试。', ); }); - expect(screen.getByTestId('dialog').textContent).toContain('failed'); - expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); + expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); @@ -3888,8 +3887,6 @@ describe('useImageCanvasGenerationWorkflow', () => { await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalled(); }); - fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); resolvePerfectPixel?.( createPerfectPixelResult( @@ -4142,6 +4139,9 @@ describe('useImageCanvasGenerationWorkflow', () => { expect( screen.getByTestId('perfect-pixel-operation-invalid').textContent, ).toBe('invalid'); + // 中文注释:标记失效必须同时丢弃快照,与 hydrateCanvasGenerationDialog 一致。两者并存会 + // 造出「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。 + expect(screen.getByTestId('perfect-pixel-operation').textContent).toBe('-'); expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。', ); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 6d6e5eed0..dbae9d7da 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -2781,17 +2781,27 @@ export function useImageCanvasGenerationWorkflow({ perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller); void (async () => { if (operation.request.projectId !== normalizedProjectId) { - updateCanvasGenerationDialogById(dialog.id, (current) => - isSameUnsettledPerfectPixelOperation(current, operation.operationId) - ? { - ...current, - status: 'failed' as const, - perfectPixelOperationInvalid: true, - errorMessage: - '完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。', - } - : current, - ); + updateCanvasGenerationDialogById(dialog.id, (current) => { + if ( + !isSameUnsettledPerfectPixelOperation( + current, + operation.operationId, + ) + ) { + return current; + } + // 中文注释:标记快照失效时必须同时丢弃 operation,与 hydrateCanvasGenerationDialog + // 的口径一致。两者并存会造出「重试按钮因 invalid 而消失、快照却还挂着」的矛盾态, + // 用户既重试不了也看不懂占位为什么还在。 + const { perfectPixelOperation: _discarded, ...rest } = current; + return { + ...rest, + status: 'failed' as const, + perfectPixelOperationInvalid: true, + errorMessage: + '完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。', + }; + }); return; } const verdict = await reconcilePerfectPixelProject( diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index d4669589b..f301c8547 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -756,8 +756,11 @@ describe('useImageCanvasLayerCommands', () => { ); }); + // 中文注释:未收口的完美像素 operation 不再豁免删除。删除占位不撤销任何在途请求,结果照常 + // 落库并进素材库;封锁换来的只是「结果自动回填画布」这一便利,代价是用户画布上出现删不掉 + // 的对象。快捷键删除因此与其它占位同等对待。 it.each(['generating', 'pending-confirmation', 'failed'] as const)( - 'keeps a selected durable perfect-pixel dialog as a complete no-op while %s', + 'deletes a selected durable perfect-pixel dialog while %s', (status) => { const dialog = createDurablePerfectPixelDialog( `perfect-pixel-${status}`, @@ -782,15 +785,18 @@ describe('useImageCanvasLayerCommands', () => { fireEvent.click(screen.getByRole('button', { name: '删除选中' })); - expect(screen.getByTestId('history').textContent).toBe('0'); - expect(screen.getByTestId('selection').textContent).toBe( - `${selectionId}:${selectionId}`, - ); - expect(screen.getByTestId('context').textContent).toBe('open'); - expect(screen.getByTestId('image-context-closed').textContent).toBe('0'); - expect(onCaptureCanvasHistory).not.toHaveBeenCalled(); + expect(screen.getByTestId('history').textContent).toBe('1'); + expect(onCaptureCanvasHistory).toHaveBeenCalledWith({ + type: 'delete-generation-result', + count: 1, + }); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + expect(screen.getByTestId('context').textContent).toBe('closed'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); - expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); + expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith( + dialog.id, + ); }, ); @@ -836,7 +842,7 @@ describe('useImageCanvasLayerCommands', () => { expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); }); - it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => { + it('deletes both layer and durable-dialog targets in a mixed selection', () => { const dialog = createDurablePerfectPixelDialog( 'perfect-pixel-pending', 'pending-confirmation', @@ -861,7 +867,8 @@ describe('useImageCanvasLayerCommands', () => { fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(screen.getByTestId('layers').textContent).not.toContain('first'); - expect(screen.getByTestId('selection').textContent).toBe('third:third'); + // 中文注释:混合选择里存在占位目标时统一清空选择,计数沿用既有口径——只数图层。 + expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(screen.getByTestId('history').textContent).toBe('1'); expect(onCaptureCanvasHistory).toHaveBeenCalledWith({ type: 'delete-image', @@ -869,6 +876,7 @@ describe('useImageCanvasLayerCommands', () => { }); expect(onDeleteLayerSideEffects).toHaveBeenCalledTimes(1); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); - expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); + expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledTimes(1); + expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith(dialog.id); }); }); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index cede3edd6..8a2aed682 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -37,9 +37,6 @@ import { getCanvasGenerationDialogIdFromSelectionId, getSelectedLayerIds, } from './ImageCanvasSelectionModel'; -import { - isUnsettledPerfectPixelOperationDialog, -} from './useCanvasGenerationDialogs'; import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow'; type LayerCommandsOptions = { @@ -703,11 +700,7 @@ export function useImageCanvasLayerCommands({ .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) .filter((dialogId) => - canvasGenerationDialogs.some( - (dialog) => - dialog.id === dialogId && - !isUnsettledPerfectPixelOperationDialog(dialog), - ), + canvasGenerationDialogs.some((dialog) => dialog.id === dialogId), ); if (!targetLayerIds.length && !targetDialogIds.length) { return;