Files
Genarrative/packages/image-canvas-react/src/useCanvasHistory.ts
T
menghao 03a4410272
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 4m1s
Project CI / Native shell tests (push) Failing after 11m56s
game agent无限画布开发 (#136)
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 10:54:16 +08:00

323 lines
10 KiB
TypeScript

import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryApplyResult,
CanvasHistoryEntry,
CanvasHistorySnapshot,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from '@genarrative/image-canvas-core';
import {
canRestoreCanvasHistorySnapshotWithoutContentLoss,
isProtectedCanvasHistoryAction,
MAX_HISTORY_STEPS,
mergeCanvasHistorySnapshotForRestore,
} from '@genarrative/image-canvas-core';
import { type RefObject, useCallback, useRef, useState } from 'react';
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,
allowContentRemovalOnRestore = false,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters?: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
allowContentRemovalOnRestore?: boolean;
}) {
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 ?? null,
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(),
layerSizeLayerIds:
action?.type === 'resize-image'
? action.layerIds?.length
? new Set(action.layerIds)
: new Set(snapshot.layers.map((layer) => layer.id))
: 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 (
!allowContentRemovalOnRestore &&
(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 };
}, [
allowContentRemovalOnRestore,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
]);
const redoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const nextEntry = redoStackRef.current.at(-1);
if (!nextEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
!allowContentRemovalOnRestore &&
!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 };
}, [
allowContentRemovalOnRestore,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
]);
const resetCanvasHistory = useCallback(() => {
undoStackRef.current = [];
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
}, []);
return {
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
historyVersion,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
discardCanvasHistoryEntriesById,
undoCanvasChange,
redoCanvasChange,
resetCanvasHistory,
};
}