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