mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-04-23 08:31:16 -04:00
fix: replace dual-store undo/redo with unified history store
The previous undo/redo had independent history stacks in modelStore and
sceneStore causing desync, history spam during drags, and data loss on
redo cycles. This replaces both with a single historyStore capturing
atomic {model, scene} snapshots, adds gesture-level boundaries
(beginGesture/endGesture) for continuous operations, blocks undo during
active interactions, and fixes redo to preserve current state correctly.
226 tests passing across 28 suites.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Box } from '@mui/material';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { theme } from 'src/styles/theme';
|
||||
import { IsoflowProps } from 'src/types';
|
||||
import { setWindowCursor, modelFromModelStore } from 'src/utils';
|
||||
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
|
||||
import { SceneProvider } from 'src/stores/sceneStore';
|
||||
import { HistoryProvider } from 'src/stores/historyStore';
|
||||
import { LocaleProvider } from 'src/stores/localeStore';
|
||||
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
||||
import { Renderer } from 'src/components/Renderer/Renderer';
|
||||
@@ -33,7 +35,7 @@ const App = ({
|
||||
const initialDataManager = useInitialDataManager();
|
||||
const model = useModelStore((state) => {
|
||||
return modelFromModelStore(state);
|
||||
});
|
||||
}, shallow);
|
||||
|
||||
const { load } = initialDataManager;
|
||||
|
||||
@@ -99,9 +101,11 @@ export const Isoflow = (props: IsoflowProps) => {
|
||||
<LocaleProvider locale={props.locale || enUS}>
|
||||
<ModelProvider>
|
||||
<SceneProvider>
|
||||
<UiStateProvider>
|
||||
<App {...props} />
|
||||
</UiStateProvider>
|
||||
<HistoryProvider>
|
||||
<UiStateProvider>
|
||||
<App {...props} />
|
||||
</UiStateProvider>
|
||||
</HistoryProvider>
|
||||
</SceneProvider>
|
||||
</ModelProvider>
|
||||
</LocaleProvider>
|
||||
|
||||
492
packages/fossflow-lib/src/__tests__/history-integration.test.tsx
Normal file
492
packages/fossflow-lib/src/__tests__/history-integration.test.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ModelProvider, useModelStoreApi } from 'src/stores/modelStore';
|
||||
import { SceneProvider, useSceneStoreApi } from 'src/stores/sceneStore';
|
||||
import { HistoryProvider, useHistoryStoreApi } from 'src/stores/historyStore';
|
||||
import { UiStateProvider } from 'src/stores/uiStateStore';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
|
||||
/**
|
||||
* Integration tests using real providers (no mocks).
|
||||
* Verifies the unified history system works end-to-end
|
||||
* across model, scene, and history stores.
|
||||
*/
|
||||
|
||||
const AllProviders = ({ children }: { children: React.ReactNode }) => (
|
||||
<ModelProvider>
|
||||
<SceneProvider>
|
||||
<HistoryProvider>
|
||||
<UiStateProvider>{children}</UiStateProvider>
|
||||
</HistoryProvider>
|
||||
</SceneProvider>
|
||||
</ModelProvider>
|
||||
);
|
||||
|
||||
/** Hook that exposes history + imperative store APIs for testing */
|
||||
function useTestHarness() {
|
||||
const history = useHistory();
|
||||
const modelApi = useModelStoreApi();
|
||||
const sceneApi = useSceneStoreApi();
|
||||
const historyApi = useHistoryStoreApi();
|
||||
return { history, modelApi, sceneApi, historyApi };
|
||||
}
|
||||
|
||||
describe('history integration (real providers)', () => {
|
||||
const renderTestHook = () =>
|
||||
renderHook(() => useTestHarness(), { wrapper: AllProviders });
|
||||
|
||||
describe('saveSnapshot + undo + redo round-trip', () => {
|
||||
it('undo restores model state atomically', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Save initial state as a snapshot
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
// Mutate model
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Changed' });
|
||||
});
|
||||
|
||||
// Undo should restore original title
|
||||
let success = false;
|
||||
act(() => {
|
||||
success = result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
});
|
||||
|
||||
it('undo restores scene state atomically', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
// Mutate scene
|
||||
act(() => {
|
||||
result.current.sceneApi.getState().actions.set({
|
||||
connectors: {
|
||||
'conn-1': {
|
||||
path: {
|
||||
tiles: [{ x: 0, y: 0 }],
|
||||
rectangle: { x: 0, y: 0, width: 1, height: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let success = false;
|
||||
act(() => {
|
||||
success = result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(result.current.sceneApi.getState().connectors).toEqual({});
|
||||
});
|
||||
|
||||
it('redo re-applies the undone state', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'V2' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
|
||||
let success = false;
|
||||
act(() => {
|
||||
success = result.current.history.redo();
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
// Redo restores the state that was current when we undid (the V2 state)
|
||||
expect(result.current.modelApi.getState().title).toBe('V2');
|
||||
});
|
||||
|
||||
it('undo returns false when history is empty', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let success = true;
|
||||
act(() => {
|
||||
success = result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it('redo returns false when future is empty', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let success = true;
|
||||
act(() => {
|
||||
success = result.current.history.redo();
|
||||
});
|
||||
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture lifecycle produces single history entry', () => {
|
||||
it('beginGesture + multiple mutations + endGesture = single undo step', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
// Multiple mutations during gesture
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Step1' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Step2' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Step3' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.history.endGesture();
|
||||
});
|
||||
|
||||
// Only 1 history entry (from beginGesture)
|
||||
const historyState = result.current.historyApi.getState();
|
||||
expect(historyState.past).toHaveLength(1);
|
||||
|
||||
// Undo restores pre-gesture state in one step
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
});
|
||||
|
||||
it('saveSnapshot is blocked during gesture', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
// These should be no-ops
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
result.current.history.saveSnapshot();
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.history.endGesture();
|
||||
});
|
||||
|
||||
// Only the beginGesture entry
|
||||
expect(result.current.historyApi.getState().past).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('beginGesture is idempotent when already in gesture', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Changed' });
|
||||
});
|
||||
|
||||
// Second beginGesture should be ignored
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.history.endGesture();
|
||||
});
|
||||
|
||||
// Only 1 entry
|
||||
expect(result.current.historyApi.getState().past).toHaveLength(1);
|
||||
// The saved state is from the FIRST beginGesture (pre-mutation)
|
||||
expect(result.current.historyApi.getState().past[0].model.title).toBe(
|
||||
'Untitled'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelGesture restores pre-gesture state', () => {
|
||||
it('restores model state on cancel', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'During gesture' });
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('During gesture');
|
||||
|
||||
act(() => {
|
||||
result.current.history.cancelGesture();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
expect(result.current.historyApi.getState().gestureInProgress).toBe(false);
|
||||
expect(result.current.historyApi.getState().past).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('restores scene state on cancel', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sceneApi.getState().actions.set({
|
||||
connectors: {
|
||||
'temp-conn': {
|
||||
path: {
|
||||
tiles: [{ x: 0, y: 0 }],
|
||||
rectangle: { x: 0, y: 0, width: 1, height: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.history.cancelGesture();
|
||||
});
|
||||
|
||||
expect(result.current.sceneApi.getState().connectors).toEqual({});
|
||||
});
|
||||
|
||||
it('cancelGesture is a no-op when no gesture in progress', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.history.cancelGesture();
|
||||
});
|
||||
|
||||
expect(result.current.historyApi.getState().past).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo + redo restores model+scene atomically', () => {
|
||||
it('combined model+scene mutation undoes as one unit', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Save pre-change snapshot
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
// Mutate both stores
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'New Title' });
|
||||
result.current.sceneApi.getState().actions.set({
|
||||
textBoxes: {
|
||||
'tb-1': { size: { width: 100, height: 50 } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Undo should restore both
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
expect(result.current.sceneApi.getState().textBoxes).toEqual({});
|
||||
|
||||
// Redo should bring both back
|
||||
act(() => {
|
||||
result.current.history.redo();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('New Title');
|
||||
expect(result.current.sceneApi.getState().textBoxes).toEqual({
|
||||
'tb-1': { size: { width: 100, height: 50 } }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory resets everything', () => {
|
||||
it('clears past, future, and gestureInProgress', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Build up some history
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'V2' });
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
// Verify we have past and future
|
||||
expect(result.current.historyApi.getState().past.length).toBeGreaterThan(0);
|
||||
expect(result.current.historyApi.getState().future.length).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
result.current.history.clearHistory();
|
||||
});
|
||||
|
||||
const state = result.current.historyApi.getState();
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(0);
|
||||
expect(state.gestureInProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('new snapshot clears future (branching)', () => {
|
||||
it('saving after undo discards redo stack', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'V2' });
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
// Undo once
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(result.current.historyApi.getState().future.length).toBeGreaterThan(0);
|
||||
|
||||
// New action should clear future
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'V3' });
|
||||
result.current.history.saveSnapshot();
|
||||
});
|
||||
|
||||
expect(result.current.historyApi.getState().future).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple undo/redo traversals', () => {
|
||||
it('can undo and redo through multiple states', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Create 3 snapshots
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot(); // saves "Untitled"
|
||||
result.current.modelApi.getState().actions.set({ title: 'A' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot(); // saves "A"
|
||||
result.current.modelApi.getState().actions.set({ title: 'B' });
|
||||
});
|
||||
act(() => {
|
||||
result.current.history.saveSnapshot(); // saves "B"
|
||||
result.current.modelApi.getState().actions.set({ title: 'C' });
|
||||
});
|
||||
|
||||
// Current state is "C", past has [Untitled, A, B]
|
||||
expect(result.current.modelApi.getState().title).toBe('C');
|
||||
|
||||
// Undo 3 times
|
||||
act(() => {
|
||||
result.current.history.undo(); // restores "B", pushes "C" to future
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('B');
|
||||
|
||||
act(() => {
|
||||
result.current.history.undo(); // restores "A", pushes "B" to future
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('A');
|
||||
|
||||
act(() => {
|
||||
result.current.history.undo(); // restores "Untitled", pushes "A" to future
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
|
||||
// No more undo
|
||||
let success = true;
|
||||
act(() => {
|
||||
success = result.current.history.undo();
|
||||
});
|
||||
expect(success).toBe(false);
|
||||
|
||||
// Redo back through
|
||||
act(() => {
|
||||
result.current.history.redo();
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('A');
|
||||
|
||||
act(() => {
|
||||
result.current.history.redo();
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('B');
|
||||
|
||||
act(() => {
|
||||
result.current.history.redo();
|
||||
});
|
||||
expect(result.current.modelApi.getState().title).toBe('C');
|
||||
|
||||
// No more redo
|
||||
success = true;
|
||||
act(() => {
|
||||
success = result.current.history.redo();
|
||||
});
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture + undo interaction', () => {
|
||||
it('gesture followed by undo restores pre-gesture state', () => {
|
||||
const { result } = renderTestHook();
|
||||
|
||||
// Do a gesture (simulates drag, draw, etc.)
|
||||
act(() => {
|
||||
result.current.history.beginGesture();
|
||||
});
|
||||
act(() => {
|
||||
result.current.modelApi.getState().actions.set({ title: 'Dragged' });
|
||||
result.current.sceneApi.getState().actions.set({
|
||||
connectors: {
|
||||
'drag-conn': {
|
||||
path: {
|
||||
tiles: [{ x: 1, y: 1 }, { x: 2, y: 2 }],
|
||||
rectangle: { x: 1, y: 1, width: 1, height: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.history.endGesture();
|
||||
});
|
||||
|
||||
// Now undo the entire gesture as one step
|
||||
act(() => {
|
||||
result.current.history.undo();
|
||||
});
|
||||
|
||||
expect(result.current.modelApi.getState().title).toBe('Untitled');
|
||||
expect(result.current.sceneApi.getState().connectors).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { generateId, findNearestUnoccupiedTile } from 'src/utils';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { useModelStore } from 'src/stores/modelStore';
|
||||
import { useModelStoreApi } from 'src/stores/modelStore';
|
||||
import { VIEW_ITEM_DEFAULTS } from 'src/config';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
@@ -12,9 +12,7 @@ interface Props {
|
||||
|
||||
export const ContextMenuManager = ({ anchorEl }: Props) => {
|
||||
const scene = useScene();
|
||||
const model = useModelStore((state) => {
|
||||
return state;
|
||||
});
|
||||
const modelStoreApi = useModelStoreApi();
|
||||
const contextMenu = useUiStateStore((state) => {
|
||||
return state.contextMenu;
|
||||
});
|
||||
@@ -36,10 +34,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
|
||||
label: 'Add Node',
|
||||
onClick: () => {
|
||||
if (!contextMenu) return;
|
||||
if (model.icons.length > 0) {
|
||||
const { icons } = modelStoreApi.getState();
|
||||
if (icons.length > 0) {
|
||||
const modelItemId = generateId();
|
||||
const firstIcon = model.icons[0];
|
||||
|
||||
const firstIcon = icons[0];
|
||||
|
||||
// Find nearest unoccupied tile (should return the same tile since context menu is for empty tiles)
|
||||
const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile;
|
||||
|
||||
@@ -63,10 +62,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
|
||||
label: 'Add Rectangle',
|
||||
onClick: () => {
|
||||
if (!contextMenu) return;
|
||||
if (model.colors.length > 0) {
|
||||
const { colors } = modelStoreApi.getState();
|
||||
if (colors.length > 0) {
|
||||
scene.createRectangle({
|
||||
id: generateId(),
|
||||
color: model.colors[0].id,
|
||||
color: colors[0].id,
|
||||
from: contextMenu.tile,
|
||||
to: contextMenu.tile
|
||||
});
|
||||
|
||||
@@ -22,3 +22,5 @@ export const Cursor = memo(() => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Cursor.displayName = 'Cursor';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
@@ -9,7 +10,8 @@ export const DebugUtils = () => {
|
||||
const uiState = useUiStateStore(
|
||||
({ scroll, mouse, zoom, mode, rendererEl }) => {
|
||||
return { scroll, mouse, zoom, mode, rendererEl };
|
||||
}
|
||||
},
|
||||
shallow
|
||||
);
|
||||
const scene = useScene();
|
||||
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from 'src/styles/theme';
|
||||
import { ModelProvider } from 'src/stores/modelStore';
|
||||
import { SceneProvider } from 'src/stores/sceneStore';
|
||||
import { HistoryProvider } from 'src/stores/historyStore';
|
||||
import { UiStateProvider } from 'src/stores/uiStateStore';
|
||||
import { DebugUtils } from '../DebugUtils';
|
||||
|
||||
@@ -13,7 +14,9 @@ describe('DebugUtils', () => {
|
||||
<ThemeProvider theme={theme}>
|
||||
<ModelProvider>
|
||||
<SceneProvider>
|
||||
<UiStateProvider>{children}</UiStateProvider>
|
||||
<HistoryProvider>
|
||||
<UiStateProvider>{children}</UiStateProvider>
|
||||
</HistoryProvider>
|
||||
</SceneProvider>
|
||||
</ModelProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from 'src/styles/theme';
|
||||
import { ModelProvider } from 'src/stores/modelStore';
|
||||
import { SceneProvider } from 'src/stores/sceneStore';
|
||||
import { HistoryProvider } from 'src/stores/historyStore';
|
||||
import { UiStateProvider } from 'src/stores/uiStateStore';
|
||||
import { SizeIndicator } from '../SizeIndicator';
|
||||
|
||||
@@ -13,7 +14,9 @@ describe('SizeIndicator', () => {
|
||||
<ThemeProvider theme={theme}>
|
||||
<ModelProvider>
|
||||
<SceneProvider>
|
||||
<UiStateProvider>{children}</UiStateProvider>
|
||||
<HistoryProvider>
|
||||
<UiStateProvider>{children}</UiStateProvider>
|
||||
</HistoryProvider>
|
||||
</SceneProvider>
|
||||
</ModelProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -55,3 +55,5 @@ export const IsoTileArea = memo(({
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
IsoTileArea.displayName = 'IsoTileArea';
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
||||
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
Add as AddIcon,
|
||||
@@ -44,6 +45,7 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
});
|
||||
const connector = useConnector(id);
|
||||
const { updateConnector, deleteConnector } = useScene();
|
||||
const { beginGesture, endGesture } = useHistory();
|
||||
const [useCustomColor, setUseCustomColor] = useState(
|
||||
!!connector?.customColor
|
||||
);
|
||||
@@ -207,6 +209,8 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
<TextField
|
||||
label="Text"
|
||||
value={label.text}
|
||||
onFocus={beginGesture}
|
||||
onBlur={endGesture}
|
||||
onChange={(e) => {
|
||||
return handleUpdateLabel(label.id, {
|
||||
text: e.target.value
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ModelItem, ViewItem } from 'src/types';
|
||||
import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor';
|
||||
import { useModelItem } from 'src/hooks/useModelItem';
|
||||
import { useModelStore } from 'src/stores/modelStore';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
import { DeleteButton } from '../../components/DeleteButton';
|
||||
import { Section } from '../../components/Section';
|
||||
|
||||
@@ -28,6 +29,7 @@ export const NodeSettings = ({
|
||||
const modelItem = useModelItem(node.id);
|
||||
const modelActions = useModelStore((state) => state.actions);
|
||||
const icons = useModelStore((state) => state.icons);
|
||||
const { beginGesture, endGesture, isGestureInProgress } = useHistory();
|
||||
|
||||
// Local state for smooth slider interaction
|
||||
const currentIcon = icons.find(icon => icon.id === modelItem?.icon);
|
||||
@@ -57,10 +59,13 @@ export const NodeSettings = ({
|
||||
|
||||
// Handle slider change with local state + debounced store update
|
||||
const handleScaleChange = useCallback((e: Event, newScale: number | number[]) => {
|
||||
if (!isGestureInProgress()) {
|
||||
beginGesture();
|
||||
}
|
||||
const scale = newScale as number;
|
||||
setLocalScale(scale); // Immediate UI update
|
||||
updateIconScale(scale); // Debounced store update
|
||||
}, [updateIconScale]);
|
||||
}, [updateIconScale, beginGesture, isGestureInProgress]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -80,6 +85,8 @@ export const NodeSettings = ({
|
||||
<Section title="Name">
|
||||
<TextField
|
||||
value={modelItem.name}
|
||||
onFocus={() => beginGesture()}
|
||||
onBlur={() => endGesture()}
|
||||
onChange={(e) => {
|
||||
const text = e.target.value as string;
|
||||
if (modelItem.name !== text) onModelItemUpdated({ name: text });
|
||||
@@ -89,6 +96,8 @@ export const NodeSettings = ({
|
||||
<Section title="Description">
|
||||
<RichTextEditor
|
||||
value={modelItem.description}
|
||||
onFocus={() => beginGesture()}
|
||||
onBlur={() => endGesture()}
|
||||
onChange={(text) => {
|
||||
if (modelItem.description !== text)
|
||||
onModelItemUpdated({ description: text });
|
||||
@@ -119,6 +128,7 @@ export const NodeSettings = ({
|
||||
max={2.5}
|
||||
value={localScale}
|
||||
onChange={handleScaleChange}
|
||||
onChangeCommitted={endGesture}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTextBox } from 'src/hooks/useTextBox';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { getIsoProjectionCss } from 'src/utils';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
import { Section } from '../components/Section';
|
||||
import { DeleteButton } from '../components/DeleteButton';
|
||||
@@ -30,6 +31,7 @@ export const TextBoxControls = ({ id }: Props) => {
|
||||
});
|
||||
const textBox = useTextBox(id);
|
||||
const { updateTextBox, deleteTextBox } = useScene();
|
||||
const { beginGesture, endGesture } = useHistory();
|
||||
|
||||
// If textBox doesn't exist, return null
|
||||
if (!textBox) {
|
||||
@@ -58,6 +60,8 @@ export const TextBoxControls = ({ id }: Props) => {
|
||||
<Section title="Enter text">
|
||||
<TextField
|
||||
value={textBox.content}
|
||||
onFocus={beginGesture}
|
||||
onBlur={endGesture}
|
||||
onChange={(e) => {
|
||||
updateTextBox(textBox.id, { content: e.target.value as string });
|
||||
}}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Settings as SettingsIcon,
|
||||
|
||||
} from '@mui/icons-material';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { UiElement } from 'src/components/UiElement/UiElement';
|
||||
import { IconButton } from 'src/components/IconButton/IconButton';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
@@ -32,7 +33,7 @@ export const MainMenu = () => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const model = useModelStore((state) => {
|
||||
return modelFromModelStore(state);
|
||||
});
|
||||
}, shallow);
|
||||
const isMainMenuOpen = useUiStateStore((state) => {
|
||||
return state.isMainMenuOpen;
|
||||
});
|
||||
@@ -120,13 +121,13 @@ export const MainMenu = () => {
|
||||
}, [uiStateActions, clear, clearHistory]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undo();
|
||||
uiStateActions.setIsMainMenuOpen(false);
|
||||
const success = undo();
|
||||
if (success) uiStateActions.setIsMainMenuOpen(false);
|
||||
}, [undo, uiStateActions]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
redo();
|
||||
uiStateActions.setIsMainMenuOpen(false);
|
||||
const success = redo();
|
||||
if (success) uiStateActions.setIsMainMenuOpen(false);
|
||||
}, [redo, uiStateActions]);
|
||||
|
||||
const onOpenSettings = useCallback(() => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import RichTextEditorErrorBoundary from './RichTextEditorErrorBoundary';
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
height?: number;
|
||||
styles?: React.CSSProperties;
|
||||
@@ -42,6 +44,8 @@ const formats = [
|
||||
export const RichTextEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
readOnly,
|
||||
height = 120,
|
||||
styles
|
||||
@@ -92,6 +96,8 @@ export const RichTextEditor = ({
|
||||
value={value ?? ''}
|
||||
readOnly={readOnly}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
formats={formats}
|
||||
modules={modules}
|
||||
/>
|
||||
|
||||
@@ -60,3 +60,5 @@ export const SceneLayer = memo(({
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SceneLayer.displayName = 'SceneLayer';
|
||||
|
||||
@@ -124,3 +124,5 @@ export const ConnectorLabel = memo(({ connector: sceneConnector }: Props) => {
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectorLabel.displayName = 'ConnectorLabel';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { ConnectorLabel } from './ConnectorLabel';
|
||||
|
||||
@@ -6,21 +6,27 @@ interface Props {
|
||||
connectors: ReturnType<typeof useScene>['connectors'];
|
||||
}
|
||||
|
||||
export const ConnectorLabels = ({ connectors }: Props) => {
|
||||
export const ConnectorLabels = memo(({ connectors }: Props) => {
|
||||
const labeledConnectors = useMemo(
|
||||
() =>
|
||||
connectors.filter((connector) => {
|
||||
return Boolean(
|
||||
connector.description ||
|
||||
connector.startLabel ||
|
||||
connector.endLabel ||
|
||||
(connector.labels && connector.labels.length > 0)
|
||||
);
|
||||
}),
|
||||
[connectors]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{connectors
|
||||
.filter((connector) => {
|
||||
return Boolean(
|
||||
connector.description ||
|
||||
connector.startLabel ||
|
||||
connector.endLabel ||
|
||||
(connector.labels && connector.labels.length > 0)
|
||||
);
|
||||
})
|
||||
.map((connector) => {
|
||||
return <ConnectorLabel key={connector.id} connector={connector} />;
|
||||
})}
|
||||
{labeledConnectors.map((connector) => {
|
||||
return <ConnectorLabel key={connector.id} connector={connector} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ConnectorLabels.displayName = 'ConnectorLabels';
|
||||
|
||||
@@ -327,3 +327,5 @@ export const Connector = memo(({ connector: _connector, isSelected }: Props) =>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Connector.displayName = 'Connector';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import type { useScene } from 'src/hooks/useScene';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { Connector } from './Connector';
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
connectors: ReturnType<typeof useScene>['connectors'];
|
||||
}
|
||||
|
||||
export const Connectors = ({ connectors }: Props) => {
|
||||
export const Connectors = memo(({ connectors }: Props) => {
|
||||
const itemControls = useUiStateStore((state) => {
|
||||
return state.itemControls;
|
||||
});
|
||||
@@ -27,9 +27,11 @@ export const Connectors = ({ connectors }: Props) => {
|
||||
return null;
|
||||
}, [mode, itemControls]);
|
||||
|
||||
const reversedConnectors = useMemo(() => [...connectors].reverse(), [connectors]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...connectors].reverse().map((connector) => {
|
||||
{reversedConnectors.map((connector) => {
|
||||
return (
|
||||
<Connector
|
||||
key={connector.id}
|
||||
@@ -40,4 +42,6 @@ export const Connectors = ({ connectors }: Props) => {
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Connectors.displayName = 'Connectors';
|
||||
|
||||
@@ -94,3 +94,5 @@ export const Node = memo(({ node, order }: Props) => {
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Node.displayName = 'Node';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import { ViewItem } from 'src/types';
|
||||
import { Node } from './Node/Node';
|
||||
|
||||
@@ -6,14 +6,18 @@ interface Props {
|
||||
nodes: ViewItem[];
|
||||
}
|
||||
|
||||
export const Nodes = ({ nodes }: Props) => {
|
||||
export const Nodes = memo(({ nodes }: Props) => {
|
||||
const reversedNodes = useMemo(() => [...nodes].reverse(), [nodes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...nodes].reverse().map((node) => {
|
||||
{reversedNodes.map((node) => {
|
||||
return (
|
||||
<Node key={node.id} order={-node.tile.x - node.tile.y} node={node} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Nodes.displayName = 'Nodes';
|
||||
|
||||
@@ -31,3 +31,5 @@ export const Rectangle = memo(({ from, to, color: colorId, customColor }: Props)
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Rectangle.displayName = 'Rectangle';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { Rectangle } from './Rectangle';
|
||||
|
||||
@@ -6,12 +6,16 @@ interface Props {
|
||||
rectangles: ReturnType<typeof useScene>['rectangles'];
|
||||
}
|
||||
|
||||
export const Rectangles = ({ rectangles }: Props) => {
|
||||
export const Rectangles = memo(({ rectangles }: Props) => {
|
||||
const reversedRectangles = useMemo(() => [...rectangles].reverse(), [rectangles]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...rectangles].reverse().map((rectangle) => {
|
||||
{reversedRectangles.map((rectangle) => {
|
||||
return <Rectangle key={rectangle.id} {...rectangle} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Rectangles.displayName = 'Rectangles';
|
||||
|
||||
@@ -50,3 +50,5 @@ export const TextBox = memo(({ textBox }: Props) => {
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
TextBox.displayName = 'TextBox';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { TextBox } from './TextBox';
|
||||
|
||||
@@ -6,12 +6,16 @@ interface Props {
|
||||
textBoxes: ReturnType<typeof useScene>['textBoxes'];
|
||||
}
|
||||
|
||||
export const TextBoxes = ({ textBoxes }: Props) => {
|
||||
export const TextBoxes = memo(({ textBoxes }: Props) => {
|
||||
const reversedTextBoxes = useMemo(() => [...textBoxes].reverse(), [textBoxes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...textBoxes].reverse().map((textBox) => {
|
||||
{reversedTextBoxes.map((textBox) => {
|
||||
return <TextBox key={textBox.id} textBox={textBox} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
TextBoxes.displayName = 'TextBoxes';
|
||||
|
||||
@@ -1,329 +1,461 @@
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { createStore } from 'zustand';
|
||||
import { useHistory } from '../useHistory';
|
||||
import type { HistoryStoreState, HistoryEntry } from 'src/stores/historyStore';
|
||||
import type { Model, Scene } from 'src/types';
|
||||
|
||||
// Mock implementations
|
||||
const mockModelStore = {
|
||||
canUndo: jest.fn(),
|
||||
canRedo: jest.fn(),
|
||||
undo: jest.fn(),
|
||||
redo: jest.fn(),
|
||||
saveToHistory: jest.fn(),
|
||||
clearHistory: jest.fn()
|
||||
};
|
||||
// --- Minimal mock data ---
|
||||
|
||||
const mockSceneStore = {
|
||||
canUndo: jest.fn(),
|
||||
canRedo: jest.fn(),
|
||||
undo: jest.fn(),
|
||||
redo: jest.fn(),
|
||||
saveToHistory: jest.fn(),
|
||||
clearHistory: jest.fn()
|
||||
};
|
||||
const makeModel = (title: string): Model => ({
|
||||
title,
|
||||
colors: [],
|
||||
icons: [],
|
||||
items: [],
|
||||
views: []
|
||||
});
|
||||
|
||||
// Mock the store hooks
|
||||
jest.mock('../../stores/modelStore', () => ({
|
||||
useModelStore: jest.fn((selector) => {
|
||||
const state = {
|
||||
actions: mockModelStore
|
||||
const makeScene = (extra?: Record<string, unknown>): Scene => ({
|
||||
connectors: {},
|
||||
textBoxes: {},
|
||||
...extra
|
||||
});
|
||||
|
||||
const makeEntry = (title: string): HistoryEntry => ({
|
||||
model: makeModel(title),
|
||||
scene: makeScene()
|
||||
});
|
||||
|
||||
// --- Mock stores ---
|
||||
|
||||
const createMockModelStore = (initial: Model) => {
|
||||
return createStore<{
|
||||
title: string;
|
||||
colors: Model['colors'];
|
||||
icons: Model['icons'];
|
||||
items: Model['items'];
|
||||
views: Model['views'];
|
||||
version?: string;
|
||||
description?: string;
|
||||
actions: {
|
||||
get: () => any;
|
||||
set: (updates: Partial<Model>) => void;
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
})
|
||||
}>((set, get) => ({
|
||||
...initial,
|
||||
actions: {
|
||||
get,
|
||||
set: (updates: Partial<Model>) => set((state) => ({ ...state, ...updates }))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const createMockSceneStore = (initial: Scene) => {
|
||||
return createStore<{
|
||||
connectors: Scene['connectors'];
|
||||
textBoxes: Scene['textBoxes'];
|
||||
actions: {
|
||||
get: () => any;
|
||||
set: (updates: Partial<Scene>) => void;
|
||||
};
|
||||
}>((set, get) => ({
|
||||
...initial,
|
||||
actions: {
|
||||
get,
|
||||
set: (updates: Partial<Scene>) => set((state) => ({ ...state, ...updates }))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
const createMockHistoryStore = () => {
|
||||
return createStore<HistoryStoreState>((set, get) => ({
|
||||
past: [],
|
||||
future: [],
|
||||
gestureInProgress: false,
|
||||
maxSize: MAX_HISTORY,
|
||||
actions: {
|
||||
saveSnapshot: (currentEntry: HistoryEntry) => {
|
||||
const { gestureInProgress, maxSize } = get();
|
||||
if (gestureInProgress) return;
|
||||
set((state) => {
|
||||
const newPast = [...state.past, currentEntry];
|
||||
if (newPast.length > maxSize) newPast.shift();
|
||||
return { past: newPast, future: [] };
|
||||
});
|
||||
},
|
||||
undo: (currentEntry: HistoryEntry) => {
|
||||
const { past } = get();
|
||||
if (past.length === 0) return null;
|
||||
const previous = past[past.length - 1];
|
||||
const newPast = past.slice(0, -1);
|
||||
set({ past: newPast, future: [currentEntry, ...get().future] });
|
||||
return previous;
|
||||
},
|
||||
redo: (currentEntry: HistoryEntry) => {
|
||||
const { future } = get();
|
||||
if (future.length === 0) return null;
|
||||
const next = future[0];
|
||||
const newFuture = future.slice(1);
|
||||
set((state) => ({ past: [...state.past, currentEntry], future: newFuture }));
|
||||
return next;
|
||||
},
|
||||
clearHistory: () => {
|
||||
set({ past: [], future: [], gestureInProgress: false });
|
||||
},
|
||||
beginGesture: (currentEntry: HistoryEntry) => {
|
||||
const { gestureInProgress, maxSize } = get();
|
||||
if (gestureInProgress) return;
|
||||
set((state) => {
|
||||
const newPast = [...state.past, currentEntry];
|
||||
if (newPast.length > maxSize) newPast.shift();
|
||||
return { past: newPast, future: [], gestureInProgress: true };
|
||||
});
|
||||
},
|
||||
endGesture: () => {
|
||||
set({ gestureInProgress: false });
|
||||
},
|
||||
cancelGesture: () => {
|
||||
const { past, gestureInProgress } = get();
|
||||
if (!gestureInProgress || past.length === 0) {
|
||||
set({ gestureInProgress: false });
|
||||
return null;
|
||||
}
|
||||
const previous = past[past.length - 1];
|
||||
const newPast = past.slice(0, -1);
|
||||
set({ past: newPast, gestureInProgress: false });
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// --- Mock context providers ---
|
||||
|
||||
let mockModelStore: ReturnType<typeof createMockModelStore>;
|
||||
let mockSceneStore: ReturnType<typeof createMockSceneStore>;
|
||||
let mockHistoryStore: ReturnType<typeof createMockHistoryStore>;
|
||||
|
||||
jest.mock('../../stores/modelStore', () => ({
|
||||
useModelStoreApi: () => mockModelStore
|
||||
}));
|
||||
|
||||
jest.mock('../../stores/sceneStore', () => ({
|
||||
useSceneStore: jest.fn((selector) => {
|
||||
const state = {
|
||||
actions: mockSceneStore
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
useSceneStoreApi: () => mockSceneStore
|
||||
}));
|
||||
|
||||
jest.mock('../../stores/historyStore', () => ({
|
||||
useHistoryStore: (selector: (state: HistoryStoreState) => any) => {
|
||||
// Need to subscribe to trigger re-renders, but in tests we just call getState
|
||||
return selector(mockHistoryStore.getState());
|
||||
},
|
||||
useHistoryStoreApi: () => mockHistoryStore,
|
||||
extractModelData: (state: any) => ({
|
||||
version: state.version,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
colors: state.colors,
|
||||
icons: state.icons,
|
||||
items: state.items,
|
||||
views: state.views
|
||||
}),
|
||||
extractSceneData: (state: any) => ({
|
||||
connectors: state.connectors,
|
||||
textBoxes: state.textBoxes
|
||||
})
|
||||
}));
|
||||
|
||||
describe('useHistory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockModelStore.canRedo.mockReturnValue(false);
|
||||
mockModelStore.undo.mockReturnValue(true);
|
||||
mockModelStore.redo.mockReturnValue(true);
|
||||
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canRedo.mockReturnValue(false);
|
||||
mockSceneStore.undo.mockReturnValue(true);
|
||||
mockSceneStore.redo.mockReturnValue(true);
|
||||
mockModelStore = createMockModelStore(makeModel('initial'));
|
||||
mockSceneStore = createMockSceneStore(makeScene());
|
||||
mockHistoryStore = createMockHistoryStore();
|
||||
});
|
||||
|
||||
describe('undo/redo basic functionality', () => {
|
||||
describe('canUndo / canRedo', () => {
|
||||
it('should initialize with no undo/redo capability', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('should call saveToHistory on both stores', () => {
|
||||
it('canUndo should be true after saveSnapshot', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.saveToHistory();
|
||||
result.current.saveSnapshot();
|
||||
});
|
||||
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalled();
|
||||
expect(mockSceneStore.saveToHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform undo when model store has history', () => {
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockModelStore.undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform undo when scene store has history', () => {
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockSceneStore.undo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform redo when model store has future', () => {
|
||||
mockModelStore.canRedo.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.canRedo).toBe(true);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.redo();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockModelStore.redo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when undo is called with no history', () => {
|
||||
mockModelStore.undo.mockReturnValue(false);
|
||||
mockSceneStore.undo.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
const success = result.current.undo();
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when redo is called with no future', () => {
|
||||
mockModelStore.redo.mockReturnValue(false);
|
||||
mockSceneStore.redo.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
const success = result.current.redo();
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transaction functionality', () => {
|
||||
it('should save history before transaction and not during', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// This should not trigger saveToHistory due to transaction
|
||||
result.current.saveToHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// Should save once before transaction starts
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockSceneStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should track transaction state correctly', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
expect(result.current.isInTransaction()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
});
|
||||
|
||||
it('should prevent nested transactions', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// First transaction saves history
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nested transaction should not save again
|
||||
result.current.transaction(() => {
|
||||
// Still in transaction
|
||||
expect(result.current.isInTransaction()).toBe(true);
|
||||
});
|
||||
|
||||
// Should still be 1 save
|
||||
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle transaction errors gracefully', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
});
|
||||
}).toThrow('Test error');
|
||||
|
||||
// Transaction should be cleaned up
|
||||
expect(result.current.isInTransaction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history management', () => {
|
||||
it('should clear all history', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.clearHistory();
|
||||
});
|
||||
|
||||
expect(mockModelStore.clearHistory).toHaveBeenCalled();
|
||||
expect(mockSceneStore.clearHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should check both stores for undo capability', () => {
|
||||
// Only model has undo
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
|
||||
const { result: result1 } = renderHook(() => useHistory());
|
||||
expect(result1.current.canUndo).toBe(true);
|
||||
|
||||
// Only scene has undo
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
|
||||
// Re-render to pick up store changes
|
||||
const { result: result2 } = renderHook(() => useHistory());
|
||||
expect(result2.current.canUndo).toBe(true);
|
||||
|
||||
// Both have undo
|
||||
mockModelStore.canUndo.mockReturnValue(true);
|
||||
mockSceneStore.canUndo.mockReturnValue(true);
|
||||
|
||||
const { result: result3 } = renderHook(() => useHistory());
|
||||
expect(result3.current.canUndo).toBe(true);
|
||||
|
||||
// Neither has undo
|
||||
mockModelStore.canUndo.mockReturnValue(false);
|
||||
mockSceneStore.canUndo.mockReturnValue(false);
|
||||
|
||||
const { result: result4 } = renderHook(() => useHistory());
|
||||
expect(result4.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('should check both stores for redo capability', () => {
|
||||
// Only model has redo
|
||||
mockModelStore.canRedo.mockReturnValue(true);
|
||||
mockSceneStore.canRedo.mockReturnValue(false);
|
||||
|
||||
const { result: result1 } = renderHook(() => useHistory());
|
||||
expect(result1.current.canRedo).toBe(true);
|
||||
|
||||
// Only scene has redo
|
||||
mockModelStore.canRedo.mockReturnValue(false);
|
||||
mockSceneStore.canRedo.mockReturnValue(true);
|
||||
|
||||
const { result: result2 } = renderHook(() => useHistory());
|
||||
expect(result2.current.canRedo).toBe(true);
|
||||
expect(result2.current.canRedo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing store actions gracefully', () => {
|
||||
// Mock stores returning undefined actions
|
||||
const useModelStore = require('../../stores/modelStore').useModelStore;
|
||||
const useSceneStore = require('../../stores/sceneStore').useSceneStore;
|
||||
|
||||
useModelStore.mockImplementation((selector) => {
|
||||
const state = { actions: undefined };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
useSceneStore.mockImplementation((selector) => {
|
||||
const state = { actions: undefined };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
describe('saveSnapshot', () => {
|
||||
it('should capture model + scene and delegate to history store', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
// Should not throw and return safe defaults
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
expect(result.current.canRedo).toBe(false);
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.saveSnapshot();
|
||||
});
|
||||
|
||||
const state = mockHistoryStore.getState();
|
||||
expect(state.past).toHaveLength(1);
|
||||
expect(state.past[0].model.title).toBe('initial');
|
||||
expect(state.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear future on new snapshot', () => {
|
||||
// Seed some past and future
|
||||
const entry1 = makeEntry('entry1');
|
||||
const entry2 = makeEntry('entry2');
|
||||
mockHistoryStore.setState({
|
||||
past: [entry1],
|
||||
future: [entry2]
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.saveSnapshot();
|
||||
});
|
||||
|
||||
const state = mockHistoryStore.getState();
|
||||
expect(state.future).toHaveLength(0);
|
||||
expect(state.past).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('should return previous entry and apply to both stores atomically', () => {
|
||||
const previousEntry = makeEntry('previous');
|
||||
mockHistoryStore.setState({ past: [previousEntry] });
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
let success: boolean = false;
|
||||
act(() => {
|
||||
success = result.current.undo();
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
// Model store should now have the previous entry's data
|
||||
expect(mockModelStore.getState().title).toBe('previous');
|
||||
});
|
||||
|
||||
it('should return false when no history available', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
let success: boolean = true;
|
||||
act(() => {
|
||||
success = result.current.undo();
|
||||
});
|
||||
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it('should push current state to future on undo', () => {
|
||||
const previousEntry = makeEntry('previous');
|
||||
mockHistoryStore.setState({ past: [previousEntry] });
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.undo();
|
||||
});
|
||||
|
||||
const state = mockHistoryStore.getState();
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(1);
|
||||
expect(state.future[0].model.title).toBe('initial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redo', () => {
|
||||
it('should return next entry and apply', () => {
|
||||
const futureEntry = makeEntry('future');
|
||||
mockHistoryStore.setState({ future: [futureEntry] });
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
let success: boolean = false;
|
||||
act(() => {
|
||||
success = result.current.redo();
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(mockModelStore.getState().title).toBe('future');
|
||||
});
|
||||
|
||||
it('should return false when no future available', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
let success: boolean = true;
|
||||
act(() => {
|
||||
success = result.current.redo();
|
||||
});
|
||||
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it('should push current state to past on redo (not the redo entry)', () => {
|
||||
const futureEntry = makeEntry('future');
|
||||
mockHistoryStore.setState({ future: [futureEntry] });
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.redo();
|
||||
});
|
||||
|
||||
const state = mockHistoryStore.getState();
|
||||
// past should contain what was current before redo (title='initial'), not 'future'
|
||||
expect(state.past).toHaveLength(1);
|
||||
expect(state.past[0].model.title).toBe('initial');
|
||||
});
|
||||
|
||||
it('undo+redo+undo cycle should not cause data loss', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
// Save snapshot (captures 'initial'), then change model to 'changed'
|
||||
act(() => {
|
||||
result.current.saveSnapshot();
|
||||
});
|
||||
mockModelStore.getState().actions.set({ title: 'changed' });
|
||||
|
||||
// Undo: should restore 'initial'
|
||||
act(() => {
|
||||
result.current.undo();
|
||||
});
|
||||
expect(mockModelStore.getState().title).toBe('initial');
|
||||
|
||||
// Redo: should restore 'changed'
|
||||
act(() => {
|
||||
result.current.redo();
|
||||
});
|
||||
expect(mockModelStore.getState().title).toBe('changed');
|
||||
|
||||
// Undo again: should restore 'initial' (not get stuck on 'changed')
|
||||
act(() => {
|
||||
result.current.undo();
|
||||
});
|
||||
expect(mockModelStore.getState().title).toBe('initial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture lifecycle', () => {
|
||||
it('beginGesture should save snapshot and set gestureInProgress', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.beginGesture();
|
||||
});
|
||||
|
||||
const state = mockHistoryStore.getState();
|
||||
expect(state.gestureInProgress).toBe(true);
|
||||
expect(state.past).toHaveLength(1);
|
||||
expect(state.past[0].model.title).toBe('initial');
|
||||
});
|
||||
|
||||
it('endGesture should clear gestureInProgress', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.beginGesture();
|
||||
});
|
||||
|
||||
expect(mockHistoryStore.getState().gestureInProgress).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.endGesture();
|
||||
});
|
||||
|
||||
expect(mockHistoryStore.getState().gestureInProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('saveSnapshot should be blocked during gesture', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.beginGesture();
|
||||
});
|
||||
|
||||
// past has 1 entry from beginGesture
|
||||
expect(mockHistoryStore.getState().past).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.saveSnapshot();
|
||||
result.current.saveSnapshot();
|
||||
result.current.saveSnapshot();
|
||||
});
|
||||
|
||||
// Should still be 1 - saves blocked during gesture
|
||||
expect(mockHistoryStore.getState().past).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('cancelGesture should restore pre-gesture state', () => {
|
||||
// Set up initial state
|
||||
mockModelStore.getState().actions.set({ title: 'before-gesture' });
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.beginGesture();
|
||||
});
|
||||
|
||||
// Simulate changes during gesture
|
||||
mockModelStore.getState().actions.set({ title: 'during-gesture' });
|
||||
expect(mockModelStore.getState().title).toBe('during-gesture');
|
||||
|
||||
act(() => {
|
||||
result.current.cancelGesture();
|
||||
});
|
||||
|
||||
// Should be restored to pre-gesture state
|
||||
expect(mockModelStore.getState().title).toBe('before-gesture');
|
||||
expect(mockHistoryStore.getState().gestureInProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('isGestureInProgress should reflect state', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
expect(result.current.isGestureInProgress()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.beginGesture();
|
||||
});
|
||||
|
||||
expect(result.current.isGestureInProgress()).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.endGesture();
|
||||
});
|
||||
|
||||
expect(result.current.isGestureInProgress()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('should reset all history state', () => {
|
||||
mockHistoryStore.setState({
|
||||
past: [makeEntry('a'), makeEntry('b')],
|
||||
future: [makeEntry('c')],
|
||||
gestureInProgress: true
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
expect(result.current.undo()).toBe(false);
|
||||
expect(result.current.redo()).toBe(false);
|
||||
// These should not throw
|
||||
result.current.saveToHistory();
|
||||
result.current.clearHistory();
|
||||
result.current.transaction(() => {});
|
||||
});
|
||||
|
||||
// Restore mocks for other tests
|
||||
useModelStore.mockImplementation((selector) => {
|
||||
const state = { actions: mockModelStore };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
useSceneStore.mockImplementation((selector) => {
|
||||
const state = { actions: mockSceneStore };
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
});
|
||||
|
||||
it('should not save history during active transaction', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
|
||||
act(() => {
|
||||
result.current.transaction(() => {
|
||||
// Clear previous calls from transaction setup
|
||||
mockModelStore.saveToHistory.mockClear();
|
||||
mockSceneStore.saveToHistory.mockClear();
|
||||
|
||||
// Try to save during transaction
|
||||
result.current.saveToHistory();
|
||||
|
||||
// Should not have saved
|
||||
expect(mockModelStore.saveToHistory).not.toHaveBeenCalled();
|
||||
expect(mockSceneStore.saveToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
const state = mockHistoryStore.getState();
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(0);
|
||||
expect(state.gestureInProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,129 +1,94 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useModelStore } from 'src/stores/modelStore';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { useCallback } from 'react';
|
||||
import { useModelStoreApi } from 'src/stores/modelStore';
|
||||
import { useSceneStoreApi } from 'src/stores/sceneStore';
|
||||
import {
|
||||
useHistoryStore,
|
||||
useHistoryStoreApi,
|
||||
extractModelData,
|
||||
extractSceneData
|
||||
} from 'src/stores/historyStore';
|
||||
import type { HistoryEntry } from 'src/stores/historyStore';
|
||||
|
||||
export const useHistory = () => {
|
||||
// Track if we're in a transaction to prevent nested history saves
|
||||
const transactionInProgress = useRef(false);
|
||||
const modelStoreApi = useModelStoreApi();
|
||||
const sceneStoreApi = useSceneStoreApi();
|
||||
const historyStoreApi = useHistoryStoreApi();
|
||||
|
||||
// Get store actions
|
||||
const modelActions = useModelStore((state) => {
|
||||
return state?.actions;
|
||||
});
|
||||
const sceneActions = useSceneStore((state) => {
|
||||
return state?.actions;
|
||||
});
|
||||
const canUndo = useHistoryStore((s) => s.past.length > 0);
|
||||
const canRedo = useHistoryStore((s) => s.future.length > 0);
|
||||
|
||||
// Get history state
|
||||
const modelCanUndo = useModelStore((state) => {
|
||||
return state?.actions?.canUndo?.() ?? false;
|
||||
});
|
||||
const sceneCanUndo = useSceneStore((state) => {
|
||||
return state?.actions?.canUndo?.() ?? false;
|
||||
});
|
||||
const modelCanRedo = useModelStore((state) => {
|
||||
return state?.actions?.canRedo?.() ?? false;
|
||||
});
|
||||
const sceneCanRedo = useSceneStore((state) => {
|
||||
return state?.actions?.canRedo?.() ?? false;
|
||||
});
|
||||
const getCurrentEntry = useCallback((): HistoryEntry => {
|
||||
const model = modelStoreApi.getState();
|
||||
const scene = sceneStoreApi.getState();
|
||||
return {
|
||||
model: extractModelData(model),
|
||||
scene: extractSceneData(scene)
|
||||
};
|
||||
}, [modelStoreApi, sceneStoreApi]);
|
||||
|
||||
// Derived values
|
||||
const canUndo = modelCanUndo || sceneCanUndo;
|
||||
const canRedo = modelCanRedo || sceneCanRedo;
|
||||
|
||||
// Transaction wrapper - groups multiple operations into single history entry
|
||||
const transaction = useCallback(
|
||||
(operations: () => void) => {
|
||||
if (!modelActions || !sceneActions) return;
|
||||
|
||||
// Prevent nested transactions
|
||||
if (transactionInProgress.current) {
|
||||
operations();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current state before transaction
|
||||
modelActions.saveToHistory();
|
||||
sceneActions.saveToHistory();
|
||||
|
||||
// Mark transaction as in progress
|
||||
transactionInProgress.current = true;
|
||||
|
||||
try {
|
||||
// Execute all operations without saving intermediate history
|
||||
operations();
|
||||
} finally {
|
||||
// Always reset transaction state
|
||||
transactionInProgress.current = false;
|
||||
}
|
||||
|
||||
// Note: We don't save after transaction - the final state is already current
|
||||
const applyEntry = useCallback(
|
||||
(entry: HistoryEntry) => {
|
||||
modelStoreApi.getState().actions.set(entry.model);
|
||||
sceneStoreApi.getState().actions.set(entry.scene);
|
||||
},
|
||||
[modelActions, sceneActions]
|
||||
[modelStoreApi, sceneStoreApi]
|
||||
);
|
||||
|
||||
const saveSnapshot = useCallback(() => {
|
||||
historyStoreApi.getState().actions.saveSnapshot(getCurrentEntry());
|
||||
}, [historyStoreApi, getCurrentEntry]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (!modelActions || !sceneActions) return false;
|
||||
|
||||
let undoPerformed = false;
|
||||
|
||||
// Try to undo model first, then scene
|
||||
if (modelActions.canUndo()) {
|
||||
undoPerformed = modelActions.undo() || undoPerformed;
|
||||
const entry = historyStoreApi.getState().actions.undo(getCurrentEntry());
|
||||
if (entry) {
|
||||
applyEntry(entry);
|
||||
return true;
|
||||
}
|
||||
if (sceneActions.canUndo()) {
|
||||
undoPerformed = sceneActions.undo() || undoPerformed;
|
||||
}
|
||||
|
||||
return undoPerformed;
|
||||
}, [modelActions, sceneActions]);
|
||||
return false;
|
||||
}, [historyStoreApi, getCurrentEntry, applyEntry]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (!modelActions || !sceneActions) return false;
|
||||
|
||||
let redoPerformed = false;
|
||||
|
||||
// Try to redo model first, then scene
|
||||
if (modelActions.canRedo()) {
|
||||
redoPerformed = modelActions.redo() || redoPerformed;
|
||||
const entry = historyStoreApi.getState().actions.redo(getCurrentEntry());
|
||||
if (entry) {
|
||||
applyEntry(entry);
|
||||
return true;
|
||||
}
|
||||
if (sceneActions.canRedo()) {
|
||||
redoPerformed = sceneActions.redo() || redoPerformed;
|
||||
}
|
||||
|
||||
return redoPerformed;
|
||||
}, [modelActions, sceneActions]);
|
||||
|
||||
const saveToHistory = useCallback(() => {
|
||||
// Don't save during transactions
|
||||
if (transactionInProgress.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modelActions || !sceneActions) return;
|
||||
|
||||
modelActions.saveToHistory();
|
||||
sceneActions.saveToHistory();
|
||||
}, [modelActions, sceneActions]);
|
||||
return false;
|
||||
}, [historyStoreApi, getCurrentEntry, applyEntry]);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
if (!modelActions || !sceneActions) return;
|
||||
historyStoreApi.getState().actions.clearHistory();
|
||||
}, [historyStoreApi]);
|
||||
|
||||
modelActions.clearHistory();
|
||||
sceneActions.clearHistory();
|
||||
}, [modelActions, sceneActions]);
|
||||
const beginGesture = useCallback(() => {
|
||||
historyStoreApi.getState().actions.beginGesture(getCurrentEntry());
|
||||
}, [historyStoreApi, getCurrentEntry]);
|
||||
|
||||
const endGesture = useCallback(() => {
|
||||
historyStoreApi.getState().actions.endGesture();
|
||||
}, [historyStoreApi]);
|
||||
|
||||
const cancelGesture = useCallback(() => {
|
||||
const entry = historyStoreApi.getState().actions.cancelGesture();
|
||||
if (entry) {
|
||||
applyEntry(entry);
|
||||
}
|
||||
}, [historyStoreApi, applyEntry]);
|
||||
|
||||
const isGestureInProgress = useCallback(() => {
|
||||
return historyStoreApi.getState().gestureInProgress;
|
||||
}, [historyStoreApi]);
|
||||
|
||||
return {
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
saveToHistory,
|
||||
saveSnapshot,
|
||||
clearHistory,
|
||||
transaction,
|
||||
isInTransaction: () => {
|
||||
return transactionInProgress.current;
|
||||
}
|
||||
beginGesture,
|
||||
endGesture,
|
||||
cancelGesture,
|
||||
isGestureInProgress
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { InitialData, IconCollectionState } from 'src/types';
|
||||
import { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
|
||||
import {
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
getItemByIdOrThrow
|
||||
} from 'src/utils';
|
||||
import * as reducers from 'src/stores/reducers';
|
||||
import { useModelStore } from 'src/stores/modelStore';
|
||||
import { useModelStore, useModelStoreApi } from 'src/stores/modelStore';
|
||||
import { useView } from 'src/hooks/useView';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { modelSchema } from 'src/schemas/model';
|
||||
@@ -17,9 +18,12 @@ import { modelSchema } from 'src/schemas/model';
|
||||
export const useInitialDataManager = () => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const prevInitialData = useRef<InitialData | undefined>(undefined);
|
||||
const model = useModelStore((state) => {
|
||||
return state;
|
||||
});
|
||||
const modelActions = useModelStore((state) => state.actions);
|
||||
const { icons, colors } = useModelStore(
|
||||
(state) => ({ icons: state.icons, colors: state.colors }),
|
||||
shallow
|
||||
);
|
||||
const modelStoreApi = useModelStoreApi();
|
||||
const uiStateActions = useUiStateStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
@@ -105,7 +109,7 @@ export const useInitialDataManager = () => {
|
||||
}
|
||||
|
||||
prevInitialData.current = initialData;
|
||||
model.actions.set(initialData);
|
||||
modelActions.set(initialData);
|
||||
|
||||
const view = getItemByIdOrThrow(
|
||||
initialData.views,
|
||||
@@ -143,13 +147,13 @@ export const useInitialDataManager = () => {
|
||||
|
||||
setIsReady(true);
|
||||
},
|
||||
[changeView, model.actions, rendererEl, uiStateActions, editorMode]
|
||||
[changeView, modelActions, rendererEl, uiStateActions, editorMode]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
load({ ...INITIAL_DATA, icons: model.icons, colors: model.colors });
|
||||
load({ ...INITIAL_DATA, icons, colors });
|
||||
uiStateActions.resetUiState();
|
||||
}, [load, model.icons, model.colors, uiStateActions]);
|
||||
}, [load, icons, colors, uiStateActions]);
|
||||
|
||||
return {
|
||||
load,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import {
|
||||
ModelItem,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RECTANGLE_DEFAULTS,
|
||||
TEXTBOX_DEFAULTS
|
||||
} from 'src/config';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
|
||||
export const useScene = () => {
|
||||
const { views, colors, icons, items, version, title, description } =
|
||||
@@ -42,10 +43,10 @@ export const useScene = () => {
|
||||
shallow
|
||||
);
|
||||
const currentViewId = useUiStateStore((state) => state.view);
|
||||
const transactionInProgress = useRef(false);
|
||||
|
||||
const modelStoreApi = useModelStoreApi();
|
||||
const sceneStoreApi = useSceneStoreApi();
|
||||
const { saveSnapshot, beginGesture, endGesture } = useHistory();
|
||||
|
||||
const currentView = useMemo(() => {
|
||||
if (!views || !currentViewId) {
|
||||
@@ -138,59 +139,45 @@ export const useScene = () => {
|
||||
|
||||
const setState = useCallback(
|
||||
(newState: State) => {
|
||||
modelStoreApi.getState().actions.set(newState.model, true);
|
||||
sceneStoreApi.getState().actions.set(newState.scene, true);
|
||||
modelStoreApi.getState().actions.set(newState.model);
|
||||
sceneStoreApi.getState().actions.set(newState.scene);
|
||||
},
|
||||
[modelStoreApi, sceneStoreApi]
|
||||
);
|
||||
|
||||
const saveToHistoryBeforeChange = useCallback(() => {
|
||||
if (transactionInProgress.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
modelStoreApi.getState().actions.saveToHistory();
|
||||
sceneStoreApi.getState().actions.saveToHistory();
|
||||
}, [modelStoreApi, sceneStoreApi]);
|
||||
|
||||
const createModelItem = useCallback(
|
||||
(newModelItem: ModelItem) => {
|
||||
if (!transactionInProgress.current) {
|
||||
saveToHistoryBeforeChange();
|
||||
}
|
||||
|
||||
saveSnapshot();
|
||||
const newState = reducers.createModelItem(newModelItem, getState());
|
||||
setState(newState);
|
||||
return newState;
|
||||
},
|
||||
[getState, setState, saveToHistoryBeforeChange]
|
||||
[getState, setState, saveSnapshot]
|
||||
);
|
||||
|
||||
const updateModelItem = useCallback(
|
||||
(id: string, updates: Partial<ModelItem>) => {
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.updateModelItem(id, updates, getState());
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, saveToHistoryBeforeChange]
|
||||
[getState, setState, saveSnapshot]
|
||||
);
|
||||
|
||||
const deleteModelItem = useCallback(
|
||||
(id: string) => {
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.deleteModelItem(id, getState());
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, saveToHistoryBeforeChange]
|
||||
[getState, setState, saveSnapshot]
|
||||
);
|
||||
|
||||
const createViewItem = useCallback(
|
||||
(newViewItem: ViewItem, currentState?: State) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
if (!transactionInProgress.current) {
|
||||
saveToHistoryBeforeChange();
|
||||
}
|
||||
saveSnapshot();
|
||||
|
||||
const stateToUse = currentState || getState();
|
||||
|
||||
@@ -202,16 +189,14 @@ export const useScene = () => {
|
||||
setState(newState);
|
||||
return newState;
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const updateViewItem = useCallback(
|
||||
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
|
||||
if (!currentViewId) return getState();
|
||||
|
||||
if (!transactionInProgress.current) {
|
||||
saveToHistoryBeforeChange();
|
||||
}
|
||||
saveSnapshot();
|
||||
|
||||
const stateToUse = currentState || getState();
|
||||
const newState = reducers.view({
|
||||
@@ -222,14 +207,14 @@ export const useScene = () => {
|
||||
setState(newState);
|
||||
return newState;
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const deleteViewItem = useCallback(
|
||||
(id: string) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'DELETE_VIEWITEM',
|
||||
payload: id,
|
||||
@@ -237,14 +222,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const createConnector = useCallback(
|
||||
(newConnector: Connector) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'CREATE_CONNECTOR',
|
||||
payload: newConnector,
|
||||
@@ -252,14 +237,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const updateConnector = useCallback(
|
||||
(id: string, updates: Partial<Connector>) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'UPDATE_CONNECTOR',
|
||||
payload: { id, ...updates },
|
||||
@@ -267,14 +252,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const deleteConnector = useCallback(
|
||||
(id: string) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'DELETE_CONNECTOR',
|
||||
payload: id,
|
||||
@@ -282,14 +267,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const createTextBox = useCallback(
|
||||
(newTextBox: TextBox) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'CREATE_TEXTBOX',
|
||||
payload: newTextBox,
|
||||
@@ -297,16 +282,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const updateTextBox = useCallback(
|
||||
(id: string, updates: Partial<TextBox>, currentState?: State) => {
|
||||
if (!currentViewId) return currentState || getState();
|
||||
|
||||
if (!transactionInProgress.current) {
|
||||
saveToHistoryBeforeChange();
|
||||
}
|
||||
saveSnapshot();
|
||||
|
||||
const stateToUse = currentState || getState();
|
||||
const newState = reducers.view({
|
||||
@@ -317,14 +300,14 @@ export const useScene = () => {
|
||||
setState(newState);
|
||||
return newState;
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const deleteTextBox = useCallback(
|
||||
(id: string) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'DELETE_TEXTBOX',
|
||||
payload: id,
|
||||
@@ -332,14 +315,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const createRectangle = useCallback(
|
||||
(newRectangle: Rectangle) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'CREATE_RECTANGLE',
|
||||
payload: newRectangle,
|
||||
@@ -347,16 +330,14 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const updateRectangle = useCallback(
|
||||
(id: string, updates: Partial<Rectangle>, currentState?: State) => {
|
||||
if (!currentViewId) return currentState || getState();
|
||||
|
||||
if (!transactionInProgress.current) {
|
||||
saveToHistoryBeforeChange();
|
||||
}
|
||||
saveSnapshot();
|
||||
|
||||
const stateToUse = currentState || getState();
|
||||
const newState = reducers.view({
|
||||
@@ -367,14 +348,14 @@ export const useScene = () => {
|
||||
setState(newState);
|
||||
return newState;
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const deleteRectangle = useCallback(
|
||||
(id: string) => {
|
||||
if (!currentViewId) return;
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
saveSnapshot();
|
||||
const newState = reducers.view({
|
||||
action: 'DELETE_RECTANGLE',
|
||||
payload: id,
|
||||
@@ -382,32 +363,24 @@ export const useScene = () => {
|
||||
});
|
||||
setState(newState);
|
||||
},
|
||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
||||
[getState, setState, currentViewId, saveSnapshot]
|
||||
);
|
||||
|
||||
const transaction = useCallback(
|
||||
(operations: () => void) => {
|
||||
if (transactionInProgress.current) {
|
||||
operations();
|
||||
return;
|
||||
}
|
||||
|
||||
saveToHistoryBeforeChange();
|
||||
transactionInProgress.current = true;
|
||||
|
||||
beginGesture();
|
||||
try {
|
||||
operations();
|
||||
} finally {
|
||||
transactionInProgress.current = false;
|
||||
endGesture();
|
||||
}
|
||||
},
|
||||
[saveToHistoryBeforeChange]
|
||||
[beginGesture, endGesture]
|
||||
);
|
||||
|
||||
const placeIcon = useCallback(
|
||||
(params: { modelItem: ModelItem; viewItem: ViewItem }) => {
|
||||
saveToHistoryBeforeChange();
|
||||
transactionInProgress.current = true;
|
||||
beginGesture();
|
||||
|
||||
try {
|
||||
const stateAfterModelItem = createModelItem(params.modelItem);
|
||||
@@ -416,10 +389,10 @@ export const useScene = () => {
|
||||
createViewItem(params.viewItem, stateAfterModelItem);
|
||||
}
|
||||
} finally {
|
||||
transactionInProgress.current = false;
|
||||
endGesture();
|
||||
}
|
||||
},
|
||||
[createModelItem, createViewItem, saveToHistoryBeforeChange]
|
||||
[createModelItem, createViewItem, beginGesture, endGesture]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import * as reducers from 'src/stores/reducers';
|
||||
import { Model } from 'src/types';
|
||||
import { INITIAL_SCENE_STATE } from 'src/config';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
|
||||
export const useView = () => {
|
||||
const uiStateActions = useUiStateStore((state) => {
|
||||
@@ -14,6 +15,8 @@ export const useView = () => {
|
||||
return state.actions;
|
||||
});
|
||||
|
||||
const { clearHistory } = useHistory();
|
||||
|
||||
const changeView = useCallback(
|
||||
(viewId: string, model: Model) => {
|
||||
const newState = reducers.view({
|
||||
@@ -24,8 +27,9 @@ export const useView = () => {
|
||||
|
||||
sceneActions.set(newState.scene);
|
||||
uiStateActions.setView(viewId);
|
||||
clearHistory();
|
||||
},
|
||||
[uiStateActions, sceneActions]
|
||||
[uiStateActions, sceneActions, clearHistory]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -61,7 +61,7 @@ export const Connector: ModeActions = {
|
||||
}
|
||||
}
|
||||
},
|
||||
mousedown: ({ uiState, scene, isRendererInteraction }) => {
|
||||
mousedown: ({ uiState, scene, isRendererInteraction, history }) => {
|
||||
if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return;
|
||||
|
||||
const itemAtTile = getItemAtTile({
|
||||
@@ -72,8 +72,9 @@ export const Connector: ModeActions = {
|
||||
if (uiState.connectorInteractionMode === 'click') {
|
||||
// Click mode: handle first and second clicks
|
||||
if (!uiState.mode.startAnchor) {
|
||||
// First click: store the start position
|
||||
const startAnchor = itemAtTile?.type === 'ITEM'
|
||||
// First click: begin gesture and store the start position
|
||||
history.beginGesture();
|
||||
const startAnchor = itemAtTile?.type === 'ITEM'
|
||||
? { itemId: itemAtTile.id }
|
||||
: { tile: uiState.mouse.position.tile };
|
||||
|
||||
@@ -144,7 +145,8 @@ export const Connector: ModeActions = {
|
||||
// Don't delete connectors to empty space - they're valid
|
||||
// Only validate minimum path length will be handled by the update
|
||||
|
||||
// Reset for next connection
|
||||
// End gesture and reset for next connection
|
||||
history.endGesture();
|
||||
uiState.actions.setMode({
|
||||
type: 'CONNECTOR',
|
||||
showCursor: true,
|
||||
@@ -155,7 +157,8 @@ export const Connector: ModeActions = {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Drag mode: original behavior
|
||||
// Drag mode: begin gesture before creating
|
||||
history.beginGesture();
|
||||
const newConnector: ConnectorI = {
|
||||
id: generateId(),
|
||||
color: scene.colors[0].id,
|
||||
@@ -183,7 +186,7 @@ export const Connector: ModeActions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
mouseup: ({ uiState, scene }) => {
|
||||
mouseup: ({ uiState, scene, history }) => {
|
||||
if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return;
|
||||
|
||||
// Only handle mouseup for drag mode
|
||||
@@ -191,6 +194,7 @@ export const Connector: ModeActions = {
|
||||
// Don't delete connectors to empty space - they're valid
|
||||
// Validation is handled in the reducer layer
|
||||
|
||||
history.endGesture();
|
||||
uiState.actions.setMode({
|
||||
type: 'CONNECTOR',
|
||||
showCursor: true,
|
||||
|
||||
@@ -50,40 +50,36 @@ const dragItems = (
|
||||
const hasUpdates = newTiles || textBoxRefs.length > 0 || rectangleRefs.length > 0;
|
||||
|
||||
if (hasUpdates) {
|
||||
// Wrap ALL updates in a single transaction with state chaining
|
||||
// This ensures each update builds on the previous one's state
|
||||
scene.transaction(() => {
|
||||
let currentState: State | undefined;
|
||||
let currentState: State | undefined;
|
||||
|
||||
// 1. Update nodes
|
||||
if (newTiles) {
|
||||
itemRefs.forEach((item, index) => {
|
||||
currentState = scene.updateViewItem(item.id, {
|
||||
tile: newTiles[index]
|
||||
}, currentState);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Update textboxes (chained from node state)
|
||||
textBoxRefs.forEach((item) => {
|
||||
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
|
||||
currentState = scene.updateTextBox(item.id, {
|
||||
tile: CoordsUtils.add(textBox.tile, delta)
|
||||
// 1. Update nodes
|
||||
if (newTiles) {
|
||||
itemRefs.forEach((item, index) => {
|
||||
currentState = scene.updateViewItem(item.id, {
|
||||
tile: newTiles[index]
|
||||
}, currentState);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update rectangles (chained from textbox state)
|
||||
rectangleRefs.forEach((item) => {
|
||||
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
|
||||
currentState = scene.updateRectangle(item.id, {
|
||||
from: CoordsUtils.add(rectangle.from, delta),
|
||||
to: CoordsUtils.add(rectangle.to, delta)
|
||||
}, currentState);
|
||||
});
|
||||
// 2. Update textboxes (chained from node state)
|
||||
textBoxRefs.forEach((item) => {
|
||||
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
|
||||
currentState = scene.updateTextBox(item.id, {
|
||||
tile: CoordsUtils.add(textBox.tile, delta)
|
||||
}, currentState);
|
||||
});
|
||||
|
||||
// 3. Update rectangles (chained from textbox state)
|
||||
rectangleRefs.forEach((item) => {
|
||||
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
|
||||
currentState = scene.updateRectangle(item.id, {
|
||||
from: CoordsUtils.add(rectangle.from, delta),
|
||||
to: CoordsUtils.add(rectangle.to, delta)
|
||||
}, currentState);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle connector anchors separately (they have different update logic)
|
||||
// Handle connector anchors (gesture already in progress from entry)
|
||||
anchorRefs.forEach((item) => {
|
||||
const connector = getAnchorParent(item.id, scene.connectors);
|
||||
|
||||
@@ -125,13 +121,15 @@ const dragItems = (
|
||||
};
|
||||
|
||||
export const DragItems: ModeActions = {
|
||||
entry: ({ uiState, rendererRef }) => {
|
||||
entry: ({ uiState, rendererRef, history }) => {
|
||||
if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return;
|
||||
|
||||
history.beginGesture();
|
||||
const renderer = rendererRef;
|
||||
renderer.style.userSelect = 'none';
|
||||
},
|
||||
exit: ({ rendererRef }) => {
|
||||
exit: ({ rendererRef, history }) => {
|
||||
history.endGesture();
|
||||
const renderer = rendererRef;
|
||||
renderer.style.userSelect = 'auto';
|
||||
},
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { produce } from 'immer';
|
||||
import { ModeActions, ItemReference, Coords } from 'src/types';
|
||||
import { ModeActions, ItemReference, Coords, SceneItemsSnapshot } from 'src/types';
|
||||
import { screenToIso, isPointInPolygon } from 'src/utils';
|
||||
|
||||
// Helper to find all items whose centers are within the freehand polygon
|
||||
const getItemsInFreehandBounds = (
|
||||
pathTiles: Coords[],
|
||||
scene: any
|
||||
scene: SceneItemsSnapshot
|
||||
): ItemReference[] => {
|
||||
const items: ItemReference[] = [];
|
||||
|
||||
if (pathTiles.length < 3) return items;
|
||||
|
||||
// Check all nodes/items
|
||||
scene.items.forEach((item: any) => {
|
||||
scene.items.forEach((item) => {
|
||||
if (isPointInPolygon(item.tile, pathTiles)) {
|
||||
items.push({ type: 'ITEM', id: item.id });
|
||||
}
|
||||
});
|
||||
|
||||
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
||||
scene.rectangles.forEach((rectangle: any) => {
|
||||
scene.rectangles.forEach((rectangle) => {
|
||||
const corners = [
|
||||
rectangle.from,
|
||||
{ x: rectangle.to.x, y: rectangle.from.y },
|
||||
@@ -36,7 +36,7 @@ const getItemsInFreehandBounds = (
|
||||
});
|
||||
|
||||
// Check all text boxes
|
||||
scene.textBoxes.forEach((textBox: any) => {
|
||||
scene.textBoxes.forEach((textBox) => {
|
||||
if (isPointInPolygon(textBox.tile, pathTiles)) {
|
||||
items.push({ type: 'TEXTBOX', id: textBox.id });
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { produce } from 'immer';
|
||||
import { ModeActions, ItemReference } from 'src/types';
|
||||
import { CoordsUtils, isWithinBounds, hasMovedTile } from 'src/utils';
|
||||
import { ModeActions, ItemReference, Coords, SceneItemsSnapshot } from 'src/types';
|
||||
import { isWithinBounds, hasMovedTile } from 'src/utils';
|
||||
|
||||
// Helper to find all items within the lasso bounds
|
||||
const getItemsInBounds = (
|
||||
startTile: { x: number; y: number },
|
||||
endTile: { x: number; y: number },
|
||||
scene: any
|
||||
startTile: Coords,
|
||||
endTile: Coords,
|
||||
scene: SceneItemsSnapshot
|
||||
): ItemReference[] => {
|
||||
const items: ItemReference[] = [];
|
||||
|
||||
// Check all nodes/items
|
||||
scene.items.forEach((item: any) => {
|
||||
scene.items.forEach((item) => {
|
||||
if (isWithinBounds(item.tile, [startTile, endTile])) {
|
||||
items.push({ type: 'ITEM', id: item.id });
|
||||
}
|
||||
});
|
||||
|
||||
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
||||
scene.rectangles.forEach((rectangle: any) => {
|
||||
scene.rectangles.forEach((rectangle) => {
|
||||
const corners = [
|
||||
rectangle.from,
|
||||
{ x: rectangle.to.x, y: rectangle.from.y },
|
||||
@@ -37,7 +37,7 @@ const getItemsInBounds = (
|
||||
});
|
||||
|
||||
// Check all text boxes
|
||||
scene.textBoxes.forEach((textBox: any) => {
|
||||
scene.textBoxes.forEach((textBox) => {
|
||||
if (isWithinBounds(textBox.tile, [startTile, endTile])) {
|
||||
items.push({ type: 'TEXTBOX', id: textBox.id });
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ export const DrawRectangle: ModeActions = {
|
||||
to: uiState.mouse.position.tile
|
||||
});
|
||||
},
|
||||
mousedown: ({ uiState, scene, isRendererInteraction }) => {
|
||||
mousedown: ({ uiState, scene, isRendererInteraction, history }) => {
|
||||
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction)
|
||||
return;
|
||||
|
||||
history.beginGesture();
|
||||
const newRectangleId = generateId();
|
||||
|
||||
scene.createRectangle({
|
||||
@@ -41,9 +42,10 @@ export const DrawRectangle: ModeActions = {
|
||||
|
||||
uiState.actions.setMode(newMode);
|
||||
},
|
||||
mouseup: ({ uiState }) => {
|
||||
mouseup: ({ uiState, history }) => {
|
||||
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return;
|
||||
|
||||
history.endGesture();
|
||||
uiState.actions.setMode({
|
||||
type: 'CURSOR',
|
||||
showCursor: true,
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
import { ModeActions } from 'src/types';
|
||||
|
||||
export const TransformRectangle: ModeActions = {
|
||||
entry: () => {},
|
||||
entry: ({ history }) => {
|
||||
history.beginGesture();
|
||||
},
|
||||
exit: () => {},
|
||||
mousemove: ({ uiState, scene }) => {
|
||||
if (
|
||||
@@ -63,9 +65,10 @@ export const TransformRectangle: ModeActions = {
|
||||
mousedown: () => {
|
||||
// MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`)
|
||||
},
|
||||
mouseup: ({ uiState }) => {
|
||||
mouseup: ({ uiState, history }) => {
|
||||
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
|
||||
|
||||
history.endGesture();
|
||||
uiState.actions.setMode({
|
||||
type: 'CURSOR',
|
||||
mousedownItem: null,
|
||||
|
||||
@@ -15,12 +15,13 @@ export const TextBox: ModeActions = {
|
||||
tile: uiState.mouse.position.tile
|
||||
});
|
||||
},
|
||||
mouseup: ({ uiState, scene, isRendererInteraction }) => {
|
||||
mouseup: ({ uiState, scene, isRendererInteraction, history }) => {
|
||||
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
|
||||
|
||||
if (!isRendererInteraction) {
|
||||
scene.deleteTextBox(uiState.mode.id);
|
||||
history.cancelGesture();
|
||||
} else {
|
||||
history.endGesture();
|
||||
uiState.actions.setItemControls({
|
||||
type: 'TEXTBOX',
|
||||
id: uiState.mode.id
|
||||
|
||||
@@ -106,7 +106,7 @@ export const useInteractionManager = () => {
|
||||
const modelStoreApi = useModelStoreApi();
|
||||
const scene = useScene();
|
||||
const { size: rendererSize } = useResizeObserver(rendererEl);
|
||||
const { undo, redo, canUndo, canRedo } = useHistory();
|
||||
const { undo, redo, canUndo, canRedo, beginGesture, endGesture, cancelGesture, isGestureInProgress } = useHistory();
|
||||
const { createTextBox } = scene;
|
||||
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
|
||||
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
|
||||
@@ -131,7 +131,7 @@ export const useInteractionManager = () => {
|
||||
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
|
||||
|
||||
if (isConnectionInProgress && connectorMode.id) {
|
||||
scene.deleteConnector(connectorMode.id);
|
||||
cancelGesture();
|
||||
|
||||
uiState.actions.setMode({
|
||||
type: 'CONNECTOR',
|
||||
@@ -158,9 +158,13 @@ export const useInteractionManager = () => {
|
||||
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Block undo/redo during active interactions or gestures
|
||||
const blockingModes = ['CONNECTOR', 'RECTANGLE.DRAW', 'RECTANGLE.TRANSFORM', 'TEXTBOX', 'DRAG_ITEMS'];
|
||||
const isUndoBlocked = blockingModes.includes(uiState.mode.type) || isGestureInProgress();
|
||||
|
||||
if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) {
|
||||
if (canUndo && !isUndoBlocked) {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
@@ -171,7 +175,7 @@ export const useInteractionManager = () => {
|
||||
(e.key.toLowerCase() === 'z' && e.shiftKey))
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (canRedo) {
|
||||
if (canRedo && !isUndoBlocked) {
|
||||
redo();
|
||||
}
|
||||
}
|
||||
@@ -230,6 +234,7 @@ export const useInteractionManager = () => {
|
||||
});
|
||||
} else if (hotkeyMapping.text && key === hotkeyMapping.text) {
|
||||
e.preventDefault();
|
||||
beginGesture();
|
||||
const textBoxId = generateId();
|
||||
createTextBox({
|
||||
...TEXTBOX_DEFAULTS,
|
||||
@@ -265,7 +270,7 @@ export const useInteractionManager = () => {
|
||||
return () => {
|
||||
return window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene]);
|
||||
}, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene, beginGesture, cancelGesture, isGestureInProgress]);
|
||||
|
||||
const processMouseUpdate = useCallback(
|
||||
(nextMouse: Mouse, e: SlimMouseEvent) => {
|
||||
@@ -284,6 +289,7 @@ export const useInteractionManager = () => {
|
||||
const baseState: State = {
|
||||
model,
|
||||
scene,
|
||||
history: { beginGesture, endGesture, cancelGesture, isGestureInProgress },
|
||||
uiState,
|
||||
rendererRef: rendererRef.current,
|
||||
rendererSize,
|
||||
@@ -307,7 +313,7 @@ export const useInteractionManager = () => {
|
||||
modeFunction(baseState);
|
||||
reducerTypeRef.current = uiState.mode.type;
|
||||
},
|
||||
[uiStateApi, modelStoreApi, scene, rendererSize]
|
||||
[uiStateApi, modelStoreApi, scene, rendererSize, beginGesture, endGesture, cancelGesture, isGestureInProgress]
|
||||
);
|
||||
|
||||
const onMouseEvent = useCallback(
|
||||
|
||||
361
packages/fossflow-lib/src/stores/__tests__/historyStore.test.tsx
Normal file
361
packages/fossflow-lib/src/stores/__tests__/historyStore.test.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
HistoryProvider,
|
||||
useHistoryStore,
|
||||
useHistoryStoreApi
|
||||
} from '../historyStore';
|
||||
import type { HistoryEntry } from '../historyStore';
|
||||
|
||||
const makeEntry = (id: number): HistoryEntry => ({
|
||||
model: {
|
||||
version: `v${id}`,
|
||||
title: `Model ${id}`,
|
||||
colors: [],
|
||||
icons: [],
|
||||
items: [],
|
||||
views: []
|
||||
},
|
||||
scene: {
|
||||
connectors: {},
|
||||
textBoxes: {}
|
||||
}
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<HistoryProvider>{children}</HistoryProvider>
|
||||
);
|
||||
|
||||
describe('historyStore', () => {
|
||||
describe('saveSnapshot', () => {
|
||||
it('adds entry to past and clears future', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past),
|
||||
future: useHistoryStore((s) => s.future)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
|
||||
});
|
||||
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
expect(result.current.past[0].model.title).toBe('Model 1');
|
||||
expect(result.current.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enforces max size', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(i));
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.past).toHaveLength(50);
|
||||
// Oldest entries should have been shifted off
|
||||
expect(result.current.past[0].model.title).toBe('Model 5');
|
||||
});
|
||||
|
||||
it('skips save during gesture', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(0));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
|
||||
});
|
||||
|
||||
// Only the beginGesture entry should be there
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
expect(result.current.past[0].model.title).toBe('Model 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('returns previous entry and moves current to future', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past),
|
||||
future: useHistoryStore((s) => s.future)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
|
||||
});
|
||||
|
||||
let undone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
undone = result.current.api.getState().actions.undo(makeEntry(2));
|
||||
});
|
||||
|
||||
expect(undone).not.toBeNull();
|
||||
expect(undone!.model.title).toBe('Model 1');
|
||||
expect(result.current.past).toHaveLength(0);
|
||||
expect(result.current.future).toHaveLength(1);
|
||||
expect(result.current.future[0].model.title).toBe('Model 2');
|
||||
});
|
||||
|
||||
it('returns null when past is empty', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi()
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
let undone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
undone = result.current.api.getState().actions.undo(makeEntry(1));
|
||||
});
|
||||
|
||||
expect(undone).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('redo', () => {
|
||||
it('returns next entry and pushes current to past', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past),
|
||||
future: useHistoryStore((s) => s.future)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
// Build up: save then undo
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
|
||||
});
|
||||
act(() => {
|
||||
result.current.api.getState().actions.undo(makeEntry(2));
|
||||
});
|
||||
|
||||
// Now redo, passing current state (makeEntry(1) since undo restored it)
|
||||
let redone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
redone = result.current.api.getState().actions.redo(makeEntry(1));
|
||||
});
|
||||
|
||||
expect(redone).not.toBeNull();
|
||||
expect(redone!.model.title).toBe('Model 2');
|
||||
expect(result.current.future).toHaveLength(0);
|
||||
// past should contain the current state we passed, not the redo'd entry
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
expect(result.current.past[0].model.title).toBe('Model 1');
|
||||
});
|
||||
|
||||
it('returns null when future is empty', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi()
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
let redone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
redone = result.current.api.getState().actions.redo(makeEntry(99));
|
||||
});
|
||||
|
||||
expect(redone).toBeNull();
|
||||
});
|
||||
|
||||
it('undo+redo+undo cycle preserves correct entries', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past),
|
||||
future: useHistoryStore((s) => s.future)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
// State A is "current", save snapshot and change to B
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1)); // past=[A]
|
||||
});
|
||||
|
||||
// Undo: current is B(=2), want to go back to A(=1)
|
||||
let undone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
undone = result.current.api.getState().actions.undo(makeEntry(2));
|
||||
});
|
||||
expect(undone!.model.title).toBe('Model 1');
|
||||
// past=[], future=[B(2)]
|
||||
|
||||
// Redo: current is A(=1), want to go forward to B(=2)
|
||||
let redone: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
redone = result.current.api.getState().actions.redo(makeEntry(1));
|
||||
});
|
||||
expect(redone!.model.title).toBe('Model 2');
|
||||
// past=[A(1)], future=[]
|
||||
|
||||
// Undo again: current is B(=2), should go back to A(=1)
|
||||
act(() => {
|
||||
undone = result.current.api.getState().actions.undo(makeEntry(2));
|
||||
});
|
||||
expect(undone!.model.title).toBe('Model 1');
|
||||
// past=[], future=[B(2)]
|
||||
|
||||
expect(result.current.past).toHaveLength(0);
|
||||
expect(result.current.future).toHaveLength(1);
|
||||
expect(result.current.future[0].model.title).toBe('Model 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture lifecycle', () => {
|
||||
it('beginGesture saves state and sets flag', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
gestureInProgress: useHistoryStore((s) => s.gestureInProgress),
|
||||
past: useHistoryStore((s) => s.past)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
expect(result.current.gestureInProgress).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(1));
|
||||
});
|
||||
|
||||
expect(result.current.gestureInProgress).toBe(true);
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('endGesture clears flag', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
gestureInProgress: useHistoryStore((s) => s.gestureInProgress)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(1));
|
||||
});
|
||||
act(() => {
|
||||
result.current.api.getState().actions.endGesture();
|
||||
});
|
||||
|
||||
expect(result.current.gestureInProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('cancelGesture pops past entry and clears flag', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
gestureInProgress: useHistoryStore((s) => s.gestureInProgress),
|
||||
past: useHistoryStore((s) => s.past)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(1));
|
||||
});
|
||||
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
|
||||
let cancelled: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
cancelled = result.current.api.getState().actions.cancelGesture();
|
||||
});
|
||||
|
||||
expect(cancelled).not.toBeNull();
|
||||
expect(cancelled!.model.title).toBe('Model 1');
|
||||
expect(result.current.past).toHaveLength(0);
|
||||
expect(result.current.gestureInProgress).toBe(false);
|
||||
});
|
||||
|
||||
it('cancelGesture returns null when no gesture in progress', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi()
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
let cancelled: HistoryEntry | null = null;
|
||||
act(() => {
|
||||
cancelled = result.current.api.getState().actions.cancelGesture();
|
||||
});
|
||||
|
||||
expect(cancelled).toBeNull();
|
||||
});
|
||||
|
||||
it('beginGesture is idempotent when already in gesture', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(1));
|
||||
});
|
||||
act(() => {
|
||||
result.current.api.getState().actions.beginGesture(makeEntry(2));
|
||||
});
|
||||
|
||||
// Should only have one entry (second beginGesture was a no-op)
|
||||
expect(result.current.past).toHaveLength(1);
|
||||
expect(result.current.past[0].model.title).toBe('Model 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('resets past, future, and gestureInProgress', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
api: useHistoryStoreApi(),
|
||||
past: useHistoryStore((s) => s.past),
|
||||
future: useHistoryStore((s) => s.future),
|
||||
gestureInProgress: useHistoryStore((s) => s.gestureInProgress)
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
|
||||
result.current.api.getState().actions.saveSnapshot(makeEntry(2));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.api.getState().actions.clearHistory();
|
||||
});
|
||||
|
||||
expect(result.current.past).toHaveLength(0);
|
||||
expect(result.current.future).toHaveLength(0);
|
||||
expect(result.current.gestureInProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
211
packages/fossflow-lib/src/stores/historyStore.tsx
Normal file
211
packages/fossflow-lib/src/stores/historyStore.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { createContext, useRef, useContext } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { Model, Scene } from 'src/types';
|
||||
|
||||
export interface HistoryEntry {
|
||||
model: Model;
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
export interface HistoryStoreState {
|
||||
past: HistoryEntry[];
|
||||
future: HistoryEntry[];
|
||||
gestureInProgress: boolean;
|
||||
maxSize: number;
|
||||
actions: {
|
||||
saveSnapshot: (currentEntry: HistoryEntry) => void;
|
||||
undo: (currentEntry: HistoryEntry) => HistoryEntry | null;
|
||||
redo: (currentEntry: HistoryEntry) => HistoryEntry | null;
|
||||
clearHistory: () => void;
|
||||
beginGesture: (currentEntry: HistoryEntry) => void;
|
||||
endGesture: () => void;
|
||||
cancelGesture: () => HistoryEntry | null;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50;
|
||||
|
||||
const createHistoryStore = () => {
|
||||
return createStore<HistoryStoreState>((set, get) => ({
|
||||
past: [],
|
||||
future: [],
|
||||
gestureInProgress: false,
|
||||
maxSize: MAX_HISTORY_SIZE,
|
||||
|
||||
actions: {
|
||||
saveSnapshot: (currentEntry: HistoryEntry) => {
|
||||
const { gestureInProgress, maxSize } = get();
|
||||
|
||||
if (gestureInProgress) return;
|
||||
|
||||
set((state) => {
|
||||
const newPast = [...state.past, currentEntry];
|
||||
|
||||
if (newPast.length > maxSize) {
|
||||
newPast.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
future: []
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
undo: (currentEntry: HistoryEntry) => {
|
||||
const { past } = get();
|
||||
if (past.length === 0) return null;
|
||||
|
||||
const previous = past[past.length - 1];
|
||||
const newPast = past.slice(0, -1);
|
||||
|
||||
set({
|
||||
past: newPast,
|
||||
future: [currentEntry, ...get().future]
|
||||
});
|
||||
|
||||
return previous;
|
||||
},
|
||||
|
||||
redo: (currentEntry: HistoryEntry) => {
|
||||
const { future } = get();
|
||||
if (future.length === 0) return null;
|
||||
|
||||
const next = future[0];
|
||||
const newFuture = future.slice(1);
|
||||
|
||||
set((state) => ({
|
||||
past: [...state.past, currentEntry],
|
||||
future: newFuture
|
||||
}));
|
||||
|
||||
return next;
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
set({
|
||||
past: [],
|
||||
future: [],
|
||||
gestureInProgress: false
|
||||
});
|
||||
},
|
||||
|
||||
beginGesture: (currentEntry: HistoryEntry) => {
|
||||
const { gestureInProgress, maxSize } = get();
|
||||
if (gestureInProgress) return;
|
||||
|
||||
set((state) => {
|
||||
const newPast = [...state.past, currentEntry];
|
||||
|
||||
if (newPast.length > maxSize) {
|
||||
newPast.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
future: [],
|
||||
gestureInProgress: true
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
endGesture: () => {
|
||||
set({ gestureInProgress: false });
|
||||
},
|
||||
|
||||
cancelGesture: () => {
|
||||
const { past, gestureInProgress } = get();
|
||||
if (!gestureInProgress || past.length === 0) {
|
||||
set({ gestureInProgress: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const previous = past[past.length - 1];
|
||||
const newPast = past.slice(0, -1);
|
||||
|
||||
set({
|
||||
past: newPast,
|
||||
gestureInProgress: false
|
||||
});
|
||||
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const HistoryContext = createContext<ReturnType<typeof createHistoryStore> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HistoryProvider = ({ children }: ProviderProps) => {
|
||||
const storeRef = useRef<ReturnType<typeof createHistoryStore> | undefined>(undefined);
|
||||
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createHistoryStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<HistoryContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</HistoryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useHistoryStore<T>(
|
||||
selector: (state: HistoryStoreState) => T,
|
||||
equalityFn?: (left: T, right: T) => boolean
|
||||
) {
|
||||
const store = useContext(HistoryContext);
|
||||
|
||||
if (store === null) {
|
||||
throw new Error('Missing HistoryProvider in the tree');
|
||||
}
|
||||
|
||||
const value = useStore(store, selector, equalityFn);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useHistoryStoreApi() {
|
||||
const store = useContext(HistoryContext);
|
||||
|
||||
if (store === null) {
|
||||
throw new Error('Missing HistoryProvider in the tree');
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export const extractModelData = (state: {
|
||||
version?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
colors: Model['colors'];
|
||||
icons: Model['icons'];
|
||||
items: Model['items'];
|
||||
views: Model['views'];
|
||||
}): Model => {
|
||||
return {
|
||||
version: state.version,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
colors: state.colors,
|
||||
icons: state.icons,
|
||||
items: state.items,
|
||||
views: state.views
|
||||
};
|
||||
};
|
||||
|
||||
export const extractSceneData = (state: {
|
||||
connectors: Scene['connectors'];
|
||||
textBoxes: Scene['textBoxes'];
|
||||
}): Scene => {
|
||||
return {
|
||||
connectors: state.connectors,
|
||||
textBoxes: state.textBoxes
|
||||
};
|
||||
};
|
||||
@@ -3,158 +3,19 @@ import { createStore, useStore } from 'zustand';
|
||||
import { ModelStore, Model } from 'src/types';
|
||||
import { INITIAL_DATA } from 'src/config';
|
||||
|
||||
export interface HistoryState {
|
||||
past: Model[];
|
||||
present: Model;
|
||||
future: Model[];
|
||||
maxHistorySize: number;
|
||||
}
|
||||
|
||||
export interface ModelStoreWithHistory extends Omit<ModelStore, 'actions'> {
|
||||
history: HistoryState;
|
||||
actions: {
|
||||
get: () => ModelStoreWithHistory;
|
||||
set: (model: Partial<Model>, skipHistory?: boolean) => void;
|
||||
undo: () => boolean;
|
||||
redo: () => boolean;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
saveToHistory: () => void;
|
||||
clearHistory: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50;
|
||||
|
||||
const createHistoryState = (initialModel: Model): HistoryState => {
|
||||
return {
|
||||
past: [],
|
||||
present: initialModel,
|
||||
future: [],
|
||||
maxHistorySize: MAX_HISTORY_SIZE
|
||||
};
|
||||
};
|
||||
|
||||
const extractModelData = (state: ModelStoreWithHistory): Model => {
|
||||
return {
|
||||
version: state.version,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
colors: state.colors,
|
||||
icons: state.icons,
|
||||
items: state.items,
|
||||
views: state.views
|
||||
};
|
||||
};
|
||||
|
||||
const initialState = () => {
|
||||
return createStore<ModelStoreWithHistory>((set, get) => {
|
||||
return createStore<ModelStore>((set, get) => {
|
||||
const initialModel = { ...INITIAL_DATA };
|
||||
|
||||
const saveToHistory = () => {
|
||||
set((state) => {
|
||||
const currentModel = extractModelData(state);
|
||||
const newPast = [...state.history.past, state.history.present];
|
||||
|
||||
// Limit history size to prevent memory issues
|
||||
if (newPast.length > state.history.maxHistorySize) {
|
||||
newPast.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
history: {
|
||||
...state.history,
|
||||
past: newPast,
|
||||
present: currentModel,
|
||||
future: [] // Clear future when new action is performed
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const undo = (): boolean => {
|
||||
const { history } = get();
|
||||
if (history.past.length === 0) return false;
|
||||
|
||||
const previous = history.past[history.past.length - 1];
|
||||
const newPast = history.past.slice(0, history.past.length - 1);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...previous,
|
||||
history: {
|
||||
...state.history,
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [state.history.present, ...state.history.future]
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const redo = (): boolean => {
|
||||
const { history } = get();
|
||||
if (history.future.length === 0) return false;
|
||||
|
||||
const next = history.future[0];
|
||||
const newFuture = history.future.slice(1);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...next,
|
||||
history: {
|
||||
...state.history,
|
||||
past: [...state.history.past, state.history.present],
|
||||
present: next,
|
||||
future: newFuture
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const canUndo = () => {
|
||||
return get().history.past.length > 0;
|
||||
};
|
||||
const canRedo = () => {
|
||||
return get().history.future.length > 0;
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
const currentState = get();
|
||||
const currentModel = extractModelData(currentState);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...state,
|
||||
history: createHistoryState(currentModel)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...initialModel,
|
||||
history: createHistoryState(initialModel),
|
||||
actions: {
|
||||
get,
|
||||
set: (updates: Partial<Model>, skipHistory = false) => {
|
||||
if (!skipHistory) {
|
||||
saveToHistory();
|
||||
}
|
||||
set: (updates: Partial<Model>) => {
|
||||
set((state) => {
|
||||
return { ...state, ...updates };
|
||||
});
|
||||
},
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
saveToHistory,
|
||||
clearHistory
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -183,7 +44,7 @@ export const ModelProvider = ({ children }: ProviderProps) => {
|
||||
};
|
||||
|
||||
export function useModelStore<T>(
|
||||
selector: (state: ModelStoreWithHistory) => T,
|
||||
selector: (state: ModelStore) => T,
|
||||
equalityFn?: (left: T, right: T) => boolean
|
||||
) {
|
||||
const store = useContext(ModelContext);
|
||||
|
||||
@@ -2,156 +2,22 @@ import React, { createContext, useRef, useContext } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { SceneStore, Scene } from 'src/types';
|
||||
|
||||
export interface SceneHistoryState {
|
||||
past: Scene[];
|
||||
present: Scene;
|
||||
future: Scene[];
|
||||
maxHistorySize: number;
|
||||
}
|
||||
|
||||
export interface SceneStoreWithHistory extends Omit<SceneStore, 'actions'> {
|
||||
history: SceneHistoryState;
|
||||
actions: {
|
||||
get: () => SceneStoreWithHistory;
|
||||
set: (scene: Partial<Scene>, skipHistory?: boolean) => void;
|
||||
undo: () => boolean;
|
||||
redo: () => boolean;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
saveToHistory: () => void;
|
||||
clearHistory: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50;
|
||||
|
||||
const createSceneHistoryState = (initialScene: Scene): SceneHistoryState => {
|
||||
return {
|
||||
past: [],
|
||||
present: initialScene,
|
||||
future: [],
|
||||
maxHistorySize: MAX_HISTORY_SIZE
|
||||
};
|
||||
};
|
||||
|
||||
const extractSceneData = (state: SceneStoreWithHistory): Scene => {
|
||||
return {
|
||||
connectors: state.connectors,
|
||||
textBoxes: state.textBoxes
|
||||
};
|
||||
};
|
||||
|
||||
const initialState = () => {
|
||||
return createStore<SceneStoreWithHistory>((set, get) => {
|
||||
return createStore<SceneStore>((set, get) => {
|
||||
const initialScene: Scene = {
|
||||
connectors: {},
|
||||
textBoxes: {}
|
||||
};
|
||||
|
||||
const saveToHistory = () => {
|
||||
set((state) => {
|
||||
const currentScene = extractSceneData(state);
|
||||
const newPast = [...state.history.past, state.history.present];
|
||||
|
||||
// Limit history size
|
||||
if (newPast.length > state.history.maxHistorySize) {
|
||||
newPast.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
history: {
|
||||
...state.history,
|
||||
past: newPast,
|
||||
present: currentScene,
|
||||
future: []
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const undo = (): boolean => {
|
||||
const { history } = get();
|
||||
if (history.past.length === 0) return false;
|
||||
|
||||
const previous = history.past[history.past.length - 1];
|
||||
const newPast = history.past.slice(0, history.past.length - 1);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...previous,
|
||||
history: {
|
||||
...state.history,
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [state.history.present, ...state.history.future]
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const redo = (): boolean => {
|
||||
const { history } = get();
|
||||
if (history.future.length === 0) return false;
|
||||
|
||||
const next = history.future[0];
|
||||
const newFuture = history.future.slice(1);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...next,
|
||||
history: {
|
||||
...state.history,
|
||||
past: [...state.history.past, state.history.present],
|
||||
present: next,
|
||||
future: newFuture
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const canUndo = () => {
|
||||
return get().history.past.length > 0;
|
||||
};
|
||||
const canRedo = () => {
|
||||
return get().history.future.length > 0;
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
const currentState = get();
|
||||
const currentScene = extractSceneData(currentState);
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
...state,
|
||||
history: createSceneHistoryState(currentScene)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...initialScene,
|
||||
history: createSceneHistoryState(initialScene),
|
||||
actions: {
|
||||
get,
|
||||
set: (updates: Partial<Scene>, skipHistory = false) => {
|
||||
if (!skipHistory) {
|
||||
saveToHistory();
|
||||
}
|
||||
set: (updates: Partial<Scene>) => {
|
||||
set((state) => {
|
||||
return { ...state, ...updates };
|
||||
});
|
||||
},
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
saveToHistory,
|
||||
clearHistory
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -180,7 +46,7 @@ export const SceneProvider = ({ children }: ProviderProps) => {
|
||||
};
|
||||
|
||||
export function useSceneStore<T>(
|
||||
selector: (state: SceneStoreWithHistory) => T,
|
||||
selector: (state: SceneStore) => T,
|
||||
equalityFn?: (left: T, right: T) => boolean
|
||||
) {
|
||||
const store = useContext(SceneContext);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ModelStore, UiStateStore, Size } from 'src/types';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { useHistory } from 'src/hooks/useHistory';
|
||||
|
||||
export interface State {
|
||||
model: ModelStore;
|
||||
scene: ReturnType<typeof useScene>;
|
||||
uiState: UiStateStore;
|
||||
history: Pick<
|
||||
ReturnType<typeof useHistory>,
|
||||
'beginGesture' | 'endGesture' | 'cancelGesture' | 'isGestureInProgress'
|
||||
>;
|
||||
rendererRef: HTMLElement;
|
||||
rendererSize: Size;
|
||||
isRendererInteraction: boolean;
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
connectorStyleOptions,
|
||||
connectorLineTypeOptions
|
||||
} from 'src/schemas';
|
||||
import { StoreApi } from 'zustand';
|
||||
|
||||
export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas';
|
||||
export type Model = z.infer<typeof modelSchema>;
|
||||
export type ModelItems = z.infer<typeof modelItemsSchema>;
|
||||
@@ -39,17 +37,7 @@ export type Rectangle = z.infer<typeof rectangleSchema>;
|
||||
|
||||
export type ModelStore = Model & {
|
||||
actions: {
|
||||
get: StoreApi<ModelStore>['getState'];
|
||||
set: StoreApi<ModelStore>['setState'];
|
||||
get: () => ModelStore;
|
||||
set: (updates: Partial<Model>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type {
|
||||
ModelStoreWithHistory,
|
||||
HistoryState as ModelHistoryState
|
||||
} from 'src/stores/modelStore';
|
||||
|
||||
export type {
|
||||
SceneStoreWithHistory,
|
||||
SceneHistoryState
|
||||
} from 'src/stores/sceneStore';
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { StoreApi } from 'zustand';
|
||||
import type { Coords, Rect, Size } from './common';
|
||||
import type { ViewItem, Rectangle, TextBox } from './model';
|
||||
|
||||
export interface SceneItemsSnapshot {
|
||||
items: Pick<ViewItem, 'tile' | 'id'>[];
|
||||
rectangles: Pick<Rectangle, 'from' | 'to' | 'id'>[];
|
||||
textBoxes: Pick<TextBox, 'tile' | 'id'>[];
|
||||
}
|
||||
|
||||
export const tileOriginOptions = {
|
||||
CENTER: 'CENTER',
|
||||
@@ -50,7 +56,7 @@ export interface Scene {
|
||||
|
||||
export type SceneStore = Scene & {
|
||||
actions: {
|
||||
get: StoreApi<SceneStore>['getState'];
|
||||
set: StoreApi<SceneStore>['setState'];
|
||||
get: () => SceneStore;
|
||||
set: (updates: Partial<Scene>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface DragItemsMode {
|
||||
type: 'DRAG_ITEMS';
|
||||
showCursor: boolean;
|
||||
items: ItemReference[];
|
||||
isInitialMovement: Boolean;
|
||||
isInitialMovement: boolean;
|
||||
}
|
||||
|
||||
export interface PanMode {
|
||||
|
||||
Reference in New Issue
Block a user