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; viewportRef: RefObject; generateDialogRef?: RefObject; inactiveGenerateDialogsRef?: RefObject; selectedLayerIdRef?: RefObject; selectedLayerIdsRef: RefObject; }; 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([]); const redoStackRef = useRef([]); 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) => { 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, }; }