完善图片画布撤销保护与操作提示

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

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

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

移除重做按钮、快捷键和重做历史

补充撤销规则的定向测试和说明文档
This commit is contained in:
2026-07-17 19:07:31 +08:00
committed by kdletters
parent 47f3297e84
commit c3cefb8b96
28 changed files with 623 additions and 174 deletions
+1
View File
@@ -20,6 +20,7 @@
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)
- [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md)
- [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)
- [AI Web 工程 Runner 安全模型](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md)
@@ -0,0 +1,35 @@
# 图片画布撤销范围与操作提示方案
更新时间:`2026-07-17`
## 产品规则
- 用户通过画布“撤销”按钮或 `Ctrl+Z` 触发撤销。
- 撤销成功后,在画布工作区顶部居中显示 `已撤销:XX操作`3 秒后自动消失。
- 如果目标快照会删除当前图层、把当前可见图层重新隐藏,或者用旧素材替换当前素材,则不得恢复快照,并显示 `无法撤销:XX操作,可能会使图片消失`3 秒后自动消失。
- 被阻止的历史记录不得出栈,也不得自动跳过到更早记录;画布状态不变,因此不会触发项目自动保存。
- 图片画布不提供重做按钮、重做快捷键或重做历史栈。
## 操作边界
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整工具栏视口。删除与剪切允许撤销,是因为恢复目标只会让内容重新出现。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的操作必须被撤销安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布撤销历史。
## 技术实现
- 每条历史记录保存操作类型、操作前 `CanvasHistorySnapshot` 和创建时间,最多保留 60 条。
- 操作类型只负责生成用户提示;能否恢复由当前快照与目标快照的差异检查决定。
- 图层安全检查以稳定的 `layer.id` 判断当前图层是否仍存在,以媒体地址、对象存储标识和序列帧等字段组成内容签名,识别同 ID 图层的素材替换;仅刷新内部资源 ID 不视为图片消失。
- 位置、尺寸、层级、分组、锁定、翻转、标题和素材类型不进入内容签名,避免误拦截普通编辑。
- 鼠标拖动在按下时暂存操作前快照,位移超过点击阈值后只提交一条历史;单击不产生历史记录。
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
## 验收重点
1. 添加、上传、粘贴、复制、生成和替换图片后撤销,当前图片不消失且出现准确警告。
2. 删除、剪切、隐藏、移动、分组和层级调整可以正常撤销,并出现成功提示。
3. 被阻止后历史记录仍位于栈顶,连续撤销不会越过保护边界。
4. 一次鼠标拖动只产生一条历史,单击不产生历史。
5. 页面不存在重做按钮和可用的 `Ctrl+Shift+Z` 重做入口。
6. 提示在 3 秒后消失,连续提示按最后一次触发重新计时。
@@ -162,7 +162,7 @@ function createStageProps(): ImageCanvasStageViewProps {
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canUndo: false,
canRedo: false,
undoFeedback: null,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -227,7 +227,6 @@ function createStageProps(): ImageCanvasStageViewProps {
onUpdateScaleFromCenter: vi.fn(),
onFitLayers: vi.fn(),
onUndoCanvasChange: vi.fn(),
onRedoCanvasChange: vi.fn(),
onToggleZoomMenu: vi.fn(),
onCloseZoomMenu: vi.fn(),
onToggleBackgroundSettings: vi.fn(),
@@ -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 CanvasUndoResult =
| {
status: 'success';
action: CanvasHistoryAction;
}
| {
status: 'blocked';
action: CanvasHistoryAction;
reason: 'content-may-disappear';
}
| {
status: 'empty';
};
export type CanvasUndoFeedback = {
id: number;
tone: 'neutral' | 'warning';
text: string;
};
export type CanvasClipboard = {
layers: CanvasLayer[];
mode: 'copy' | 'cut';
@@ -2624,17 +2624,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(
'disabled',
true,
);
expect(screen.queryByRole('button', { name: '重做' })).toBeNull();
openAssetSidebar();
await act(async () => {
@@ -2650,19 +2647,19 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
});
expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull();
expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
'disabled',
false,
);
await act(async () => {
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();
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 and ignores the removed redo shortcut', async () => {
render(<ImageCanvasEditorView />);
openAssetSidebar();
@@ -2671,10 +2668,11 @@ 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();
await act(async () => {
fireEvent.keyDown(window, {
@@ -44,8 +44,10 @@ import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasUndoFeedback,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
@@ -59,6 +61,7 @@ import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
@@ -307,7 +310,9 @@ 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 resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
@@ -430,7 +435,7 @@ export function ImageCanvasEditorView({
>(null);
const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false);
const captureViewportHistory = useCallback(() => {
captureCanvasHistoryRef.current();
captureCanvasHistoryRef.current({ type: 'change-viewport' });
}, []);
const {
viewport,
@@ -763,16 +768,40 @@ export function ImageCanvasEditorView({
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
const [undoFeedback, setUndoFeedback] = useState<CanvasUndoFeedback | null>(
null,
);
useEffect(() => {
if (!undoFeedback) {
return;
}
const timer = window.setTimeout(() => setUndoFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [undoFeedback]);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setUndoFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `${actionLabel}使`
: `${actionLabel}`,
}));
}, [undoCanvasChange]);
const selectSingleLayer = useCallback(
(layerId: string | null) => {
setSelectedLayerId(layerId);
@@ -1096,11 +1125,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) {
@@ -1195,6 +1228,7 @@ export function ImageCanvasEditorView({
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
captureCanvasHistory,
selectSingleLayer,
});
const generationSurface = useImageCanvasGenerationSurface({
@@ -1614,6 +1648,8 @@ export function ImageCanvasEditorView({
onViewportInteractionStart: beginViewportInteraction,
onViewportInteractionEnd: endViewportInteraction,
onCloseImageContextMenu: () => setImageContextMenu(null),
getCanvasHistorySnapshot,
captureCanvasHistory,
});
resetCanvasInteractionStateRef.current = clearActiveInteraction;
useEffect(() => {
@@ -1657,7 +1693,7 @@ export function ImageCanvasEditorView({
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory();
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
@@ -1702,7 +1738,7 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
captureCanvasHistory();
captureCanvasHistory({ type: 'change-asset-kind', count: 1 });
const nextLayer = {
...targetLayer,
assetKind,
@@ -1770,7 +1806,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) =>
@@ -1840,8 +1881,7 @@ export function ImageCanvasEditorView({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
undoCanvasChange: handleUndoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
selectAllCanvasObjects,
@@ -2127,7 +2167,7 @@ export function ImageCanvasEditorView({
contextShouldShowLayer,
contextShouldUnlockLayer,
canUndo,
canRedo,
undoFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -2202,8 +2242,7 @@ export function ImageCanvasEditorView({
onCloseImageContextMenu: () => setImageContextMenu(null),
onUpdateScaleFromCenter: updateScaleFromCenter,
onFitLayers: fitLayers,
onUndoCanvasChange: undoCanvasChange,
onRedoCanvasChange: redoCanvasChange,
onUndoCanvasChange: handleUndoCanvasChange,
onToggleZoomMenu: toggleZoomMenu,
onCloseZoomMenu: closeZoomMenu,
onToggleBackgroundSettings: toggleBackgroundSettings,
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import type {
CanvasHistorySnapshot,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
canRestoreCanvasHistorySnapshotWithoutContentLoss,
formatCanvasHistoryAction,
isProtectedCanvasHistoryAction,
} from './ImageCanvasHistoryModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '图片',
src: '/image-1.png',
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function createSnapshot(layers: CanvasLayer[]): CanvasHistorySnapshot {
return {
layers,
viewport: { x: 0, y: 0, scale: 1 },
generateDialog: null,
inactiveGenerateDialogs: [],
selectedLayerId: null,
selectedLayerIds: [],
};
}
describe('ImageCanvasHistoryModel', () => {
it('allows restoring position and restoring a previously deleted layer', () => {
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: createSnapshot([createLayer({ x: 80 })]),
target: createSnapshot([createLayer({ x: 10 })]),
}),
).toBe(true);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: createSnapshot([createLayer({ resourceId: 'resource-new' })]),
target: createSnapshot([createLayer({ resourceId: 'resource-old' })]),
}),
).toBe(true);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: createSnapshot([]),
target: createSnapshot([createLayer()]),
}),
).toBe(true);
});
it('blocks removing, hiding, or replacing current canvas content', () => {
const current = createSnapshot([createLayer()]);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target: createSnapshot([]),
}),
).toBe(false);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target: createSnapshot([createLayer({ hidden: true })]),
}),
).toBe(false);
expect(
canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target: createSnapshot([
createLayer({ resourceId: 'resource-old', src: '/old.png' }),
]),
}),
).toBe(false);
});
it('formats action names without duplicating the 操作 suffix', () => {
expect(formatCanvasHistoryAction({ type: 'replace-image' })).toBe(
'替换图片',
);
expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe(
true,
);
expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false);
});
});
@@ -0,0 +1,98 @@
import type {
CanvasHistoryAction,
CanvasHistorySnapshot,
CanvasLayer,
} 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 getLayerContentSignature(layer: CanvasLayer): string {
return JSON.stringify({
src: layer.src,
mediaType: layer.mediaType ?? null,
objectKey: layer.objectKey ?? null,
assetObjectId: layer.assetObjectId ?? null,
sourceResourceId: layer.sourceResourceId ?? null,
sourceAssetId: layer.sourceAssetId ?? null,
previewVideoPath: layer.previewVideoPath ?? null,
imageSequenceFrames: layer.imageSequenceFrames ?? null,
});
}
export function canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
}): boolean {
const targetLayerById = new Map(
target.layers.map((layer) => [layer.id, layer] as const),
);
return current.layers.every((currentLayer) => {
const targetLayer = targetLayerById.get(currentLayer.id);
if (!targetLayer) {
return false;
}
if (!currentLayer.hidden && targetLayer.hidden) {
return false;
}
return (
getLayerContentSignature(currentLayer) ===
getLayerContentSignature(targetLayer)
);
});
}
@@ -14,7 +14,6 @@ function renderPanelDock(
canvasBackgroundColor: '#f8fafc',
canvasBackgroundHexValue: '#f8fafc',
canUndo: true,
canRedo: false,
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
@@ -24,7 +23,6 @@ function renderPanelDock(
minimapModel: null,
onFitLayers: vi.fn(),
onUndoCanvasChange: vi.fn(),
onRedoCanvasChange: vi.fn(),
onUpdateScaleFromCenter: vi.fn(),
onToggleZoomMenu: vi.fn(),
onCloseZoomMenu: vi.fn(),
@@ -83,13 +81,7 @@ describe('ImageCanvasPanelDockView', () => {
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
(
within(toolbar).getByRole('button', {
name: '重做',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(within(toolbar).queryByRole('button', { name: '重做' })).toBeNull();
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
@@ -3,7 +3,6 @@ import {
Layers,
Map as MapIcon,
MessageCircle,
Redo2,
RotateCcw,
Undo2,
X,
@@ -38,7 +37,6 @@ type ImageCanvasPanelDockViewProps = {
canvasBackgroundColor: string;
canvasBackgroundHexValue: string;
canUndo: boolean;
canRedo: boolean;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
@@ -48,7 +46,6 @@ type ImageCanvasPanelDockViewProps = {
minimapModel: StageMinimapModel | null;
onFitLayers: () => void;
onUndoCanvasChange: () => void;
onRedoCanvasChange: () => void;
onUpdateScaleFromCenter: (nextScale: number) => void;
onToggleZoomMenu: () => void;
onCloseZoomMenu: () => void;
@@ -205,7 +202,6 @@ export function ImageCanvasPanelDockView({
canvasBackgroundColor,
canvasBackgroundHexValue,
canUndo,
canRedo,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
@@ -215,7 +211,6 @@ export function ImageCanvasPanelDockView({
minimapModel,
onFitLayers,
onUndoCanvasChange,
onRedoCanvasChange,
onUpdateScaleFromCenter,
onToggleZoomMenu,
onCloseZoomMenu,
@@ -337,13 +332,6 @@ export function ImageCanvasPanelDockView({
disabled={!canUndo}
onClick={onUndoCanvasChange}
/>
<EditorIconButton
label="重做"
title="重做"
icon={Redo2}
disabled={!canRedo}
onClick={onRedoCanvasChange}
/>
<div className="image-canvas-editor__zoom-menu-wrap">
<PlatformInlineOptionButton
className="image-canvas-editor__zoom-trigger"
@@ -16,7 +16,6 @@ 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', 'A'], status: 'new' },
{ action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' },
{ action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' },
@@ -153,7 +153,6 @@ function SidebarTabsHarness() {
canvasBackgroundColor={chrome.canvasBackgroundColor}
canvasBackgroundHexValue={chrome.canvasBackgroundHexValue}
canUndo={false}
canRedo={false}
isZoomMenuOpen={false}
isBackgroundSettingsOpen={false}
activeSidebarPanel={chrome.activeSidebarPanel}
@@ -163,7 +162,6 @@ function SidebarTabsHarness() {
minimapModel={null}
onFitLayers={vi.fn()}
onUndoCanvasChange={vi.fn()}
onRedoCanvasChange={vi.fn()}
onUpdateScaleFromCenter={vi.fn()}
onToggleZoomMenu={vi.fn()}
onCloseZoomMenu={vi.fn()}
@@ -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 {
@@ -20,6 +21,7 @@ import type {
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
CanvasUndoFeedback,
CanvasViewport,
CropExpandPanelState,
CropExpandResizeHandle,
@@ -82,7 +84,7 @@ export type ImageCanvasStageViewProps = {
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canUndo: boolean;
canRedo: boolean;
undoFeedback: CanvasUndoFeedback | null;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
@@ -174,7 +176,6 @@ export type ImageCanvasStageViewProps = {
onUpdateScaleFromCenter: (nextScale: number) => 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 ? (
<PlatformRuntimeStatusToast
key={undoFeedback.id}
tone={undoFeedback.tone}
className="image-canvas-editor__undo-toast"
>
{undoFeedback.text}
</PlatformRuntimeStatusToast>
) : null}
{uploadDropTarget === 'canvas' ? (
<div
className="image-canvas-editor__upload-drop-overlay image-canvas-editor__upload-drop-overlay--canvas"
@@ -448,7 +457,6 @@ export function ImageCanvasStageView({
canvasBackgroundColor={canvasBackgroundColor}
canvasBackgroundHexValue={canvasBackgroundHexValue}
canUndo={canUndo}
canRedo={canRedo}
isZoomMenuOpen={isZoomMenuOpen}
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
activeSidebarPanel={activeSidebarPanel}
@@ -458,7 +466,6 @@ export function ImageCanvasStageView({
minimapModel={minimapModel}
onFitLayers={onFitLayers}
onUndoCanvasChange={onUndoCanvasChange}
onRedoCanvasChange={onRedoCanvasChange}
onUpdateScaleFromCenter={onUpdateScaleFromCenter}
onToggleZoomMenu={onToggleZoomMenu}
onCloseZoomMenu={onCloseZoomMenu}
@@ -126,11 +126,10 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
</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>
<button
type="button"
onClick={() => {
history.captureCanvasHistory();
history.captureCanvasHistory({ type: 'move-image', count: 1 });
}}
>
capture
@@ -138,7 +137,7 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
<button
type="button"
onClick={() => {
setLayers([createLayer('second', 90)]);
setLayers([createLayer('first', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setGenerateDialog({
id: 'dialog-next',
@@ -147,8 +146,8 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
status: 'idle',
});
setInactiveGenerateDialogs([]);
setSelectedLayerId('second');
setSelectedLayerIds(['second']);
setSelectedLayerId('first');
setSelectedLayerIds(['first']);
}}
>
mutate
@@ -161,20 +160,12 @@ function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
>
undo
</button>
<button
type="button"
onClick={() => {
history.redoCanvasChange();
}}
>
redo
</button>
</div>
);
}
describe('useCanvasHistory', () => {
it('captures, restores, and replays canvas history snapshots', () => {
it('captures and restores canvas history snapshots without redo', () => {
const clearDragState = vi.fn();
render(<HistoryHarness onClearDrag={clearDragState} />);
@@ -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');
});
});
+37 -32
View File
@@ -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<CanvasLayer[]>;
@@ -60,8 +67,7 @@ export function useCanvasHistory({
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
}) {
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
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,
};
}
@@ -13,6 +13,7 @@ import {
} from './ImageCanvasEditorModel';
import type {
AssetPointerDragState,
CanvasHistoryAction,
CanvasLayer,
CanvasViewport,
EditorAsset,
@@ -54,7 +55,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: (
@@ -157,7 +158,7 @@ export function useImageCanvasAssetCanvasBridge({
},
{ applyCascadeOffset: position === undefined },
);
captureCanvasHistory();
captureCanvasHistory({ type: 'add-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
@@ -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;
};
@@ -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');
});
@@ -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;
};
@@ -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) {
@@ -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(
<KeyboardShortcutsHarness
undoCanvasChange={undoCanvasChange}
redoCanvasChange={redoCanvasChange}
/>,
<KeyboardShortcutsHarness undoCanvasChange={undoCanvasChange} />,
);
act(() => {
@@ -285,7 +278,7 @@ describe('useImageCanvasKeyboardShortcuts', () => {
shiftKey: true,
});
});
expect(redoCanvasChange).toHaveBeenCalledTimes(1);
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), {
@@ -13,7 +13,6 @@ type UseImageCanvasKeyboardShortcutsOptions = {
generateDialogRef: RefObject<GenerateDialogState | null>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef?: RefObject<string[]>;
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,
@@ -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()),
);
@@ -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<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(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(<StageInteractionsHarness />);
const captureCanvasHistory = vi.fn();
render(
<StageInteractionsHarness
captureCanvasHistory={captureCanvasHistory}
/>,
);
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();
@@ -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<DragState | null>(null);
const pendingDragHistoryRef = useRef<PendingDragHistory | null>(null);
const pendingClickCollapseRef = useRef<PendingClickCollapse | null>(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') {
@@ -178,6 +178,7 @@ function UploadWorkflowHarness({
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory: vi.fn(),
selectSingleLayer: setSelectedLayerId,
});

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