void;
onFitLayers: () => void;
onUndoCanvasChange: () => void;
- onRedoCanvasChange: () => void;
onToggleZoomMenu: () => void;
onCloseZoomMenu: () => void;
onToggleBackgroundSettings: () => void;
@@ -234,7 +235,7 @@ export function ImageCanvasStageView({
contextShouldShowLayer,
contextShouldUnlockLayer,
canUndo,
- canRedo,
+ undoFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -303,7 +304,6 @@ export function ImageCanvasStageView({
onUpdateScaleFromCenter,
onFitLayers,
onUndoCanvasChange,
- onRedoCanvasChange,
onToggleZoomMenu,
onCloseZoomMenu,
onToggleBackgroundSettings,
@@ -335,6 +335,15 @@ export function ImageCanvasStageView({
onDrop={isInteractionPaused ? undefined : onCanvasDrop}
onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu}
>
+ {undoFeedback ? (
+
+ {undoFeedback.text}
+
+ ) : null}
{uploadDropTarget === 'canvas' ? (
void }) {
{selectedLayerIds.join(',')}
{String(history.canUndo)}
- {String(history.canRedo)}
);
}
describe('useCanvasHistory', () => {
- it('captures, restores, and replays canvas history snapshots', () => {
+ it('captures and restores canvas history snapshots without redo', () => {
const clearDragState = vi.fn();
render();
@@ -186,7 +177,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(() => {
@@ -198,16 +189,6 @@ describe('useCanvasHistory', () => {
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
expect(screen.getByTestId('inactive').textContent).toBe('archived prompt');
expect(screen.getByTestId('selection').textContent).toBe('first');
- expect(screen.getByTestId('can-redo').textContent).toBe('true');
expect(clearDragState).toHaveBeenCalledTimes(1);
-
- act(() => {
- screen.getByRole('button', { name: 'redo' }).click();
- });
-
- expect(screen.getByTestId('layers').textContent).toBe('second: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');
});
});
diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts
index a9e1ae120..eb533b80c 100644
--- a/src/components/image-editor/useCanvasHistory.ts
+++ b/src/components/image-editor/useCanvasHistory.ts
@@ -3,11 +3,18 @@ import { type RefObject, useCallback, useRef, useState } from 'react';
import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
+ CanvasHistoryAction,
+ CanvasHistoryEntry,
CanvasHistorySnapshot,
CanvasLayer,
+ CanvasUndoResult,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
+import {
+ canRestoreCanvasHistorySnapshotWithoutContentLoss,
+ isProtectedCanvasHistoryAction,
+} from './ImageCanvasHistoryModel';
type CanvasHistoryRefs = {
layersRef: RefObject;
@@ -60,8 +67,7 @@ export function useCanvasHistory({
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
}) {
- const undoStackRef = useRef([]);
- const redoStackRef = useRef([]);
+ const undoStackRef = useRef([]);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
@@ -101,55 +107,54 @@ export function useCanvasHistory({
);
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 = [];
- }
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
- const undoCanvasChange = useCallback(() => {
- const previousSnapshot = undoStackRef.current.at(-1);
- if (!previousSnapshot) {
- return;
+ const undoCanvasChange = useCallback((): CanvasUndoResult => {
+ 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(),
- ];
- restoreCanvasHistorySnapshot(previousSnapshot);
- setHistoryVersion((version) => version + 1);
- }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
-
- const redoCanvasChange = useCallback(() => {
- const nextSnapshot = redoStackRef.current.at(-1);
- if (!nextSnapshot) {
- return;
- }
- redoStackRef.current = redoStackRef.current.slice(0, -1);
- undoStackRef.current = [
- ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
- getCanvasHistorySnapshot(),
- ];
- restoreCanvasHistorySnapshot(nextSnapshot);
+ restoreCanvasHistorySnapshot(previousEntry.snapshot);
setHistoryVersion((version) => version + 1);
+ return { status: 'success', action: previousEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
return {
canUndo: undoStackRef.current.length > 0,
- canRedo: redoStackRef.current.length > 0,
historyVersion,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
undoCanvasChange,
- redoCanvasChange,
};
}
diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts
index 1e4753de9..c039ba2fc 100644
--- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts
+++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts
@@ -13,6 +13,7 @@ import {
} from './ImageCanvasEditorModel';
import type {
AssetPointerDragState,
+ CanvasHistoryAction,
CanvasLayer,
CanvasViewport,
EditorAsset,
@@ -54,7 +55,7 @@ type UseImageCanvasAssetCanvasBridgeOptions = {
setHoveredLayerId: Dispatch>;
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: (
@@ -157,7 +158,7 @@ export function useImageCanvasAssetCanvasBridge({
},
{ applyCascadeOffset: position === undefined },
);
- captureCanvasHistory();
+ captureCanvasHistory({ type: 'add-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx
index ab1ee8477..240379ca1 100644
--- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx
+++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx
@@ -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>;
setActiveSidebarPanel: Dispatch>;
setMetadataLayer: Dispatch>;
@@ -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;
};
diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
index f226754fe..3b4fb3787 100644
--- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
@@ -1906,7 +1906,10 @@ describe('useImageCanvasGenerationWorkflow', () => {
},
});
});
- expect(applyProjectSnapshot).toHaveBeenCalledWith(project);
+ expect(applyProjectSnapshot).toHaveBeenCalledWith(project, {
+ type: 'split-atlas',
+ count: 1,
+ });
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
index f06958895..2c1495b3b 100644
--- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
+++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
@@ -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>;
setActiveSidebarPanel: Dispatch>;
setMetadataLayer: Dispatch>;
@@ -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;
};
@@ -1229,7 +1236,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,
@@ -1240,7 +1247,7 @@ export function useImageCanvasGenerationWorkflow({
}
selectSingleLayer(sourceLayerId);
if (options.fit !== false) {
- fitLayers([updatedLayer]);
+ fitLayers([updatedLayer], { captureHistory: false });
}
},
[
@@ -1581,11 +1588,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) {
@@ -1673,7 +1680,11 @@ export function useImageCanvasGenerationWorkflow({
await applyQueuedEditorGenerationProject(
result,
projectId,
- applyProjectSnapshot,
+ (project) =>
+ applyProjectSnapshot?.(project, {
+ type: 'remove-background',
+ count: 1,
+ }),
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
@@ -1746,7 +1757,10 @@ export function useImageCanvasGenerationWorkflow({
},
},
});
- applyProjectSnapshot(result.project);
+ applyProjectSnapshot(result.project, {
+ type: 'split-atlas',
+ count: 1,
+ });
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
index 5b7216ed1..13a5d6e66 100644
--- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
+++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
@@ -65,7 +65,6 @@ function KeyboardShortcutsHarness({
initialTool = 'select',
isInteractionPaused = false,
undoCanvasChange = vi.fn(),
- redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
deleteSelectedCanvasObjects = vi.fn(),
selectAllCanvasObjects = vi.fn(),
@@ -95,7 +94,6 @@ function KeyboardShortcutsHarness({
initialTool?: CanvasTool;
isInteractionPaused?: boolean;
undoCanvasChange?: () => void;
- redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
selectAllCanvasObjects?: () => void;
@@ -166,7 +164,6 @@ function KeyboardShortcutsHarness({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
- redoCanvasChange,
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
@@ -262,14 +259,10 @@ function KeyboardShortcutsHarness({
}
describe('useImageCanvasKeyboardShortcuts', () => {
- it('routes undo and redo shortcuts while ignoring editable inputs', () => {
+ it('routes undo while ignoring redo and editable inputs', () => {
const undoCanvasChange = vi.fn();
- const redoCanvasChange = vi.fn();
render(
- ,
+ ,
);
act(() => {
@@ -285,7 +278,7 @@ describe('useImageCanvasKeyboardShortcuts', () => {
shiftKey: true,
});
});
- expect(redoCanvasChange).toHaveBeenCalledTimes(1);
+ expect(undoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), {
diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
index 390a6ab2b..8c1068bf1 100644
--- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
+++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts
@@ -13,7 +13,6 @@ type UseImageCanvasKeyboardShortcutsOptions = {
generateDialogRef: RefObject;
selectedLayerIdRef: RefObject;
selectedLayerIdsRef?: RefObject;
- redoCanvasChange: () => void;
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
@@ -161,7 +160,6 @@ export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
- redoCanvasChange,
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
@@ -272,9 +270,7 @@ export function useImageCanvasKeyboardShortcuts({
!isEditableTarget(event)
) {
event.preventDefault();
- if (event.shiftKey) {
- redoCanvasChange();
- } else {
+ if (!event.shiftKey) {
undoCanvasChange();
}
return;
@@ -521,7 +517,6 @@ export function useImageCanvasKeyboardShortcuts({
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
- redoCanvasChange,
selectAllCanvasObjects,
selectedLayerIdRef,
selectedLayerIdsRef,
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts
index ce71fe2e1..720f0f78f 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.ts
+++ b/src/components/image-editor/useImageCanvasLayerCommands.ts
@@ -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>;
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()),
);
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
index 0db87d40a..cc8e1c428 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
+++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
@@ -10,6 +10,8 @@ import { describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
+ CanvasHistoryAction,
+ CanvasHistorySnapshot,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
@@ -118,6 +120,7 @@ function StageInteractionsHarness({
flushMinimapViewportDrag = vi.fn(),
onViewportInteractionStart = vi.fn(),
onViewportInteractionEnd = vi.fn(),
+ captureCanvasHistory = vi.fn(),
}: {
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
@@ -139,6 +142,10 @@ function StageInteractionsHarness({
flushMinimapViewportDrag?: () => void;
onViewportInteractionStart?: () => void;
onViewportInteractionEnd?: () => void;
+ captureCanvasHistory?: (
+ action: CanvasHistoryAction,
+ options?: { snapshot?: CanvasHistorySnapshot },
+ ) => void;
}) {
const canvasViewportRef = useRef(null);
const worldRef = useRef(null);
@@ -239,6 +246,15 @@ function StageInteractionsHarness({
onViewportInteractionEnd,
onCloseImageContextMenu: () =>
setImageMenuCloseCount((currentCount) => currentCount + 1),
+ getCanvasHistorySnapshot: () => ({
+ layers,
+ viewport,
+ generateDialog,
+ inactiveGenerateDialogs: [],
+ selectedLayerId,
+ selectedLayerIds,
+ }),
+ captureCanvasHistory,
});
const getViewportElement = () => {
const element = canvasViewportRef.current;
@@ -694,7 +710,12 @@ function StageInteractionsHarness({
describe('useImageCanvasStageInteractions', () => {
it('selects and drags multiple layers from stage pointer events', () => {
- render();
+ const captureCanvasHistory = vi.fn();
+ render(
+ ,
+ );
act(() => {
screen.getByRole('button', { name: '直接选第一层' }).click();
@@ -718,6 +739,11 @@ describe('useImageCanvasStageInteractions', () => {
expect(screen.getByTestId('layers').textContent).toContain(
'second:250.0,90.0',
);
+ expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
+ expect(captureCanvasHistory).toHaveBeenCalledWith(
+ { type: 'move-image', count: 2 },
+ expect.objectContaining({ snapshot: expect.any(Object) }),
+ );
act(() => {
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts
index 1b94876b2..ee2c946fa 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.ts
+++ b/src/components/image-editor/useImageCanvasStageInteractions.ts
@@ -12,6 +12,8 @@ import {
import type {
CanvasGenerationDialogState,
+ CanvasHistoryAction,
+ CanvasHistorySnapshot,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -112,6 +114,19 @@ type UseImageCanvasStageInteractionsOptions = {
onViewportInteractionStart?: () => void;
onViewportInteractionEnd?: () => void;
onCloseImageContextMenu: () => void;
+ getCanvasHistorySnapshot: () => CanvasHistorySnapshot;
+ captureCanvasHistory: (
+ action: CanvasHistoryAction,
+ options?: { snapshot?: CanvasHistorySnapshot },
+ ) => void;
+};
+
+type PendingDragHistory = {
+ snapshot: CanvasHistorySnapshot;
+ action: CanvasHistoryAction;
+ startClientX: number;
+ startClientY: number;
+ committed: boolean;
};
function focusCanvasInteractionTarget(target: HTMLElement) {
@@ -171,8 +186,11 @@ export function useImageCanvasStageInteractions({
onViewportInteractionStart,
onViewportInteractionEnd,
onCloseImageContextMenu,
+ getCanvasHistorySnapshot,
+ captureCanvasHistory,
}: UseImageCanvasStageInteractionsOptions) {
const dragStateRef = useRef(null);
+ const pendingDragHistoryRef = useRef(null);
const pendingClickCollapseRef = useRef(null);
const isShiftPressedRef = useRef(false);
const suppressNextLayerClickRef = useRef(false);
@@ -207,6 +225,7 @@ export function useImageCanvasStageInteractions({
flushMinimapViewportDrag();
}
dragStateRef.current = null;
+ pendingDragHistoryRef.current = null;
pendingClickCollapseRef.current = null;
setCanvasMarquee(null);
setIsPanning(false);
@@ -362,6 +381,7 @@ export function useImageCanvasStageInteractions({
event.preventDefault();
event.stopPropagation();
const pointer = getPointerClient(event);
+ const dragHistorySnapshot = getCanvasHistorySnapshot();
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
const layerDragStart = createLayerDragStart({
@@ -380,6 +400,16 @@ export function useImageCanvasStageInteractions({
updateGenerateDialogForLayerPointerDown(currentDialog, layer.id),
);
dragStateRef.current = layerDragStart.dragState;
+ pendingDragHistoryRef.current = {
+ snapshot: dragHistorySnapshot,
+ action: {
+ type: 'move-image',
+ count: layerDragStart.selectedLayerIds.length,
+ },
+ startClientX: pointer.x,
+ startClientY: pointer.y,
+ committed: false,
+ };
pendingClickCollapseRef.current = {
kind: 'layer',
pointerId: getPointerId(event),
@@ -392,6 +422,7 @@ export function useImageCanvasStageInteractions({
[
canvasViewportRef,
canvasGenerationDialogs,
+ getCanvasHistorySnapshot,
effectiveTool,
generateDialog?.mode,
isPickingCharacterSpecFromCanvas,
@@ -494,6 +525,7 @@ export function useImageCanvasStageInteractions({
event.preventDefault();
event.stopPropagation();
const pointer = getPointerClient(event);
+ const dragHistorySnapshot = getCanvasHistorySnapshot();
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
if (!isMultiSelectGesture) {
@@ -512,6 +544,16 @@ export function useImageCanvasStageInteractions({
setSelectedLayerId(frameDragStart.selectedLayerId);
setSelectedLayerIds(frameDragStart.selectedLayerIds);
dragStateRef.current = frameDragStart.dragState;
+ pendingDragHistoryRef.current = {
+ snapshot: dragHistorySnapshot,
+ action: {
+ type: 'move-generation-result',
+ count: frameDragStart.selectedLayerIds.length,
+ },
+ startClientX: pointer.x,
+ startClientY: pointer.y,
+ committed: false,
+ };
pendingClickCollapseRef.current = {
kind: 'generation-frame',
pointerId: getPointerId(event),
@@ -526,6 +568,7 @@ export function useImageCanvasStageInteractions({
canvasViewportRef,
canvasGenerationDialogs,
effectiveTool,
+ getCanvasHistorySnapshot,
layers,
selectedLayerIds,
setSelectedLayerId,
@@ -597,6 +640,22 @@ export function useImageCanvasStageInteractions({
return;
}
+ const dragPointer = getPointerClient(event);
+ const pendingDragHistory = pendingDragHistoryRef.current;
+ if (
+ pendingDragHistory &&
+ !pendingDragHistory.committed &&
+ Math.hypot(
+ dragPointer.x - pendingDragHistory.startClientX,
+ dragPointer.y - pendingDragHistory.startClientY,
+ ) >= CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX
+ ) {
+ captureCanvasHistory(pendingDragHistory.action, {
+ snapshot: pendingDragHistory.snapshot,
+ });
+ pendingDragHistory.committed = true;
+ }
+
if (dragState.kind === 'pan') {
const pointer = getPointerClient(event);
setViewport(moveViewportFromPan(dragState, pointer));
@@ -669,6 +728,7 @@ export function useImageCanvasStageInteractions({
},
[
canvasMarquee,
+ captureCanvasHistory,
canvasViewportRef,
canvasGenerationDialogs,
layers,
@@ -729,6 +789,7 @@ export function useImageCanvasStageInteractions({
flushMinimapViewportDrag();
}
dragStateRef.current = null;
+ pendingDragHistoryRef.current = null;
setIsPanning(false);
setSnapGuide(null);
if (dragState.kind === 'pan' || dragState.kind === 'minimap') {
diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
index efa115b08..30e2b669c 100644
--- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
@@ -178,6 +178,7 @@ function UploadWorkflowHarness({
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
+ captureCanvasHistory: vi.fn(),
selectSingleLayer: setSelectedLayerId,
});
diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts
index 0c005d430..d73d0fecc 100644
--- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts
+++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts
@@ -21,6 +21,7 @@ import {
resolveUploadAssetFolder,
} from './ImageCanvasAssetLibraryModel';
import type {
+ CanvasHistoryAction,
CanvasLayer,
CanvasMediaType,
CanvasTool,
@@ -97,6 +98,7 @@ type UseImageCanvasUploadWorkflowOptions = {
references: CharacterReferenceImage[],
) => void;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
+ captureCanvasHistory: (action: CanvasHistoryAction) => void;
selectSingleLayer: (layerId: string | null) => void;
};
@@ -134,6 +136,7 @@ export function useImageCanvasUploadWorkflow({
setQuickEditPanel,
appendUiAssetExtractionReferences,
appendCanvasLayersWithResources,
+ captureCanvasHistory,
selectSingleLayer,
}: UseImageCanvasUploadWorkflowOptions) {
const uploadInputRef = useRef(null);
@@ -1021,6 +1024,7 @@ export function useImageCanvasUploadWorkflow({
});
if (options.addToCanvas) {
+ captureCanvasHistory({ type: 'upload-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
}
@@ -1112,6 +1116,7 @@ export function useImageCanvasUploadWorkflow({
[
activeUploadFolderId,
appendCanvasLayersWithResources,
+ captureCanvasHistory,
assetFolders,
canvasSize,
openEditorLoginModal,
diff --git a/src/components/image-editor/useImageCanvasViewportControls.ts b/src/components/image-editor/useImageCanvasViewportControls.ts
index 9cbf6b30c..4e5d0ba1d 100644
--- a/src/components/image-editor/useImageCanvasViewportControls.ts
+++ b/src/components/image-editor/useImageCanvasViewportControls.ts
@@ -117,7 +117,10 @@ export function useImageCanvasViewportControls({
);
const fitLayers = useCallback(
- (targetLayers: CanvasLayer[] = layers) => {
+ (
+ targetLayers: CanvasLayer[] = layers,
+ options: { captureHistory?: boolean } = {},
+ ) => {
const nextViewport = fitViewportToLayers({
layers: targetLayers,
canvasSize,
@@ -126,7 +129,9 @@ export function useImageCanvasViewportControls({
return;
}
- captureCanvasHistory();
+ if (options.captureHistory !== false) {
+ captureCanvasHistory();
+ }
setViewport(nextViewport);
},
[captureCanvasHistory, canvasSize, layers],
diff --git a/src/index.css b/src/index.css
index 86b43d828..6894f4a08 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5044,6 +5044,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
cursor: grab;
}
+.image-canvas-editor__undo-toast {
+ position: absolute;
+ top: 1rem;
+ left: 50%;
+ z-index: 73;
+ max-width: min(32rem, calc(100% - 2rem));
+ transform: translateX(-50%);
+ pointer-events: none;
+}
+
.image-canvas-editor__world {
position: absolute;
left: 0;