修复图片画布撤销持久化边界

失效素材库删除关联图层的撤销与恢复记录
为修改图片草稿补充稳定标识并保护未提交状态
同步持久化素材类型撤销恢复并阻止旧请求回写
补充历史模型、集成回归和专题文档
This commit is contained in:
2026-07-20 15:03:15 +08:00
parent d0ea13bc8d
commit 4c7a88fdab
12 changed files with 424 additions and 14 deletions
@@ -119,12 +119,12 @@
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 图片画布布局历史不能回退生成任务状态或把资源 ID 回填当成换图
## 图片画布历史不能回退当前权威状态或复活后端已删素材
- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。
- 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 `sourceAssetId``sourceResourceId` 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 `generating` / 完成态。
- 处理:图层内容身份按对象存储 key、对象标识和媒体地址的稳定优先级比较,内部关联 ID 的补齐不参与内容消失判断。同 ID 图层以 current 为权威,只从历史覆盖 `x``y``zIndex``groupId``assetKind``hidden``locked``flipX``flipY`;current 的资源关联、内容、媒体、生成元数据、尺寸和标题全部保留。同 ID generation dialog 从 target 恢复 placeholder 的 `x` / `y` 和 active / inactive 槽位对应的 `composerOpen`current 的 `width` / `height` / `originalWidth` / `originalHeight`、当前参数、任务生命周期、提示词、参考图和结果保持一致。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。
- 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 `resourceId` / `sourceAssetId` / `sourceResourceId` 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护,以及阈值内指针抖动既不移动也不产生历史。
- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。素材库后端删除关联素材后,更早的移动快照还可能把已删图层重新加入并自动保存;修改素材类型虽然界面提示撤销成功,刷新后却可能从仍指向新类型的 resource 回弹。无稳定 ID 的“修改图片”草稿也可能被 target-null 快照直接关闭。
- 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 `sourceAssetId``sourceResourceId` 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 `generating` / 完成态;没有 ID 的 edit 草稿则根本不会进入存在性检查。外部素材删除不写画布历史,若不主动剪除包含关联图层的旧目标快照,target-only 图层会被当作正常撤销删除完整恢复。`assetKind` 的正式事实保存在项目 resource,历史只改内存字段而保留当前 `resourceId` 时无法跨刷新成立
- 处理:图层内容身份按对象存储 key、对象标识和媒体地址的稳定优先级比较,内部关联 ID 的补齐不参与内容消失判断。同 ID 图层以 current 为权威,只从历史覆盖 `x``y``zIndex``groupId``assetKind``hidden``locked``flipX``flipY`;current 的资源关联、内容、媒体、生成元数据、尺寸和标题全部保留。同 ID generation dialog 从 target 恢复 placeholder 的 `x` / `y` 和 active / inactive 槽位对应的 `composerOpen`current 的 `width` / `height` / `originalWidth` / `originalHeight`、当前参数、任务生命周期、提示词、参考图和结果保持一致;edit 草稿使用基于来源图层的稳定 ID。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。素材库删除必须用 `isLayerLinkedToAsset` matcher 同步过滤 undo / redo 中所有包含关联图层的 entry,即使图层只存在于历史中也要过滤;普通画布删除不调用该接口。assetKind undo / redo 每次重新创建匹配恢复类型的正式 resource,并按 layer 请求版本只接受最新响应。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。
- 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 `resourceId` / `sourceAssetId` / `sourceResourceId` 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护阈值内指针抖动既不移动也不产生历史、素材库删除后旧 undo / redo 无法复活关联 layer、edit 草稿不会被 target-null 快照吞掉,以及 assetKind 撤销与乱序 resource 响应后刷新仍保持最终类型
- 关联:`src/components/image-editor/ImageCanvasHistoryModel.ts``src/components/image-editor/useCanvasHistory.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasStageInteractions.ts``docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`
## 画板参考图 objectKey 必须先做归属校验
@@ -1,6 +1,6 @@
# 图片画布撤销、恢复范围与操作提示方案
更新时间:`2026-07-18`
更新时间:`2026-07-20`
## 产品规则
@@ -15,7 +15,7 @@
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材
恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。
@@ -27,8 +27,11 @@
- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId``sourceResourceId``sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。
- 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x``y``zIndex``groupId``assetKind``hidden``locked``flipX``flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。
- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。
- “修改图片”草稿使用基于来源图层的稳定 ID;撤销目标没有该草稿时必须阻止恢复,目标包含同 ID 旧草稿时保留当前未提交提示词和参数。
- 当前快照中已经没有对应 ID 的完整删除对象不执行上述字段级合并;撤销删除或剪切时仍可从目标快照全量恢复图层或生成对话框。
- 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。
- 素材库删除通过本次删除素材与历史图层的关联 matcher,同时过滤撤销栈和恢复栈;只删除目标快照中包含关联图层的记录,保留不可能复活该素材的其它画布历史。即使关联图层当前已经不在画布、只存在于历史快照中,也必须执行过滤。
- 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。
- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。
- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
@@ -49,3 +52,6 @@
12. 生成参数切换到 2K 或改变比例并同步占位尺寸后撤销位置,只恢复占位框 `x` / `y`,不得回退当前宽高、原始宽高或生成参数。
13. 拖动非活动生成框触发 active / inactive 互换后撤销,原 active 生成框恢复打开,原非活动生成框恢复关闭,任务状态与尺寸不回退。
14. 即时生成、本地结果和后端项目快照结果都只生成一条准确的生成历史,自动适合视图不会遮挡该历史。
15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。
16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。
17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。
@@ -830,6 +830,12 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy();
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
false,
);
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
await user.click(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
@@ -857,6 +863,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.queryByAltText('画布图片:账号素材A')).toBeNull();
expect(screen.queryByAltText('画布图片:账号素材B')).toBeNull();
});
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
@@ -12,11 +12,13 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
AuthUiContext,
createAuthValue,
createDeferred,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
@@ -2048,6 +2050,84 @@ describe('ImageCanvasEditorView', () => {
});
});
it('persists an undone asset kind and ignores the older pending response', async () => {
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => characterResource.promise)
.mockImplementationOnce(() => restoredResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ assetKind: null }),
);
await act(async () => {
restoredResource.resolve({
resourceId: 'resource-restored-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: null,
});
await restoredResource.promise;
});
await waitFor(() => {
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-restored-kind',
),
),
).toBe(true);
});
await act(async () => {
characterResource.resolve({
resourceId: 'resource-stale-character',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await characterResource.promise;
});
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character',
),
),
).toBe(false);
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
@@ -313,6 +313,13 @@ export function ImageCanvasEditorView({
const captureCanvasHistoryRef = useRef<
(action: CanvasHistoryAction) => void
>(() => {});
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
(matchesLayer: (layer: CanvasLayer) => boolean) => void
>(() => {});
const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>(
() => {},
);
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
@@ -643,6 +650,11 @@ export function ImageCanvasEditorView({
toggleMinimap,
} = useImageCanvasEditorChrome({ openEditorLoginModal });
canvasBackgroundColorRef.current = canvasBackgroundColor;
const discardAssetRelatedCanvasHistory = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) =>
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
[],
);
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
layers,
setLayers,
@@ -650,6 +662,7 @@ export function ImageCanvasEditorView({
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
assetFolders,
@@ -770,19 +783,30 @@ export function ImageCanvasEditorView({
}),
[],
);
const canvasHistoryEffects = useMemo(
() => ({
persistRestoredAssetKind: (layer: CanvasLayer) =>
persistRestoredAssetKindRef.current(layer),
}),
[],
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
effects: canvasHistoryEffects,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
discardCanvasHistoryEntriesContainingLayerRef.current =
discardCanvasHistoryEntriesContainingLayer;
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(null);
useEffect(() => {
@@ -1172,6 +1196,9 @@ export function ImageCanvasEditorView({
if (!imageSrc) {
return;
}
const requestVersion =
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
void createEditorProjectResource(projectId, {
imageSrc,
objectKey: layer.objectKey,
@@ -1189,6 +1216,12 @@ export function ImageCanvasEditorView({
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
}).then((resource) => {
if (
layerResourcePersistenceVersionRef.current.get(layer.id) !==
requestVersion
) {
return;
}
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
@@ -1205,6 +1238,7 @@ export function ImageCanvasEditorView({
},
[projectId],
);
persistRestoredAssetKindRef.current = persistUpdatedLayerResource;
const {
assetExportStatus,
isExportingAssets,
@@ -383,6 +383,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
expect(createEditDialogDraft(sourceLayer)).toEqual({
id: 'edit-dialog:layer-source',
mode: 'edit',
prompt: '原图提示',
status: 'idle',
@@ -36,9 +36,9 @@ import {
inferEditorImageSizeLabel,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
resizeGenerationPlaceholderToImageSelection,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -1244,6 +1244,7 @@ export function createEditDialogDraft(
sourceLayer.originalHeight,
);
return {
id: `edit-dialog:${sourceLayer.id}`,
mode: 'edit',
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
status: 'idle',
@@ -294,6 +294,43 @@ describe('ImageCanvasHistoryModel', () => {
).toBe(true);
});
it('protects an edit draft and preserves its latest unsaved state', () => {
const current = createSnapshot([createLayer({ x: 80 })]);
current.generateDialog = {
id: 'edit-dialog:layer-1',
mode: 'edit',
prompt: '当前未保存提示词',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-1',
imageModel: 'gpt-image-2',
};
const targetWithoutDraft = createSnapshot([createLayer({ x: 10 })]);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target: targetWithoutDraft,
}),
).toBe(false);
const targetWithOlderDraft = createSnapshot([createLayer({ x: 10 })]);
targetWithOlderDraft.generateDialog = {
...current.generateDialog,
prompt: '旧提示词',
};
const merged = mergeCanvasHistorySnapshotForRestore({
current,
target: targetWithOlderDraft,
});
expect(merged.generateDialog).toMatchObject({
id: 'edit-dialog:layer-1',
prompt: '当前未保存提示词',
composerOpen: true,
});
});
it('restores only position for an existing generation dialog', () => {
const target = createSnapshot([]);
target.generateDialog = {
@@ -29,7 +29,13 @@ function createLayer(id: string, x: number): CanvasLayer {
};
}
function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
function HistoryHarness({
onClearDrag,
persistRestoredAssetKind = () => {},
}: {
onClearDrag: () => void;
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([
createLayer('first', 10),
]);
@@ -114,6 +120,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
setMetadataLayer: () => {},
resetCanvasInteractionState: onClearDrag,
},
effects: { persistRestoredAssetKind },
});
return (
@@ -129,6 +136,11 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
)
.join(',')}
</span>
<span data-testid="layer-asset-kind">
{layers
.map((layer) => `${layer.resourceId}:${layer.assetKind ?? '-'}`)
.join(',')}
</span>
<span data-testid="viewport">
{viewport.x},{viewport.y},{viewport.scale}
</span>
@@ -150,6 +162,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<span data-testid="can-undo">{String(history.canUndo)}</span>
<span data-testid="can-redo">{String(history.canRedo)}</span>
<span data-testid="history-status">{lastHistoryStatus}</span>
<span data-testid="history-version">{history.historyVersion}</span>
<button
type="button"
onClick={() => {
@@ -158,6 +171,17 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
>
capture
</button>
<button
type="button"
onClick={() => {
history.captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
});
}}
>
capture-asset-kind
</button>
<button
type="button"
onClick={() => {
@@ -188,6 +212,44 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button type="button" onClick={() => setLayers([])}>
delete
</button>
<button
type="button"
onClick={() =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === 'first'
? {
...layer,
resourceId: 'resource-first-icon',
assetKind: 'icon',
}
: layer,
),
)
}
>
change-asset-kind
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'first',
)
}
>
discard-first-history
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'missing',
)
}
>
discard-missing-history
</button>
<button
type="button"
onClick={() =>
@@ -399,4 +461,89 @@ describe('useCanvasHistory', () => {
'persisted-resource-first:source-resource-late:source-asset-late',
);
});
it('discards undo and redo entries whose snapshots contain a deleted asset layer', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'discard-first-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('false');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('empty');
});
it('does not update history state when an invalidation matcher misses', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
const versionBeforeDiscard =
screen.getByTestId('history-version').textContent;
act(() => {
screen.getByRole('button', { name: 'discard-missing-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('true');
expect(screen.getByTestId('history-version').textContent).toBe(
versionBeforeDiscard,
);
});
it('persists asset kind changes restored by undo and redo', () => {
const persistRestoredAssetKind = vi.fn();
render(
<HistoryHarness
onClearDrag={() => {}}
persistRestoredAssetKind={persistRestoredAssetKind}
/>,
);
act(() => {
screen.getByRole('button', { name: 'capture-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'change-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:-',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({
id: 'first',
resourceId: 'resource-first-icon',
assetKind: undefined,
}),
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:icon',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({ assetKind: 'icon' }),
);
expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2);
});
});
@@ -41,6 +41,10 @@ type CanvasHistoryResetters = {
resetCanvasInteractionState: () => void;
};
type CanvasHistoryEffects = {
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
@@ -61,10 +65,12 @@ export function useCanvasHistory({
refs,
setters,
resetters,
effects,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
}) {
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
@@ -87,9 +93,10 @@ export function useCanvasHistory({
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
(snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => {
const currentSnapshot = getCanvasHistorySnapshot();
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
current: getCanvasHistorySnapshot(),
current: currentSnapshot,
target: snapshot,
});
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
@@ -107,8 +114,20 @@ export function useCanvasHistory({
resetters.setHoveredLayerId(null);
resetters.setMetadataLayer(null);
resetters.resetCanvasInteractionState();
if (action?.type === 'change-asset-kind') {
const currentLayerById = new Map(
currentSnapshot.layers.map((layer) => [layer.id, layer] as const),
);
for (const layer of mergedSnapshot.layers) {
const currentLayer = currentLayerById.get(layer.id);
if (currentLayer && currentLayer.assetKind !== layer.assetKind) {
effects?.persistRestoredAssetKind?.({ ...layer });
}
}
}
},
[getCanvasHistorySnapshot, resetters, setters],
[effects, getCanvasHistorySnapshot, resetters, setters],
);
const captureCanvasHistory = useCallback(
@@ -130,6 +149,25 @@ export function useCanvasHistory({
[getCanvasHistorySnapshot],
);
const discardCanvasHistoryEntriesContainingLayer = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) => {
const keepEntry = (entry: CanvasHistoryEntry) =>
!entry.snapshot.layers.some(matchesLayer);
const nextUndoStack = undoStackRef.current.filter(keepEntry);
const nextRedoStack = redoStackRef.current.filter(keepEntry);
if (
nextUndoStack.length === undoStackRef.current.length &&
nextRedoStack.length === redoStackRef.current.length
) {
return;
}
undoStackRef.current = nextUndoStack;
redoStackRef.current = nextRedoStack;
setHistoryVersion((version) => version + 1);
},
[],
);
const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const previousEntry = undoStackRef.current.at(-1);
if (!previousEntry) {
@@ -158,7 +196,7 @@ export function useCanvasHistory({
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(previousEntry.snapshot);
restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: previousEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
@@ -190,7 +228,7 @@ export function useCanvasHistory({
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(nextEntry.snapshot);
restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: nextEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
@@ -202,6 +240,7 @@ export function useCanvasHistory({
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
undoCanvasChange,
redoCanvasChange,
};
@@ -158,10 +158,14 @@ function AssetCleanupHarness({
}),
],
onDeleteLayerSideEffects = vi.fn(),
discardHistoryEntriesContainingLayer = vi.fn(),
}: {
deletedAssets?: EditorAsset[];
initialLayers?: CanvasLayer[];
onDeleteLayerSideEffects?: (layerId: string) => void;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
}) {
const [layers, setLayers] = useState(initialLayers);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
@@ -172,6 +176,7 @@ function AssetCleanupHarness({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
discardHistoryEntriesContainingLayer,
});
return (
@@ -249,10 +254,14 @@ describe('useImageCanvasAssetCanvasBridge', () => {
it('removes canvas layers linked to deleted assets and keeps unrelated selection', () => {
const onDeleteLayerSideEffects = vi.fn();
const discardHistoryEntriesContainingLayer = vi.fn();
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
discardHistoryEntriesContainingLayer={
discardHistoryEntriesContainingLayer
}
/>,
);
@@ -264,6 +273,45 @@ describe('useImageCanvasAssetCanvasBridge', () => {
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('selected-many').textContent).toBe('kept');
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('linked');
const matchesLayer =
discardHistoryEntriesContainingLayer.mock.calls[0]?.[0];
expect(discardHistoryEntriesContainingLayer).toHaveBeenCalledTimes(1);
expect(matchesLayer(createLayer({ sourceAssetId: 'asset-1' }))).toBe(true);
expect(
matchesLayer(
createLayer({
id: 'layer-other',
resourceId: 'resource-other',
src: 'data:image/png;base64,other',
objectKey: 'uploads/other.png',
assetObjectId: 'asset-object-other',
sourceResourceId: 'source-resource-other',
sourceAssetId: 'asset-other',
}),
),
).toBe(false);
});
it('invalidates matching history even when no linked layer is currently mounted', () => {
const discardHistoryEntriesContainingLayer = vi.fn();
render(
<AssetCleanupHarness
deletedAssets={[defaultAsset]}
initialLayers={[]}
discardHistoryEntriesContainingLayer={
discardHistoryEntriesContainingLayer
}
/>,
);
act(() => {
screen.getByRole('button', { name: '清理素材' }).click();
});
const matchesLayer =
discardHistoryEntriesContainingLayer.mock.calls[0]?.[0];
expect(discardHistoryEntriesContainingLayer).toHaveBeenCalledTimes(1);
expect(matchesLayer(createLayer({ sourceAssetId: 'asset-1' }))).toBe(true);
});
it('removes canvas layers linked by the asset source resource id', () => {
@@ -36,6 +36,9 @@ type UseImageCanvasAssetLayerCleanupOptions = {
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
onDeleteLayerSideEffects?: (layerId: string) => void;
discardHistoryEntriesContainingLayer?: (
matchesLayer: (layer: CanvasLayer) => boolean,
) => void;
};
type UseImageCanvasAssetCanvasBridgeOptions = {
@@ -70,12 +73,16 @@ export function useImageCanvasAssetLayerCleanup({
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects,
discardHistoryEntriesContainingLayer,
}: UseImageCanvasAssetLayerCleanupOptions) {
return useCallback(
(deletedAssets: EditorAsset[]) => {
if (!deletedAssets.length) {
return;
}
discardHistoryEntriesContainingLayer?.((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
);
const deletedLayerIds = layers
.filter((layer) =>
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
@@ -113,6 +120,7 @@ export function useImageCanvasAssetLayerCleanup({
);
},
[
discardHistoryEntriesContainingLayer,
layers,
onDeleteLayerSideEffects,
setLayers,