mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
Fixes architectural issue where snapshot logic was duplicated between hook and store layers. Problem Identified: - useDocumentHistory (hook) created snapshots with duplicate logic - timelineStore (store) created snapshots with duplicate logic - timelineStore couldn't call useDocumentHistory (hooks can't be used in stores) - Snapshot creation logic scattered across codebase Root Cause: Timeline operations happen in the store layer but history was managed via a hook. This architectural mismatch forced duplication. Solution: - Add pushToHistory() method to historyStore - Single source of truth for snapshot creation - Both hook and store layers call historyStore.pushToHistory() - Eliminates all snapshot creation duplication Changes: - Add historyStore.pushToHistory() - high-level API with snapshot creation - Keep historyStore.pushAction() - low-level API (for future use) - Update useDocumentHistory to call historyStore.pushToHistory() - Update timelineStore pushDocumentHistory() to call historyStore.pushToHistory() - Remove createDocumentSnapshot import from useDocumentHistory - Remove createDocumentSnapshot import from timelineStore Architecture: Before: Hook (useDocumentHistory) → creates snapshot → historyStore.pushAction() Store (timelineStore) → creates snapshot → historyStore.pushAction() [Duplication at 2 call sites] After: Hook (useDocumentHistory) → historyStore.pushToHistory() → creates snapshot Store (timelineStore) → historyStore.pushToHistory() → creates snapshot [Single implementation in historyStore] Benefits: - ✅ Zero snapshot creation duplication - ✅ Proper architectural separation (store handles snapshots, not hook/store) - ✅ Single source of truth (historyStore.pushToHistory) - ✅ Timeline and graph operations use identical snapshot logic - ✅ Easy to modify snapshot behavior in future (one place) Impact: - No breaking changes - No behavior changes - Better code organization - Easier maintenance Related: Phase 2.1 architectural improvement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
|
import { flushSync } from 'react-dom';
|
|
import { useWorkspaceStore } from '../stores/workspaceStore';
|
|
import { useHistoryStore } from '../stores/historyStore';
|
|
import { useGraphStore } from '../stores/graphStore';
|
|
import { useTimelineStore } from '../stores/timelineStore';
|
|
import type { DocumentSnapshot } from '../stores/historyStore';
|
|
|
|
/**
|
|
* useDocumentHistory Hook
|
|
*
|
|
* Provides undo/redo functionality for the active document.
|
|
* Each document has its own independent history stack (max 50 actions).
|
|
*
|
|
* IMPORTANT: History is per-document. All operations (graph changes, timeline operations)
|
|
* are tracked in a single unified history stack for the entire document.
|
|
*
|
|
* Usage:
|
|
* const { undo, redo, canUndo, canRedo, pushToHistory } = useDocumentHistory();
|
|
*/
|
|
export function useDocumentHistory() {
|
|
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
|
|
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
|
|
|
|
const loadGraphState = useGraphStore((state) => state.loadGraphState);
|
|
|
|
const historyStore = useHistoryStore();
|
|
|
|
// Initialize history for active document
|
|
useEffect(() => {
|
|
if (!activeDocumentId) return;
|
|
|
|
const history = historyStore.histories.get(activeDocumentId);
|
|
if (!history) {
|
|
historyStore.initializeHistory(activeDocumentId);
|
|
}
|
|
}, [activeDocumentId, historyStore]);
|
|
|
|
/**
|
|
* Push current document state to history (timeline + types)
|
|
*/
|
|
const pushToHistory = useCallback(
|
|
(description: string) => {
|
|
if (!activeDocumentId) {
|
|
console.warn('No active document to record action');
|
|
return;
|
|
}
|
|
|
|
// Get current state from stores
|
|
const workspaceStore = useWorkspaceStore.getState();
|
|
const activeDoc = workspaceStore.getActiveDocument();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
|
const graphStore = useGraphStore.getState();
|
|
|
|
if (!timeline || !activeDoc) {
|
|
console.warn('Cannot push to history: missing timeline or document');
|
|
return;
|
|
}
|
|
|
|
// ✅ Call historyStore's high-level pushToHistory (single source of truth)
|
|
historyStore.pushToHistory(
|
|
activeDocumentId,
|
|
description,
|
|
activeDoc,
|
|
timeline,
|
|
graphStore
|
|
);
|
|
},
|
|
[activeDocumentId, historyStore]
|
|
);
|
|
|
|
/**
|
|
* Undo the last action for the active document
|
|
*/
|
|
const undo = useCallback(() => {
|
|
if (!activeDocumentId) {
|
|
console.warn('No active document to undo');
|
|
return;
|
|
}
|
|
|
|
// Capture current state BEFORE undoing
|
|
const workspaceStore = useWorkspaceStore.getState();
|
|
const activeDoc = workspaceStore.getActiveDocument();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
|
|
|
if (!timeline) {
|
|
console.warn('No timeline found for active document');
|
|
return;
|
|
}
|
|
|
|
if (!activeDoc) {
|
|
console.warn('Active document not found');
|
|
return;
|
|
}
|
|
|
|
// IMPORTANT: Update timeline's current state with graphStore BEFORE capturing snapshot
|
|
// This ensures the snapshot includes the current groups
|
|
const currentState = timeline.states.get(timeline.currentStateId);
|
|
if (currentState) {
|
|
const graphStore = useGraphStore.getState();
|
|
currentState.graph = {
|
|
nodes: graphStore.nodes as unknown as typeof currentState.graph.nodes,
|
|
edges: graphStore.edges as unknown as typeof currentState.graph.edges,
|
|
groups: graphStore.groups as unknown as typeof currentState.graph.groups,
|
|
};
|
|
}
|
|
|
|
// NOTE: Read types and labels from the document, not from graphStore
|
|
const currentSnapshot: DocumentSnapshot = {
|
|
timeline: {
|
|
states: new Map(timeline.states),
|
|
currentStateId: timeline.currentStateId,
|
|
rootStateId: timeline.rootStateId,
|
|
},
|
|
nodeTypes: activeDoc.nodeTypes,
|
|
edgeTypes: activeDoc.edgeTypes,
|
|
labels: activeDoc.labels || [],
|
|
};
|
|
|
|
const restoredState = historyStore.undo(activeDocumentId, currentSnapshot);
|
|
if (restoredState) {
|
|
|
|
// Restore complete document state (timeline + types + labels)
|
|
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
|
|
|
// Update document's types and labels (which will sync to graphStore via workspaceStore)
|
|
activeDoc.nodeTypes = restoredState.nodeTypes;
|
|
activeDoc.edgeTypes = restoredState.edgeTypes;
|
|
activeDoc.labels = restoredState.labels || [];
|
|
|
|
// Load the current state's graph from the restored timeline
|
|
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
|
if (currentState) {
|
|
// IMPORTANT: Use flushSync to force React to process the Zustand update immediately
|
|
// This prevents React Flow from processing stale state before the new state arrives
|
|
flushSync(() => {
|
|
// Use loadGraphState to update ALL graph state atomically in a single Zustand transaction
|
|
// This prevents React Flow from receiving intermediate state where nodes have
|
|
// parentId references but groups don't exist yet (which causes "Parent node not found")
|
|
loadGraphState({
|
|
nodes: currentState.graph.nodes,
|
|
edges: currentState.graph.edges,
|
|
groups: currentState.graph.groups || [],
|
|
nodeTypes: restoredState.nodeTypes,
|
|
edgeTypes: restoredState.edgeTypes,
|
|
labels: restoredState.labels || [],
|
|
});
|
|
});
|
|
}
|
|
|
|
// Mark document as dirty and trigger auto-save
|
|
markDocumentDirty(activeDocumentId);
|
|
|
|
// Auto-save after a short delay
|
|
const { saveDocument } = useWorkspaceStore.getState();
|
|
setTimeout(() => {
|
|
saveDocument(activeDocumentId);
|
|
}, 1000);
|
|
}
|
|
}, [activeDocumentId, historyStore, loadGraphState, markDocumentDirty]);
|
|
|
|
/**
|
|
* Redo the last undone action for the active document
|
|
*/
|
|
const redo = useCallback(() => {
|
|
if (!activeDocumentId) {
|
|
console.warn('No active document to redo');
|
|
return;
|
|
}
|
|
|
|
// Capture current state BEFORE redoing
|
|
const workspaceStore = useWorkspaceStore.getState();
|
|
const activeDoc = workspaceStore.getActiveDocument();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
|
|
|
if (!timeline) {
|
|
console.warn('No timeline found for active document');
|
|
return;
|
|
}
|
|
|
|
if (!activeDoc) {
|
|
console.warn('Active document not found');
|
|
return;
|
|
}
|
|
|
|
// IMPORTANT: Update timeline's current state with graphStore BEFORE capturing snapshot
|
|
// This ensures the snapshot includes the current groups
|
|
const currentState = timeline.states.get(timeline.currentStateId);
|
|
if (currentState) {
|
|
const graphStore = useGraphStore.getState();
|
|
currentState.graph = {
|
|
nodes: graphStore.nodes as unknown as typeof currentState.graph.nodes,
|
|
edges: graphStore.edges as unknown as typeof currentState.graph.edges,
|
|
groups: graphStore.groups as unknown as typeof currentState.graph.groups,
|
|
};
|
|
}
|
|
|
|
// NOTE: Read types and labels from the document, not from graphStore
|
|
const currentSnapshot: DocumentSnapshot = {
|
|
timeline: {
|
|
states: new Map(timeline.states),
|
|
currentStateId: timeline.currentStateId,
|
|
rootStateId: timeline.rootStateId,
|
|
},
|
|
nodeTypes: activeDoc.nodeTypes,
|
|
edgeTypes: activeDoc.edgeTypes,
|
|
labels: activeDoc.labels || [],
|
|
};
|
|
|
|
const restoredState = historyStore.redo(activeDocumentId, currentSnapshot);
|
|
if (restoredState) {
|
|
|
|
// Restore complete document state (timeline + types + labels)
|
|
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
|
|
|
// Update document's types and labels (which will sync to graphStore via workspaceStore)
|
|
activeDoc.nodeTypes = restoredState.nodeTypes;
|
|
activeDoc.edgeTypes = restoredState.edgeTypes;
|
|
activeDoc.labels = restoredState.labels || [];
|
|
|
|
// Load the current state's graph from the restored timeline
|
|
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
|
if (currentState) {
|
|
// IMPORTANT: Use flushSync to force React to process the Zustand update immediately
|
|
// This prevents React Flow from processing stale state before the new state arrives
|
|
flushSync(() => {
|
|
// Use loadGraphState to update ALL graph state atomically in a single Zustand transaction
|
|
// This prevents React Flow from receiving intermediate state where nodes have
|
|
// parentId references but groups don't exist yet (which causes "Parent node not found")
|
|
loadGraphState({
|
|
nodes: currentState.graph.nodes,
|
|
edges: currentState.graph.edges,
|
|
groups: currentState.graph.groups || [],
|
|
nodeTypes: restoredState.nodeTypes,
|
|
edgeTypes: restoredState.edgeTypes,
|
|
labels: restoredState.labels || [],
|
|
});
|
|
});
|
|
}
|
|
|
|
// Mark document as dirty and trigger auto-save
|
|
markDocumentDirty(activeDocumentId);
|
|
|
|
// Auto-save after a short delay
|
|
const { saveDocument } = useWorkspaceStore.getState();
|
|
setTimeout(() => {
|
|
saveDocument(activeDocumentId);
|
|
}, 1000);
|
|
}
|
|
}, [activeDocumentId, historyStore, loadGraphState, markDocumentDirty]);
|
|
|
|
/**
|
|
* Check if undo is available for the active document
|
|
*/
|
|
const canUndo = activeDocumentId ? historyStore.canUndo(activeDocumentId) : false;
|
|
|
|
/**
|
|
* Check if redo is available for the active document
|
|
*/
|
|
const canRedo = activeDocumentId ? historyStore.canRedo(activeDocumentId) : false;
|
|
|
|
/**
|
|
* Get the description of the next undo action
|
|
*/
|
|
const undoDescription = activeDocumentId
|
|
? historyStore.getUndoDescription(activeDocumentId)
|
|
: null;
|
|
|
|
/**
|
|
* Get the description of the next redo action
|
|
*/
|
|
const redoDescription = activeDocumentId
|
|
? historyStore.getRedoDescription(activeDocumentId)
|
|
: null;
|
|
|
|
return {
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
undoDescription,
|
|
redoDescription,
|
|
pushToHistory,
|
|
};
|
|
}
|