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

失效素材库删除关联图层的撤销与恢复记录
为修改图片草稿补充稳定标识并保护未提交状态
同步持久化素材类型撤销恢复并阻止旧请求回写
补充历史模型、集成回归和专题文档
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
@@ -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,