完善图片画布撤销保护与操作提示 (#89)

为画布历史记录补充操作类型和内容消失保护

增加撤销成功、恢复成功及阻止提示并在三秒后隐藏

补充拖动、上传、生成和替换等操作的历史语义

保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈

补充撤销与恢复规则的定向测试和说明文档

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89
Co-authored-by: oj-afraid-student <1373241747@qq.com>
Co-committed-by: oj-afraid-student <1373241747@qq.com>
This commit was merged in pull request #89.
This commit is contained in:
2026-07-20 15:19:48 +08:00
committed by 段舒康
parent a5669602c0
commit eb608ac6f2
36 changed files with 2148 additions and 144 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 () => {
@@ -163,6 +163,7 @@ function createStageProps(): ImageCanvasStageViewProps {
contextShouldUnlockLayer: false,
canUndo: false,
canRedo: false,
historyFeedback: null,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -274,6 +274,63 @@ export type CanvasHistorySnapshot = {
selectedLayerIds: string[];
};
export type CanvasHistoryActionType =
| 'move-image'
| 'move-generation-result'
| 'delete-image'
| 'delete-generation-result'
| 'cut-image'
| 'paste-image'
| 'duplicate-image'
| 'add-image'
| 'upload-image'
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'split-atlas'
| 'replace-image'
| 'show-image'
| 'hide-image'
| 'change-layer-order'
| 'group-images'
| 'ungroup-images'
| 'lock-image'
| 'unlock-image'
| 'flip-image'
| 'change-asset-kind'
| 'change-viewport';
export type CanvasHistoryAction = {
type: CanvasHistoryActionType;
count?: number;
};
export type CanvasHistoryEntry = {
snapshot: CanvasHistorySnapshot;
action: CanvasHistoryAction;
createdAt: number;
};
export type CanvasHistoryApplyResult =
| {
status: 'success';
action: CanvasHistoryAction;
}
| {
status: 'blocked';
action: CanvasHistoryAction;
reason: 'content-may-disappear';
}
| {
status: 'empty';
};
export type CanvasHistoryFeedback = {
id: number;
tone: 'neutral' | 'warning';
text: string;
};
export type CanvasClipboard = {
layers: CanvasLayer[];
mode: 'copy' | 'cut';
@@ -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 />);
@@ -2624,14 +2704,14 @@ describe('ImageCanvasEditorView', () => {
).toBeNull();
});
it('undoes and redoes canvas layer changes from the panel controls', async () => {
it('blocks panel undo when an added image would disappear', async () => {
render(<ImageCanvasEditorView />);
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
@@ -2650,19 +2730,28 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByText('已撤销:删除图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '重做' }));
});
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法恢复:删除图片操作,可能会使图片消失'),
).toBeTruthy();
});
it('supports undo and redo keyboard shortcuts inside the editor', async () => {
it('blocks protected keyboard undo without creating a restore entry', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
@@ -2671,10 +2760,15 @@ describe('ImageCanvasEditorView', () => {
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
});
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(
screen.getByText('无法撤销:添加图片操作,可能会使图片消失'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
true,
);
await act(async () => {
fireEvent.keyDown(window, {
@@ -2686,4 +2780,33 @@ describe('ImageCanvasEditorView', () => {
});
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
});
it('restores a safely undone move from the panel and keyboard shortcut', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
});
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(screen.getByText('已撤销:移动图片操作')).toBeTruthy();
expect(screen.getByRole('button', { name: '恢复' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
expect(screen.getByText('已恢复:移动图片操作')).toBeTruthy();
});
});
@@ -44,6 +44,8 @@ import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryFeedback,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -59,6 +61,7 @@ import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
@@ -307,7 +310,16 @@ export function ImageCanvasEditorView({
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
const captureCanvasHistoryRef = useRef<() => void>(() => {});
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);
@@ -430,7 +442,7 @@ export function ImageCanvasEditorView({
>(null);
const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false);
const captureViewportHistory = useCallback(() => {
captureCanvasHistoryRef.current();
captureCanvasHistoryRef.current({ type: 'change-viewport' });
}, []);
const {
viewport,
@@ -638,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,
@@ -645,6 +662,7 @@ export function ImageCanvasEditorView({
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
assetFolders,
@@ -765,18 +783,69 @@ 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(() => {
if (!historyFeedback) {
return;
}
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [historyFeedback]);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `${actionLabel}使`
: `${actionLabel}`,
}));
}, [undoCanvasChange]);
const handleRedoCanvasChange = useCallback(() => {
const result = redoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `${actionLabel}使`
: `${actionLabel}`,
}));
}, [redoCanvasChange]);
const selectSingleLayer = useCallback(
(layerId: string | null) => {
setSelectedLayerId(layerId);
@@ -1100,11 +1169,15 @@ export function ImageCanvasEditorView({
setEditorProjectContextId(projectId);
}, [projectId, setEditorProjectContextId]);
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
(
project: EditorProjectSnapshot,
action: CanvasHistoryAction = { type: 'generate-image', count: 1 },
) => {
captureCanvasHistory(action);
applyProjectSnapshot(project);
void refreshAssetLibrary();
},
[applyProjectSnapshot, refreshAssetLibrary],
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
const handleEditorAgentCanvasRefreshRequested = useCallback(() => {
if (!projectId) {
@@ -1123,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,
@@ -1140,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
@@ -1156,6 +1238,7 @@ export function ImageCanvasEditorView({
},
[projectId],
);
persistRestoredAssetKindRef.current = persistUpdatedLayerResource;
const {
assetExportStatus,
isExportingAssets,
@@ -1199,6 +1282,7 @@ export function ImageCanvasEditorView({
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
captureCanvasHistory,
selectSingleLayer,
});
const generationSurface = useImageCanvasGenerationSurface({
@@ -1619,6 +1703,8 @@ export function ImageCanvasEditorView({
onViewportInteractionStart: beginViewportInteraction,
onViewportInteractionEnd: endViewportInteraction,
onCloseImageContextMenu: () => setImageContextMenu(null),
getCanvasHistorySnapshot,
captureCanvasHistory,
});
resetCanvasInteractionStateRef.current = clearActiveInteraction;
useEffect(() => {
@@ -1662,7 +1748,7 @@ export function ImageCanvasEditorView({
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory();
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
@@ -1707,7 +1793,7 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'change-asset-kind', count: 1 });
const nextLayer = {
...targetLayer,
assetKind,
@@ -1775,7 +1861,12 @@ export function ImageCanvasEditorView({
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: targetLayerIds.length
? 'move-image'
: 'move-generation-result',
count: targetLayerIds.length || targetDialogIds.length,
});
if (targetLayerIds.length) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
@@ -1845,8 +1936,8 @@ export function ImageCanvasEditorView({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
undoCanvasChange: handleUndoCanvasChange,
redoCanvasChange: handleRedoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
selectAllCanvasObjects,
@@ -2134,6 +2225,7 @@ export function ImageCanvasEditorView({
contextShouldUnlockLayer,
canUndo,
canRedo,
historyFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -2208,8 +2300,8 @@ export function ImageCanvasEditorView({
onCloseImageContextMenu: () => setImageContextMenu(null),
onUpdateScaleFromCenter: updateScaleFromCenter,
onFitLayers: fitLayers,
onUndoCanvasChange: undoCanvasChange,
onRedoCanvasChange: redoCanvasChange,
onUndoCanvasChange: handleUndoCanvasChange,
onRedoCanvasChange: handleRedoCanvasChange,
onToggleZoomMenu: toggleZoomMenu,
onCloseZoomMenu: closeZoomMenu,
onToggleBackgroundSettings: toggleBackgroundSettings,
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistorySnapshot,
CanvasLayer,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
const CANVAS_HISTORY_ACTION_LABELS: Record<
CanvasHistoryAction['type'],
string
> = {
'move-image': '移动图片',
'move-generation-result': '移动生成结果',
'delete-image': '删除图片',
'delete-generation-result': '删除生成结果',
'cut-image': '剪切图片',
'paste-image': '粘贴图片',
'duplicate-image': '复制图片',
'add-image': '添加图片',
'upload-image': '上传图片',
'generate-image': '生成图片',
'expand-image': '扩展图片',
'remove-background': '移除背景',
'split-atlas': '拆分图集',
'replace-image': '替换图片',
'show-image': '显示图片',
'hide-image': '隐藏图片',
'change-layer-order': '调整图片层级',
'group-images': '组合图片',
'ungroup-images': '取消组合',
'lock-image': '锁定图片',
'unlock-image': '解锁图片',
'flip-image': '翻转图片',
'change-asset-kind': '修改素材类型',
'change-viewport': '调整画布视图',
};
const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
CanvasHistoryAction['type']
>([
'paste-image',
'duplicate-image',
'add-image',
'upload-image',
'generate-image',
'expand-image',
'remove-background',
'split-atlas',
'replace-image',
]);
export function formatCanvasHistoryAction(action: CanvasHistoryAction): string {
return CANVAS_HISTORY_ACTION_LABELS[action.type];
}
export function isProtectedCanvasHistoryAction(
action: CanvasHistoryAction,
): boolean {
return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type);
}
function hasSameStableMediaReference(
current: {
src: string;
objectKey?: string | null;
assetObjectId?: string | null;
},
target: {
src: string;
objectKey?: string | null;
assetObjectId?: string | null;
},
): boolean {
if (current.objectKey && target.objectKey) {
return current.objectKey === target.objectKey;
}
if (current.assetObjectId && target.assetObjectId) {
return current.assetObjectId === target.assetObjectId;
}
return current.src === target.src;
}
function hasSameImageSequenceFrames(
current: CanvasLayer,
target: CanvasLayer,
): boolean {
const currentFrames = current.imageSequenceFrames ?? [];
const targetFrames = target.imageSequenceFrames ?? [];
if (currentFrames.length !== targetFrames.length) {
return false;
}
return currentFrames.every((currentFrame, index) => {
const targetFrame = targetFrames[index];
return (
targetFrame !== undefined &&
currentFrame.frameIndex === targetFrame.frameIndex &&
hasSameStableMediaReference(
{
src: currentFrame.imageSrc,
objectKey: currentFrame.objectKey,
assetObjectId: currentFrame.assetObjectId,
},
{
src: targetFrame.imageSrc,
objectKey: targetFrame.objectKey,
assetObjectId: targetFrame.assetObjectId,
},
)
);
});
}
function hasSameLayerContent(
current: CanvasLayer,
target: CanvasLayer,
): boolean {
return (
(current.mediaType ?? 'image') === (target.mediaType ?? 'image') &&
hasSameStableMediaReference(current, target) &&
hasSameImageSequenceFrames(current, target)
);
}
function cloneDialogPlaceholder(
dialog: GenerateDialogState,
): GenerateDialogState['placeholder'] {
return dialog.placeholder ? { ...dialog.placeholder } : undefined;
}
function mergeLayerForHistoryRestore(
target: CanvasLayer,
current: CanvasLayer | undefined,
): CanvasLayer {
if (!current) {
return { ...target };
}
return {
...current,
x: target.x,
y: target.y,
zIndex: target.zIndex,
groupId: target.groupId,
assetKind: target.assetKind,
hidden: target.hidden,
locked: target.locked,
flipX: target.flipX,
flipY: target.flipY,
};
}
function mergeDialogPlaceholderForHistoryRestore(
target: GenerateDialogState,
current: GenerateDialogState,
): GenerateDialogState['placeholder'] {
if (!current.placeholder) {
return cloneDialogPlaceholder(target);
}
if (!target.placeholder) {
return cloneDialogPlaceholder(current);
}
return {
...current.placeholder,
x: target.placeholder.x,
y: target.placeholder.y,
};
}
function mergeDialogForHistoryRestore<
TTarget extends GenerateDialogState,
TCurrent extends GenerateDialogState,
>(target: TTarget, current: TCurrent | undefined): TTarget | TCurrent {
if (!current) {
return {
...target,
placeholder: cloneDialogPlaceholder(target),
};
}
return {
...current,
composerOpen: target.composerOpen,
placeholder: mergeDialogPlaceholderForHistoryRestore(target, current),
};
}
export function mergeCanvasHistorySnapshotForRestore({
current,
target,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
}): CanvasHistorySnapshot {
const currentLayerById = new Map(
current.layers.map((layer) => [layer.id, layer] as const),
);
const currentDialogById = new Map<string, GenerateDialogState>();
for (const dialog of current.inactiveGenerateDialogs) {
currentDialogById.set(dialog.id, dialog);
}
if (current.generateDialog?.id) {
currentDialogById.set(current.generateDialog.id, current.generateDialog);
}
const mergeTargetDialog = <T extends GenerateDialogState>(dialog: T): T =>
mergeDialogForHistoryRestore(
dialog,
dialog.id ? currentDialogById.get(dialog.id) : undefined,
) as T;
return {
...target,
layers: target.layers.map((layer) =>
mergeLayerForHistoryRestore(layer, currentLayerById.get(layer.id)),
),
generateDialog: target.generateDialog
? mergeTargetDialog(target.generateDialog)
: null,
inactiveGenerateDialogs: target.inactiveGenerateDialogs.map(
(dialog): CanvasGenerationDialogState => mergeTargetDialog(dialog),
),
};
}
export function canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
}): boolean {
const targetLayerById = new Map(
target.layers.map((layer) => [layer.id, layer] as const),
);
const preservesLayers = current.layers.every((currentLayer) => {
const targetLayer = targetLayerById.get(currentLayer.id);
if (!targetLayer) {
return false;
}
if (!currentLayer.hidden && targetLayer.hidden) {
return false;
}
return hasSameLayerContent(currentLayer, targetLayer);
});
if (!preservesLayers) {
return false;
}
const getDialogIds = (snapshot: CanvasHistorySnapshot): Set<string> =>
new Set(
[
snapshot.generateDialog?.id,
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
].filter((dialogId): dialogId is string => Boolean(dialogId)),
);
const targetDialogIds = getDialogIds(target);
return [...getDialogIds(current)].every((dialogId) =>
targetDialogIds.has(dialogId),
);
}
@@ -14,7 +14,7 @@ function renderPanelDock(
canvasBackgroundColor: '#f8fafc',
canvasBackgroundHexValue: '#f8fafc',
canUndo: true,
canRedo: false,
canRedo: true,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -83,17 +83,12 @@ describe('ImageCanvasPanelDockView', () => {
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
(
within(toolbar).getByRole('button', {
name: '重做',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(within(toolbar).getByRole('button', { name: '恢复' })).toBeTruthy();
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '恢复' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '画布 Agent' }),
@@ -104,6 +99,7 @@ describe('ImageCanvasPanelDockView', () => {
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onRedoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
expect(props.onToggleAgentConversation).toHaveBeenCalledTimes(1);
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
@@ -338,8 +338,8 @@ export function ImageCanvasPanelDockView({
onClick={onUndoCanvasChange}
/>
<EditorIconButton
label="重做"
title="重做"
label="恢复"
title="恢复上一次撤销"
icon={Redo2}
disabled={!canRedo}
onClick={onRedoCanvasChange}
@@ -16,7 +16,7 @@ export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
title: '编辑',
items: [
{ action: '撤销', keys: ['Ctrl', 'Z'], status: 'ready' },
{ action: '重做', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '恢复', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '全选画布元素', keys: ['Ctrl', 'A'], status: 'new' },
{ action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' },
{ action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' },
@@ -10,6 +10,7 @@ import type {
import type { ExternalGenerationTaskRecord } from '@/packages/shared/src';
import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
@@ -17,6 +18,7 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryFeedback,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -86,6 +88,7 @@ export type ImageCanvasStageViewProps = {
contextShouldUnlockLayer: boolean;
canUndo: boolean;
canRedo: boolean;
historyFeedback: CanvasHistoryFeedback | null;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
@@ -239,6 +242,7 @@ export function ImageCanvasStageView({
contextShouldUnlockLayer,
canUndo,
canRedo,
historyFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -339,6 +343,15 @@ export function ImageCanvasStageView({
onDrop={isInteractionPaused ? undefined : onCanvasDrop}
onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu}
>
{historyFeedback ? (
<PlatformRuntimeStatusToast
key={historyFeedback.id}
tone={historyFeedback.tone}
className="image-canvas-editor__undo-toast"
>
{historyFeedback.text}
</PlatformRuntimeStatusToast>
) : null}
{uploadDropTarget === 'canvas' ? (
<div
className="image-canvas-editor__upload-drop-overlay image-canvas-editor__upload-drop-overlay--canvas"
@@ -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),
]);
@@ -44,6 +50,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
mode: 'generate',
prompt: 'active prompt',
status: 'idle',
imageSize: '1K',
placeholder: {
x: 10,
y: 20,
@@ -71,8 +78,11 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
},
},
]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('first');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
'first',
);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
const [lastHistoryStatus, setLastHistoryStatus] = useState('empty');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
@@ -110,6 +120,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
setMetadataLayer: () => {},
resetCanvasInteractionState: onClearDrag,
},
effects: { persistRestoredAssetKind },
});
return (
@@ -117,20 +128,45 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<span data-testid="layers">
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
</span>
<span data-testid="layer-relations">
{layers
.map(
(layer) =>
`${layer.resourceId}:${layer.sourceResourceId ?? '-'}:${layer.sourceAssetId ?? '-'}`,
)
.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>
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
<span data-testid="dialog-state">
{generateDialog
? `${generateDialog.status}:${generateDialog.placeholder?.x ?? '-'}:${generateDialog.generatedLayerId ?? '-'}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationStartedAt ?? '-'}`
: '-'}
</span>
<span data-testid="dialog-size">
{generateDialog
? `${generateDialog.imageSize ?? '-'}:${generateDialog.placeholder?.width ?? '-'}:${generateDialog.placeholder?.height ?? '-'}:${generateDialog.placeholder?.originalWidth ?? '-'}:${generateDialog.placeholder?.originalHeight ?? '-'}`
: '-'}
</span>
<span data-testid="inactive">
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
</span>
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
<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={() => {
history.captureCanvasHistory();
history.captureCanvasHistory({ type: 'move-image', count: 1 });
}}
>
capture
@@ -138,25 +174,125 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button
type="button"
onClick={() => {
setLayers([createLayer('second', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setGenerateDialog({
id: 'dialog-next',
mode: 'spec',
prompt: 'next prompt',
status: 'idle',
history.captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
});
setInactiveGenerateDialogs([]);
setSelectedLayerId('second');
setSelectedLayerIds(['second']);
}}
>
capture-asset-kind
</button>
<button
type="button"
onClick={() => {
setLayers([createLayer('first', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setSelectedLayerId('first');
setSelectedLayerIds(['first']);
}}
>
mutate
</button>
<button
type="button"
onClick={() =>
setLayers([
{
...createLayer('first', 90),
resourceId: 'persisted-resource-first',
sourceResourceId: 'source-resource-late',
sourceAssetId: 'source-asset-late',
objectKey: 'uploads/first.png',
},
])
}
>
persist-and-move
</button>
<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={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: 'current prompt',
status: 'generating',
generatedLayerId: 'generated-layer-current',
generationReferences: [
{
id: 'current-reference',
label: 'current reference',
src: '/current-reference.png',
},
],
generationStartedAt: 100,
imageSize: '2K',
placeholder: currentDialog.placeholder
? {
...currentDialog.placeholder,
x: 90,
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
}
: undefined,
}
: currentDialog,
)
}
>
advance-dialog
</button>
<button type="button" onClick={() => setGenerateDialog(null)}>
delete-dialog
</button>
<button
type="button"
onClick={() => {
history.undoCanvasChange();
setLastHistoryStatus(history.undoCanvasChange().status);
}}
>
undo
@@ -164,7 +300,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button
type="button"
onClick={() => {
history.redoCanvasChange();
setLastHistoryStatus(history.redoCanvasChange().status);
}}
>
redo
@@ -174,7 +310,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
}
describe('useCanvasHistory', () => {
it('captures, restores, and replays canvas history snapshots', () => {
it('captures, undoes, and redoes safe canvas history snapshots', () => {
const clearDragState = vi.fn();
render(<HistoryHarness onClearDrag={clearDragState} />);
@@ -186,7 +322,7 @@ describe('useCanvasHistory', () => {
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('second:90');
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
act(() => {
@@ -204,10 +340,210 @@ describe('useCanvasHistory', () => {
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('second:90');
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
expect(screen.getByTestId('dialog').textContent).toBe('next prompt');
expect(screen.getByTestId('selection').textContent).toBe('second');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
expect(screen.getByTestId('can-undo').textContent).toBe('true');
});
it('blocks redo when replaying a deletion would remove canvas content', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:10');
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('blocked');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('can-redo').textContent).toBe('true');
});
it('clears redo history after a new canvas edit is captured', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
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: 'capture' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('false');
});
it('undoes dialog movement without reverting its generation lifecycle', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'advance-dialog' }).click();
});
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:10:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
});
it('restores a generation dialog that was deleted after capture', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete-dialog' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'idle:10:-:-:-',
);
});
it('undoes movement after source asset relation ids arrive asynchronously', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'persist-and-move' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:90');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('success');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('layer-relations').textContent).toBe(
'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);
});
});
+125 -33
View File
@@ -3,11 +3,19 @@ import { type RefObject, useCallback, useRef, useState } from 'react';
import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryApplyResult,
CanvasHistoryEntry,
CanvasHistorySnapshot,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
canRestoreCanvasHistorySnapshotWithoutContentLoss,
isProtectedCanvasHistoryAction,
mergeCanvasHistorySnapshotForRestore,
} from './ImageCanvasHistoryModel';
type CanvasHistoryRefs = {
layersRef: RefObject<CanvasLayer[]>;
@@ -22,9 +30,7 @@ type CanvasHistorySetters = {
setLayers: (layers: CanvasLayer[]) => void;
setViewport: (viewport: CanvasViewport) => void;
setGenerateDialog: (dialog: GenerateDialogState | null) => void;
setInactiveGenerateDialogs: (
dialogs: CanvasGenerationDialogState[],
) => void;
setInactiveGenerateDialogs: (dialogs: CanvasGenerationDialogState[]) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
};
@@ -35,6 +41,10 @@ type CanvasHistoryResetters = {
resetCanvasInteractionState: () => void;
};
type CanvasHistoryEffects = {
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
@@ -55,13 +65,15 @@ export function useCanvasHistory({
refs,
setters,
resetters,
effects,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
}) {
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
@@ -71,8 +83,9 @@ export function useCanvasHistory({
generateDialog: refs.generateDialogRef.current
? cloneGenerateDialog(refs.generateDialogRef.current)
: null,
inactiveGenerateDialogs:
refs.inactiveGenerateDialogsRef.current.map(cloneCanvasGenerationDialog),
inactiveGenerateDialogs: refs.inactiveGenerateDialogsRef.current.map(
cloneCanvasGenerationDialog,
),
selectedLayerId: refs.selectedLayerIdRef.current,
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
}),
@@ -80,66 +93,144 @@ export function useCanvasHistory({
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
setters.setLayers(snapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...snapshot.viewport });
(snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => {
const currentSnapshot = getCanvasHistorySnapshot();
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
current: currentSnapshot,
target: snapshot,
});
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...mergedSnapshot.viewport });
setters.setGenerateDialog(
snapshot.generateDialog
? cloneGenerateDialog(snapshot.generateDialog)
mergedSnapshot.generateDialog
? cloneGenerateDialog(mergedSnapshot.generateDialog)
: null,
);
setters.setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
mergedSnapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
);
setters.setSelectedLayerId(snapshot.selectedLayerId);
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
setters.setSelectedLayerId(mergedSnapshot.selectedLayerId);
setters.setSelectedLayerIds([...mergedSnapshot.selectedLayerIds]);
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 });
}
}
}
},
[resetters, setters],
[effects, getCanvasHistorySnapshot, resetters, setters],
);
const captureCanvasHistory = useCallback(
(options: { clearRedo?: boolean } = {}) => {
(
action: CanvasHistoryAction,
options: { snapshot?: CanvasHistorySnapshot } = {},
) => {
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: options.snapshot ?? getCanvasHistorySnapshot(),
action,
createdAt: Date.now(),
},
];
if (options.clearRedo !== false) {
redoStackRef.current = [];
}
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
const undoCanvasChange = useCallback(() => {
const previousSnapshot = undoStackRef.current.at(-1);
if (!previousSnapshot) {
return;
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) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
isProtectedCanvasHistoryAction(previousEntry.action) ||
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: previousEntry.snapshot,
})
) {
return {
status: 'blocked',
action: previousEntry.action,
reason: 'content-may-disappear',
};
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: currentSnapshot,
action: previousEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(previousSnapshot);
restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: previousEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const redoCanvasChange = useCallback(() => {
const nextSnapshot = redoStackRef.current.at(-1);
if (!nextSnapshot) {
return;
const redoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const nextEntry = redoStackRef.current.at(-1);
if (!nextEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: nextEntry.snapshot,
})
) {
return {
status: 'blocked',
action: nextEntry.action,
reason: 'content-may-disappear',
};
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
{
snapshot: currentSnapshot,
action: nextEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(nextSnapshot);
restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: nextEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
return {
@@ -149,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', () => {
@@ -13,6 +13,7 @@ import {
} from './ImageCanvasEditorModel';
import type {
AssetPointerDragState,
CanvasHistoryAction,
CanvasLayer,
CanvasViewport,
EditorAsset,
@@ -35,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 = {
@@ -54,7 +58,7 @@ type UseImageCanvasAssetCanvasBridgeOptions = {
setHoveredLayerId: Dispatch<SetStateAction<string | null>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
captureCanvasHistory: () => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
addUploadedFiles: (
@@ -69,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)),
@@ -112,6 +120,7 @@ export function useImageCanvasAssetLayerCleanup({
);
},
[
discardHistoryEntriesContainingLayer,
layers,
onDeleteLayerSideEffects,
setLayers,
@@ -157,7 +166,7 @@ export function useImageCanvasAssetCanvasBridge({
},
{ applyCascadeOffset: position === undefined },
);
captureCanvasHistory();
captureCanvasHistory({ type: 'add-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
@@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CharacterAnimationPanelState,
@@ -194,6 +195,9 @@ function SubmissionWorkflowHarness({
applyProjectSnapshot = undefined,
onQueuedGenerationTask = undefined,
onWalletBalanceMayHaveChanged = undefined,
captureCanvasHistory = () => undefined,
fitLayers = undefined,
onAppendCanvasLayers = undefined,
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
@@ -214,6 +218,12 @@ function SubmissionWorkflowHarness({
onWalletBalanceMayHaveChanged?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onWalletBalanceMayHaveChanged'];
captureCanvasHistory?: (action: CanvasHistoryAction) => void;
fitLayers?: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
onAppendCanvasLayers?: (nextLayers: CanvasLayer[]) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
@@ -230,7 +240,14 @@ function SubmissionWorkflowHarness({
null,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersMockRef = useRef(vi.fn());
const fitLayersRef = useRef(fitLayers);
fitLayersRef.current = fitLayers;
const fitLayersMockRef = useRef(
vi.fn(
(targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }) =>
fitLayersRef.current?.(targetLayers, options),
),
);
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
@@ -269,8 +286,11 @@ function SubmissionWorkflowHarness({
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
appendCanvasLayersWithResources: (nextLayers) => {
onAppendCanvasLayers?.(nextLayers);
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory,
updateSourceLayer: (sourceLayerId, updater) =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
@@ -1358,13 +1378,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits edit dialogs beside the source and clears the modal state', async () => {
it('captures a local edit fallback when the project response has no snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '修改当前图片' }),
);
render(
<SubmissionWorkflowHarness
projectId="project-edit-size"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialDialog={{
mode: 'edit',
prompt: '',
@@ -1406,14 +1432,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
const callOrder: string[] = [];
const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => {
callOrder.push(`capture:${action.type}:${action.count}`);
});
const onAppendCanvasLayers = vi.fn(() => {
callOrder.push('append');
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 512, height: 512 }),
);
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-1',
mode: 'generate',
@@ -1444,10 +1488,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:placeholder:-',
);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['capture:generate-image:1', 'append']);
});
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
width: 1024,
@@ -1496,6 +1547,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
initialDialog={{
id: 'dialog-character',
mode: 'character',
@@ -1570,6 +1622,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
expect(captureCanvasHistory).not.toHaveBeenCalled();
});
it('submits audio generations with asset library context and upserts the returned asset', async () => {
@@ -2387,6 +2440,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
@@ -2404,6 +2459,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialLayers={[
createLayer({
assetKind: 'ui-design',
@@ -2440,6 +2497,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 2,
});
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
@@ -32,6 +32,7 @@ import {
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -285,13 +286,17 @@ type GenerationSubmissionWorkflowOptions = {
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
updateSourceLayer: (
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options?: { fit?: boolean; persist?: boolean },
) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
@@ -600,6 +605,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
@@ -616,6 +622,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
if (!nextLayers.length) {
return;
}
captureCanvasHistory({
type: 'generate-image',
count: nextLayers.length,
});
appendCanvasLayersWithResources(nextLayers);
nextLayers.forEach((layer) => {
const asset = layer.generatedAssetSnapshot;
@@ -624,7 +637,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
});
},
[appendCanvasLayersWithResources, upsertGeneratedAsset],
[
appendCanvasLayersWithResources,
captureCanvasHistory,
upsertGeneratedAsset,
],
);
const addGeneratedResultLayer = useCallback(
@@ -675,7 +692,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}));
}
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]);
fitLayers([options.sourceLayer, nextLayer], { captureHistory: false });
}
},
[
@@ -753,7 +770,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
}
}
fitLayers([sourceLayer, nextLayer]);
fitLayers([sourceLayer, nextLayer], { captureHistory: false });
},
[
addGeneratedLayersToCanvas,
@@ -1156,7 +1173,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
},
);
if (nextLayers.length) {
fitLayers([sourceLayer, ...nextLayers]);
fitLayers([sourceLayer, ...nextLayers], { captureHistory: false });
}
} catch (error) {
if (options.suppressAlert) {
@@ -23,6 +23,7 @@ import {
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -85,8 +86,11 @@ type ImageCanvasGenerationSurfaceOptions = {
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
captureCanvasHistory: () => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
@@ -98,7 +102,10 @@ type ImageCanvasGenerationSurfaceOptions = {
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
applyProjectSnapshot?: (
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -1911,7 +1911,10 @@ describe('useImageCanvasGenerationWorkflow', () => {
},
});
});
expect(applyProjectSnapshot).toHaveBeenCalledWith(project);
expect(applyProjectSnapshot).toHaveBeenCalledWith(project, {
type: 'split-atlas',
count: 1,
});
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
@@ -23,6 +23,7 @@ import {
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -610,8 +611,11 @@ type GenerationWorkflowOptions = {
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
captureCanvasHistory: () => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
@@ -622,7 +626,10 @@ type GenerationWorkflowOptions = {
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
applyProjectSnapshot?: (
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -1231,7 +1238,7 @@ export function useImageCanvasGenerationWorkflow({
return;
}
const updatedLayer = updater(sourceLayer);
captureCanvasHistory();
captureCanvasHistory({ type: 'replace-image', count: 1 });
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === sourceLayerId ? updatedLayer : layer,
@@ -1242,7 +1249,7 @@ export function useImageCanvasGenerationWorkflow({
}
selectSingleLayer(sourceLayerId);
if (options.fit !== false) {
fitLayers([updatedLayer]);
fitLayers([updatedLayer], { captureHistory: false });
}
},
[
@@ -1583,11 +1590,11 @@ export function useImageCanvasGenerationWorkflow({
sourceAssetId: null,
assetKind: cropExpandAssetKind,
};
captureCanvasHistory();
captureCanvasHistory({ type: 'expand-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
persistGeneratedAsset?.(nextLayer);
selectSingleLayer(nextLayer.id);
fitLayers([cropExpandSourceLayer, nextLayer]);
fitLayers([cropExpandSourceLayer, nextLayer], { captureHistory: false });
setCropExpandPanel(null);
setActiveSidebarPanel('layers');
} catch (error) {
@@ -1675,7 +1682,11 @@ export function useImageCanvasGenerationWorkflow({
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
(project) =>
applyProjectSnapshot?.(project, {
type: 'remove-background',
count: 1,
}),
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
@@ -1753,7 +1764,10 @@ export function useImageCanvasGenerationWorkflow({
},
},
});
applyProjectSnapshot(result.project);
applyProjectSnapshot(result.project, {
type: 'split-atlas',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
@@ -1945,6 +1959,7 @@ export function useImageCanvasGenerationWorkflow({
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
captureCanvasHistory,
updateSourceLayer,
selectSingleLayer,
fitLayers,
@@ -166,8 +166,8 @@ function KeyboardShortcutsHarness({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
redoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
selectAllCanvasObjects,
@@ -262,7 +262,7 @@ function KeyboardShortcutsHarness({
}
describe('useImageCanvasKeyboardShortcuts', () => {
it('routes undo and redo shortcuts while ignoring editable inputs', () => {
it('routes undo and redo while ignoring editable inputs', () => {
const undoCanvasChange = vi.fn();
const redoCanvasChange = vi.fn();
render(
@@ -276,6 +276,7 @@ describe('useImageCanvasKeyboardShortcuts', () => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
expect(redoCanvasChange).toHaveBeenCalledTimes(0);
act(() => {
fireEvent.keyDown(window, {
@@ -285,6 +286,7 @@ describe('useImageCanvasKeyboardShortcuts', () => {
shiftKey: true,
});
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
expect(redoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
@@ -13,8 +13,8 @@ type UseImageCanvasKeyboardShortcutsOptions = {
generateDialogRef: RefObject<GenerateDialogState | null>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef?: RefObject<string[]>;
redoCanvasChange: () => void;
undoCanvasChange: () => void;
redoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
selectAllCanvasObjects?: () => void;
@@ -161,8 +161,8 @@ export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
redoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
selectAllCanvasObjects,
@@ -10,6 +10,7 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import { readLayerImageBlob } from './ImageCanvasExportModel';
@@ -49,7 +50,7 @@ type LayerCommandsOptions = {
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
setImageContextMenu: (menu: null) => void;
setActiveTool: (tool: 'select') => void;
captureCanvasHistory: () => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
selectSingleLayer: (layerId: string | null) => void;
onDeleteLayerSideEffects: (targetLayerId: string) => void;
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
@@ -193,7 +194,7 @@ export function useImageCanvasLayerCommands({
if (!nextLayers.length) {
return false;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'paste-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
@@ -229,7 +230,7 @@ export function useImageCanvasLayerCommands({
copyLayerImageToSystemClipboard(clipboard.layers);
}
if (options.cut) {
captureCanvasHistory();
captureCanvasHistory({ type: 'cut-image', count: targetIds.length });
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
@@ -272,7 +273,7 @@ export function useImageCanvasLayerCommands({
copyLayerImageToSystemClipboard(clipboard.layers);
}
if (options.cut) {
captureCanvasHistory();
captureCanvasHistory({ type: 'cut-image', count: targetIds.length });
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
@@ -307,7 +308,7 @@ export function useImageCanvasLayerCommands({
if (!nextLayers.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
@@ -330,7 +331,7 @@ export function useImageCanvasLayerCommands({
if (!nextLayers.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
@@ -354,7 +355,7 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length });
setLayers((currentLayers) =>
updateCanvasLayersByIds(currentLayers, targetIds, updater),
);
@@ -374,7 +375,10 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: 'change-layer-order',
count: targetIds.length,
});
setLayers((currentLayers) =>
moveCanvasLayers(currentLayers, targetIds, mode),
);
@@ -394,7 +398,10 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: 'change-layer-order',
count: targetIds.length,
});
setLayers((currentLayers) =>
moveCanvasLayers(currentLayers, targetIds, mode),
);
@@ -415,7 +422,7 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'group-images', count: targetIds.length });
setLayers((currentLayers) =>
groupCanvasLayers(currentLayers, targetIds, createGroupId()),
);
@@ -432,7 +439,7 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'ungroup-images', count: targetIds.length });
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
closeContextMenus();
}, [
@@ -447,7 +454,13 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
const shouldHide = getCanvasLayersByIds(layers, targetIds).some(
(layer) => !layer.hidden,
);
captureCanvasHistory({
type: shouldHide ? 'hide-image' : 'show-image',
count: targetIds.length,
});
setLayers((currentLayers) =>
toggleCanvasLayersVisibility(currentLayers, targetIds),
);
@@ -456,6 +469,7 @@ export function useImageCanvasLayerCommands({
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
layers,
setLayers,
]);
@@ -464,7 +478,13 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
const shouldLock = getCanvasLayersByIds(layers, targetIds).some(
(layer) => !layer.locked,
);
captureCanvasHistory({
type: shouldLock ? 'lock-image' : 'unlock-image',
count: targetIds.length,
});
setLayers((currentLayers) =>
toggleCanvasLayersLock(currentLayers, targetIds),
);
@@ -473,6 +493,7 @@ export function useImageCanvasLayerCommands({
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
layers,
setLayers,
]);
@@ -482,7 +503,7 @@ export function useImageCanvasLayerCommands({
if (!targetIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'flip-image', count: targetIds.length });
setLayers((currentLayers) =>
flipCanvasLayers(currentLayers, targetIds, axis),
);
@@ -508,7 +529,12 @@ export function useImageCanvasLayerCommands({
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: targetLayerIds.length
? 'delete-image'
: 'delete-generation-result',
count: targetLayerIds.length || targetDialogIds.length,
});
if (targetLayerIds.length) {
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetLayerIds),
@@ -563,7 +589,7 @@ export function useImageCanvasLayerCommands({
}
setImageContextMenu(null);
setContextMenu(null);
captureCanvasHistory();
captureCanvasHistory({ type: 'delete-image', count: 1 });
setLayers((currentLayers) => {
const nextLayers = currentLayers.filter(
(layer) => layer.id !== targetLayerId,
@@ -612,7 +638,12 @@ export function useImageCanvasLayerCommands({
deleteLayerById(targetLayerIds[0] ?? null);
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: targetLayerIds.length
? 'delete-image'
: 'delete-generation-result',
count: targetLayerIds.length || targetDialogIds.length,
});
setImageContextMenu(null);
setContextMenu(null);
if (targetLayerIds.length) {
@@ -668,7 +699,10 @@ export function useImageCanvasLayerCommands({
if (!targetLayerIds.length) {
return;
}
captureCanvasHistory();
captureCanvasHistory({
type: 'group-images',
count: targetLayerIds.length,
});
setLayers((currentLayers) =>
groupCanvasLayers(currentLayers, targetLayerIds, createGroupId()),
);

Some files were not shown because too many files have changed in this diff Show More