Files
Genarrative/src/components/image-editor/useCanvasHistory.ts
T
kdletters 44f47e5db1
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Frontend tests (push) Successful in 2m5s
Project CI / Native shell tests (push) Successful in 2m40s
Project CI / Backend tests (push) Successful in 3m16s
修复图集素材标签持久化与拆分校验
项目资源复用增加素材类型匹配,避免同源旧类型资源回流
标签保存期间禁用拆分并在服务端确认后回写资源引用
补齐乱序请求、快照刷新、失败回滚和撤销历史保护
新增前后端回归测试并同步图集入口设计文档
2026-07-29 11:06:56 +08:00

299 lines
9.4 KiB
TypeScript

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[]>;
viewportRef: RefObject<CanvasViewport>;
generateDialogRef: RefObject<GenerateDialogState | null>;
inactiveGenerateDialogsRef: RefObject<CanvasGenerationDialogState[]>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
type CanvasHistorySetters = {
setLayers: (layers: CanvasLayer[]) => void;
setViewport: (viewport: CanvasViewport) => void;
setGenerateDialog: (dialog: GenerateDialogState | null) => void;
setInactiveGenerateDialogs: (dialogs: CanvasGenerationDialogState[]) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
};
type CanvasHistoryResetters = {
setHoveredLayerId: (layerId: string | null) => void;
setMetadataLayer: (layer: CanvasLayer | null) => void;
resetCanvasInteractionState: () => void;
};
type CanvasHistoryEffects = {
persistRestoredAssetKind?: (
layer: CanvasLayer,
historyEntryId?: number,
) => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
function cloneCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
export function useCanvasHistory({
refs,
setters,
resetters,
effects,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
}) {
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
const nextHistoryEntryIdRef = useRef(0);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({
layers: refs.layersRef.current.map((layer) => ({ ...layer })),
viewport: { ...refs.viewportRef.current },
generateDialog: refs.generateDialogRef.current
? cloneGenerateDialog(refs.generateDialogRef.current)
: null,
inactiveGenerateDialogs: refs.inactiveGenerateDialogsRef.current.map(
cloneCanvasGenerationDialog,
),
selectedLayerId: refs.selectedLayerIdRef.current,
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
}),
[refs],
);
const restoreCanvasHistorySnapshot = useCallback(
(
snapshot: CanvasHistorySnapshot,
action?: CanvasHistoryAction,
historyEntryId?: number,
) => {
const currentSnapshot = getCanvasHistorySnapshot();
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
current: currentSnapshot,
target: snapshot,
assetKindLayerIds:
action?.type === 'change-asset-kind'
? action.layerIds?.length
? new Set(action.layerIds)
: undefined
: new Set(),
});
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...mergedSnapshot.viewport });
setters.setGenerateDialog(
mergedSnapshot.generateDialog
? cloneGenerateDialog(mergedSnapshot.generateDialog)
: null,
);
setters.setInactiveGenerateDialogs(
mergedSnapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
);
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 }, historyEntryId);
}
}
}
},
[effects, getCanvasHistorySnapshot, resetters, setters],
);
const captureCanvasHistory = useCallback(
(
action: CanvasHistoryAction,
options: { snapshot?: CanvasHistorySnapshot } = {},
) => {
nextHistoryEntryIdRef.current += 1;
const entryId = nextHistoryEntryIdRef.current;
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
{
entryId,
snapshot: options.snapshot ?? getCanvasHistorySnapshot(),
action,
createdAt: Date.now(),
},
];
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
return entryId;
},
[getCanvasHistorySnapshot],
);
const discardCanvasHistoryEntriesContainingLayer = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) => {
const keepEntry = (entry: CanvasHistoryEntry) =>
!entry.snapshot.layers.some(matchesLayer);
const nextUndoStack = undoStackRef.current.filter(keepEntry);
const nextRedoStack = redoStackRef.current.filter(keepEntry);
if (
nextUndoStack.length === undoStackRef.current.length &&
nextRedoStack.length === redoStackRef.current.length
) {
return;
}
undoStackRef.current = nextUndoStack;
redoStackRef.current = nextRedoStack;
setHistoryVersion((version) => version + 1);
},
[],
);
const discardCanvasHistoryEntriesById = useCallback(
(entryIds: ReadonlySet<number>) => {
if (entryIds.size === 0) {
return;
}
const keepEntry = (entry: CanvasHistoryEntry) =>
!entryIds.has(entry.entryId);
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)),
{
entryId: previousEntry.entryId,
snapshot: currentSnapshot,
action: previousEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(
previousEntry.snapshot,
previousEntry.action,
previousEntry.entryId,
);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: previousEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
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)),
{
entryId: nextEntry.entryId,
snapshot: currentSnapshot,
action: nextEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(
nextEntry.snapshot,
nextEntry.action,
nextEntry.entryId,
);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: nextEntry.action };
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
return {
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
historyVersion,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
discardCanvasHistoryEntriesById,
undoCanvasChange,
redoCanvasChange,
};
}