mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-08-31 00:39:47 +08: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 React, { useEffect } from 'react';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import { theme } from 'src/styles/theme';
|
import { theme } from 'src/styles/theme';
|
||||||
import { IsoflowProps } from 'src/types';
|
import { IsoflowProps } from 'src/types';
|
||||||
import { setWindowCursor, modelFromModelStore } from 'src/utils';
|
import { setWindowCursor, modelFromModelStore } from 'src/utils';
|
||||||
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
|
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
|
||||||
import { SceneProvider } from 'src/stores/sceneStore';
|
import { SceneProvider } from 'src/stores/sceneStore';
|
||||||
|
import { HistoryProvider } from 'src/stores/historyStore';
|
||||||
import { LocaleProvider } from 'src/stores/localeStore';
|
import { LocaleProvider } from 'src/stores/localeStore';
|
||||||
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
||||||
import { Renderer } from 'src/components/Renderer/Renderer';
|
import { Renderer } from 'src/components/Renderer/Renderer';
|
||||||
@@ -33,7 +35,7 @@ const App = ({
|
|||||||
const initialDataManager = useInitialDataManager();
|
const initialDataManager = useInitialDataManager();
|
||||||
const model = useModelStore((state) => {
|
const model = useModelStore((state) => {
|
||||||
return modelFromModelStore(state);
|
return modelFromModelStore(state);
|
||||||
});
|
}, shallow);
|
||||||
|
|
||||||
const { load } = initialDataManager;
|
const { load } = initialDataManager;
|
||||||
|
|
||||||
@@ -99,9 +101,11 @@ export const Isoflow = (props: IsoflowProps) => {
|
|||||||
<LocaleProvider locale={props.locale || enUS}>
|
<LocaleProvider locale={props.locale || enUS}>
|
||||||
<ModelProvider>
|
<ModelProvider>
|
||||||
<SceneProvider>
|
<SceneProvider>
|
||||||
<UiStateProvider>
|
<HistoryProvider>
|
||||||
<App {...props} />
|
<UiStateProvider>
|
||||||
</UiStateProvider>
|
<App {...props} />
|
||||||
|
</UiStateProvider>
|
||||||
|
</HistoryProvider>
|
||||||
</SceneProvider>
|
</SceneProvider>
|
||||||
</ModelProvider>
|
</ModelProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|||||||
@@ -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 { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { generateId, findNearestUnoccupiedTile } from 'src/utils';
|
import { generateId, findNearestUnoccupiedTile } from 'src/utils';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
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 { VIEW_ITEM_DEFAULTS } from 'src/config';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
|
||||||
@@ -12,9 +12,7 @@ interface Props {
|
|||||||
|
|
||||||
export const ContextMenuManager = ({ anchorEl }: Props) => {
|
export const ContextMenuManager = ({ anchorEl }: Props) => {
|
||||||
const scene = useScene();
|
const scene = useScene();
|
||||||
const model = useModelStore((state) => {
|
const modelStoreApi = useModelStoreApi();
|
||||||
return state;
|
|
||||||
});
|
|
||||||
const contextMenu = useUiStateStore((state) => {
|
const contextMenu = useUiStateStore((state) => {
|
||||||
return state.contextMenu;
|
return state.contextMenu;
|
||||||
});
|
});
|
||||||
@@ -36,10 +34,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
|
|||||||
label: 'Add Node',
|
label: 'Add Node',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
if (model.icons.length > 0) {
|
const { icons } = modelStoreApi.getState();
|
||||||
|
if (icons.length > 0) {
|
||||||
const modelItemId = generateId();
|
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)
|
// Find nearest unoccupied tile (should return the same tile since context menu is for empty tiles)
|
||||||
const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile;
|
const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile;
|
||||||
|
|
||||||
@@ -63,10 +62,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
|
|||||||
label: 'Add Rectangle',
|
label: 'Add Rectangle',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
if (model.colors.length > 0) {
|
const { colors } = modelStoreApi.getState();
|
||||||
|
if (colors.length > 0) {
|
||||||
scene.createRectangle({
|
scene.createRectangle({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
color: model.colors[0].id,
|
color: colors[0].id,
|
||||||
from: contextMenu.tile,
|
from: contextMenu.tile,
|
||||||
to: 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 React from 'react';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
@@ -9,7 +10,8 @@ export const DebugUtils = () => {
|
|||||||
const uiState = useUiStateStore(
|
const uiState = useUiStateStore(
|
||||||
({ scroll, mouse, zoom, mode, rendererEl }) => {
|
({ scroll, mouse, zoom, mode, rendererEl }) => {
|
||||||
return { scroll, mouse, zoom, mode, rendererEl };
|
return { scroll, mouse, zoom, mode, rendererEl };
|
||||||
}
|
},
|
||||||
|
shallow
|
||||||
);
|
);
|
||||||
const scene = useScene();
|
const scene = useScene();
|
||||||
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
|
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
|
|||||||
import { theme } from 'src/styles/theme';
|
import { theme } from 'src/styles/theme';
|
||||||
import { ModelProvider } from 'src/stores/modelStore';
|
import { ModelProvider } from 'src/stores/modelStore';
|
||||||
import { SceneProvider } from 'src/stores/sceneStore';
|
import { SceneProvider } from 'src/stores/sceneStore';
|
||||||
|
import { HistoryProvider } from 'src/stores/historyStore';
|
||||||
import { UiStateProvider } from 'src/stores/uiStateStore';
|
import { UiStateProvider } from 'src/stores/uiStateStore';
|
||||||
import { DebugUtils } from '../DebugUtils';
|
import { DebugUtils } from '../DebugUtils';
|
||||||
|
|
||||||
@@ -13,7 +14,9 @@ describe('DebugUtils', () => {
|
|||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ModelProvider>
|
<ModelProvider>
|
||||||
<SceneProvider>
|
<SceneProvider>
|
||||||
<UiStateProvider>{children}</UiStateProvider>
|
<HistoryProvider>
|
||||||
|
<UiStateProvider>{children}</UiStateProvider>
|
||||||
|
</HistoryProvider>
|
||||||
</SceneProvider>
|
</SceneProvider>
|
||||||
</ModelProvider>
|
</ModelProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
|
|||||||
import { theme } from 'src/styles/theme';
|
import { theme } from 'src/styles/theme';
|
||||||
import { ModelProvider } from 'src/stores/modelStore';
|
import { ModelProvider } from 'src/stores/modelStore';
|
||||||
import { SceneProvider } from 'src/stores/sceneStore';
|
import { SceneProvider } from 'src/stores/sceneStore';
|
||||||
|
import { HistoryProvider } from 'src/stores/historyStore';
|
||||||
import { UiStateProvider } from 'src/stores/uiStateStore';
|
import { UiStateProvider } from 'src/stores/uiStateStore';
|
||||||
import { SizeIndicator } from '../SizeIndicator';
|
import { SizeIndicator } from '../SizeIndicator';
|
||||||
|
|
||||||
@@ -13,7 +14,9 @@ describe('SizeIndicator', () => {
|
|||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ModelProvider>
|
<ModelProvider>
|
||||||
<SceneProvider>
|
<SceneProvider>
|
||||||
<UiStateProvider>{children}</UiStateProvider>
|
<HistoryProvider>
|
||||||
|
<UiStateProvider>{children}</UiStateProvider>
|
||||||
|
</HistoryProvider>
|
||||||
</SceneProvider>
|
</SceneProvider>
|
||||||
</ModelProvider>
|
</ModelProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -55,3 +55,5 @@ export const IsoTileArea = memo(({
|
|||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
IsoTileArea.displayName = 'IsoTileArea';
|
||||||
|
|||||||
+4
@@ -24,6 +24,7 @@ import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
|||||||
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
|
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
|
||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
import {
|
import {
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
@@ -44,6 +45,7 @@ export const ConnectorControls = ({ id }: Props) => {
|
|||||||
});
|
});
|
||||||
const connector = useConnector(id);
|
const connector = useConnector(id);
|
||||||
const { updateConnector, deleteConnector } = useScene();
|
const { updateConnector, deleteConnector } = useScene();
|
||||||
|
const { beginGesture, endGesture } = useHistory();
|
||||||
const [useCustomColor, setUseCustomColor] = useState(
|
const [useCustomColor, setUseCustomColor] = useState(
|
||||||
!!connector?.customColor
|
!!connector?.customColor
|
||||||
);
|
);
|
||||||
@@ -207,6 +209,8 @@ export const ConnectorControls = ({ id }: Props) => {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Text"
|
label="Text"
|
||||||
value={label.text}
|
value={label.text}
|
||||||
|
onFocus={beginGesture}
|
||||||
|
onBlur={endGesture}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
return handleUpdateLabel(label.id, {
|
return handleUpdateLabel(label.id, {
|
||||||
text: e.target.value
|
text: e.target.value
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,7 @@ import { ModelItem, ViewItem } from 'src/types';
|
|||||||
import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor';
|
import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor';
|
||||||
import { useModelItem } from 'src/hooks/useModelItem';
|
import { useModelItem } from 'src/hooks/useModelItem';
|
||||||
import { useModelStore } from 'src/stores/modelStore';
|
import { useModelStore } from 'src/stores/modelStore';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
import { DeleteButton } from '../../components/DeleteButton';
|
import { DeleteButton } from '../../components/DeleteButton';
|
||||||
import { Section } from '../../components/Section';
|
import { Section } from '../../components/Section';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export const NodeSettings = ({
|
|||||||
const modelItem = useModelItem(node.id);
|
const modelItem = useModelItem(node.id);
|
||||||
const modelActions = useModelStore((state) => state.actions);
|
const modelActions = useModelStore((state) => state.actions);
|
||||||
const icons = useModelStore((state) => state.icons);
|
const icons = useModelStore((state) => state.icons);
|
||||||
|
const { beginGesture, endGesture, isGestureInProgress } = useHistory();
|
||||||
|
|
||||||
// Local state for smooth slider interaction
|
// Local state for smooth slider interaction
|
||||||
const currentIcon = icons.find(icon => icon.id === modelItem?.icon);
|
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
|
// Handle slider change with local state + debounced store update
|
||||||
const handleScaleChange = useCallback((e: Event, newScale: number | number[]) => {
|
const handleScaleChange = useCallback((e: Event, newScale: number | number[]) => {
|
||||||
|
if (!isGestureInProgress()) {
|
||||||
|
beginGesture();
|
||||||
|
}
|
||||||
const scale = newScale as number;
|
const scale = newScale as number;
|
||||||
setLocalScale(scale); // Immediate UI update
|
setLocalScale(scale); // Immediate UI update
|
||||||
updateIconScale(scale); // Debounced store update
|
updateIconScale(scale); // Debounced store update
|
||||||
}, [updateIconScale]);
|
}, [updateIconScale, beginGesture, isGestureInProgress]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,6 +85,8 @@ export const NodeSettings = ({
|
|||||||
<Section title="Name">
|
<Section title="Name">
|
||||||
<TextField
|
<TextField
|
||||||
value={modelItem.name}
|
value={modelItem.name}
|
||||||
|
onFocus={() => beginGesture()}
|
||||||
|
onBlur={() => endGesture()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const text = e.target.value as string;
|
const text = e.target.value as string;
|
||||||
if (modelItem.name !== text) onModelItemUpdated({ name: text });
|
if (modelItem.name !== text) onModelItemUpdated({ name: text });
|
||||||
@@ -89,6 +96,8 @@ export const NodeSettings = ({
|
|||||||
<Section title="Description">
|
<Section title="Description">
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
value={modelItem.description}
|
value={modelItem.description}
|
||||||
|
onFocus={() => beginGesture()}
|
||||||
|
onBlur={() => endGesture()}
|
||||||
onChange={(text) => {
|
onChange={(text) => {
|
||||||
if (modelItem.description !== text)
|
if (modelItem.description !== text)
|
||||||
onModelItemUpdated({ description: text });
|
onModelItemUpdated({ description: text });
|
||||||
@@ -119,6 +128,7 @@ export const NodeSettings = ({
|
|||||||
max={2.5}
|
max={2.5}
|
||||||
value={localScale}
|
value={localScale}
|
||||||
onChange={handleScaleChange}
|
onChange={handleScaleChange}
|
||||||
|
onChangeCommitted={endGesture}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useTextBox } from 'src/hooks/useTextBox';
|
|||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { getIsoProjectionCss } from 'src/utils';
|
import { getIsoProjectionCss } from 'src/utils';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
import { ControlsContainer } from '../components/ControlsContainer';
|
import { ControlsContainer } from '../components/ControlsContainer';
|
||||||
import { Section } from '../components/Section';
|
import { Section } from '../components/Section';
|
||||||
import { DeleteButton } from '../components/DeleteButton';
|
import { DeleteButton } from '../components/DeleteButton';
|
||||||
@@ -30,6 +31,7 @@ export const TextBoxControls = ({ id }: Props) => {
|
|||||||
});
|
});
|
||||||
const textBox = useTextBox(id);
|
const textBox = useTextBox(id);
|
||||||
const { updateTextBox, deleteTextBox } = useScene();
|
const { updateTextBox, deleteTextBox } = useScene();
|
||||||
|
const { beginGesture, endGesture } = useHistory();
|
||||||
|
|
||||||
// If textBox doesn't exist, return null
|
// If textBox doesn't exist, return null
|
||||||
if (!textBox) {
|
if (!textBox) {
|
||||||
@@ -58,6 +60,8 @@ export const TextBoxControls = ({ id }: Props) => {
|
|||||||
<Section title="Enter text">
|
<Section title="Enter text">
|
||||||
<TextField
|
<TextField
|
||||||
value={textBox.content}
|
value={textBox.content}
|
||||||
|
onFocus={beginGesture}
|
||||||
|
onBlur={endGesture}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
updateTextBox(textBox.id, { content: e.target.value as string });
|
updateTextBox(textBox.id, { content: e.target.value as string });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import { UiElement } from 'src/components/UiElement/UiElement';
|
import { UiElement } from 'src/components/UiElement/UiElement';
|
||||||
import { IconButton } from 'src/components/IconButton/IconButton';
|
import { IconButton } from 'src/components/IconButton/IconButton';
|
||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
@@ -32,7 +33,7 @@ export const MainMenu = () => {
|
|||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const model = useModelStore((state) => {
|
const model = useModelStore((state) => {
|
||||||
return modelFromModelStore(state);
|
return modelFromModelStore(state);
|
||||||
});
|
}, shallow);
|
||||||
const isMainMenuOpen = useUiStateStore((state) => {
|
const isMainMenuOpen = useUiStateStore((state) => {
|
||||||
return state.isMainMenuOpen;
|
return state.isMainMenuOpen;
|
||||||
});
|
});
|
||||||
@@ -120,13 +121,13 @@ export const MainMenu = () => {
|
|||||||
}, [uiStateActions, clear, clearHistory]);
|
}, [uiStateActions, clear, clearHistory]);
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
undo();
|
const success = undo();
|
||||||
uiStateActions.setIsMainMenuOpen(false);
|
if (success) uiStateActions.setIsMainMenuOpen(false);
|
||||||
}, [undo, uiStateActions]);
|
}, [undo, uiStateActions]);
|
||||||
|
|
||||||
const handleRedo = useCallback(() => {
|
const handleRedo = useCallback(() => {
|
||||||
redo();
|
const success = redo();
|
||||||
uiStateActions.setIsMainMenuOpen(false);
|
if (success) uiStateActions.setIsMainMenuOpen(false);
|
||||||
}, [redo, uiStateActions]);
|
}, [redo, uiStateActions]);
|
||||||
|
|
||||||
const onOpenSettings = useCallback(() => {
|
const onOpenSettings = useCallback(() => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import RichTextEditorErrorBoundary from './RichTextEditorErrorBoundary';
|
|||||||
interface Props {
|
interface Props {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
styles?: React.CSSProperties;
|
styles?: React.CSSProperties;
|
||||||
@@ -42,6 +44,8 @@ const formats = [
|
|||||||
export const RichTextEditor = ({
|
export const RichTextEditor = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
readOnly,
|
readOnly,
|
||||||
height = 120,
|
height = 120,
|
||||||
styles
|
styles
|
||||||
@@ -92,6 +96,8 @@ export const RichTextEditor = ({
|
|||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
formats={formats}
|
formats={formats}
|
||||||
modules={modules}
|
modules={modules}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,3 +60,5 @@ export const SceneLayer = memo(({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
SceneLayer.displayName = 'SceneLayer';
|
||||||
|
|||||||
@@ -124,3 +124,5 @@ export const ConnectorLabel = memo(({ connector: sceneConnector }: Props) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ConnectorLabel.displayName = 'ConnectorLabel';
|
||||||
|
|||||||
+21
-15
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import { useMemo, memo } from 'react';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
import { ConnectorLabel } from './ConnectorLabel';
|
import { ConnectorLabel } from './ConnectorLabel';
|
||||||
|
|
||||||
@@ -6,21 +6,27 @@ interface Props {
|
|||||||
connectors: ReturnType<typeof useScene>['connectors'];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{connectors
|
{labeledConnectors.map((connector) => {
|
||||||
.filter((connector) => {
|
return <ConnectorLabel key={connector.id} connector={connector} />;
|
||||||
return Boolean(
|
})}
|
||||||
connector.description ||
|
|
||||||
connector.startLabel ||
|
|
||||||
connector.endLabel ||
|
|
||||||
(connector.labels && connector.labels.length > 0)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.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>
|
</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 type { useScene } from 'src/hooks/useScene';
|
||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { Connector } from './Connector';
|
import { Connector } from './Connector';
|
||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
connectors: ReturnType<typeof useScene>['connectors'];
|
connectors: ReturnType<typeof useScene>['connectors'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Connectors = ({ connectors }: Props) => {
|
export const Connectors = memo(({ connectors }: Props) => {
|
||||||
const itemControls = useUiStateStore((state) => {
|
const itemControls = useUiStateStore((state) => {
|
||||||
return state.itemControls;
|
return state.itemControls;
|
||||||
});
|
});
|
||||||
@@ -27,9 +27,11 @@ export const Connectors = ({ connectors }: Props) => {
|
|||||||
return null;
|
return null;
|
||||||
}, [mode, itemControls]);
|
}, [mode, itemControls]);
|
||||||
|
|
||||||
|
const reversedConnectors = useMemo(() => [...connectors].reverse(), [connectors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{[...connectors].reverse().map((connector) => {
|
{reversedConnectors.map((connector) => {
|
||||||
return (
|
return (
|
||||||
<Connector
|
<Connector
|
||||||
key={connector.id}
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Node.displayName = 'Node';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import { useMemo, memo } from 'react';
|
||||||
import { ViewItem } from 'src/types';
|
import { ViewItem } from 'src/types';
|
||||||
import { Node } from './Node/Node';
|
import { Node } from './Node/Node';
|
||||||
|
|
||||||
@@ -6,14 +6,18 @@ interface Props {
|
|||||||
nodes: ViewItem[];
|
nodes: ViewItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Nodes = ({ nodes }: Props) => {
|
export const Nodes = memo(({ nodes }: Props) => {
|
||||||
|
const reversedNodes = useMemo(() => [...nodes].reverse(), [nodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{[...nodes].reverse().map((node) => {
|
{reversedNodes.map((node) => {
|
||||||
return (
|
return (
|
||||||
<Node key={node.id} order={-node.tile.x - node.tile.y} node={node} />
|
<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 { useScene } from 'src/hooks/useScene';
|
||||||
import { Rectangle } from './Rectangle';
|
import { Rectangle } from './Rectangle';
|
||||||
|
|
||||||
@@ -6,12 +6,16 @@ interface Props {
|
|||||||
rectangles: ReturnType<typeof useScene>['rectangles'];
|
rectangles: ReturnType<typeof useScene>['rectangles'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Rectangles = ({ rectangles }: Props) => {
|
export const Rectangles = memo(({ rectangles }: Props) => {
|
||||||
|
const reversedRectangles = useMemo(() => [...rectangles].reverse(), [rectangles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{[...rectangles].reverse().map((rectangle) => {
|
{reversedRectangles.map((rectangle) => {
|
||||||
return <Rectangle key={rectangle.id} {...rectangle} />;
|
return <Rectangle key={rectangle.id} {...rectangle} />;
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Rectangles.displayName = 'Rectangles';
|
||||||
|
|||||||
@@ -50,3 +50,5 @@ export const TextBox = memo(({ textBox }: Props) => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
TextBox.displayName = 'TextBox';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import { useMemo, memo } from 'react';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
import { TextBox } from './TextBox';
|
import { TextBox } from './TextBox';
|
||||||
|
|
||||||
@@ -6,12 +6,16 @@ interface Props {
|
|||||||
textBoxes: ReturnType<typeof useScene>['textBoxes'];
|
textBoxes: ReturnType<typeof useScene>['textBoxes'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextBoxes = ({ textBoxes }: Props) => {
|
export const TextBoxes = memo(({ textBoxes }: Props) => {
|
||||||
|
const reversedTextBoxes = useMemo(() => [...textBoxes].reverse(), [textBoxes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{[...textBoxes].reverse().map((textBox) => {
|
{reversedTextBoxes.map((textBox) => {
|
||||||
return <TextBox key={textBox.id} textBox={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 { renderHook, act } from '@testing-library/react';
|
||||||
|
import { createStore } from 'zustand';
|
||||||
import { useHistory } from '../useHistory';
|
import { useHistory } from '../useHistory';
|
||||||
|
import type { HistoryStoreState, HistoryEntry } from 'src/stores/historyStore';
|
||||||
|
import type { Model, Scene } from 'src/types';
|
||||||
|
|
||||||
// Mock implementations
|
// --- Minimal mock data ---
|
||||||
const mockModelStore = {
|
|
||||||
canUndo: jest.fn(),
|
|
||||||
canRedo: jest.fn(),
|
|
||||||
undo: jest.fn(),
|
|
||||||
redo: jest.fn(),
|
|
||||||
saveToHistory: jest.fn(),
|
|
||||||
clearHistory: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSceneStore = {
|
const makeModel = (title: string): Model => ({
|
||||||
canUndo: jest.fn(),
|
title,
|
||||||
canRedo: jest.fn(),
|
colors: [],
|
||||||
undo: jest.fn(),
|
icons: [],
|
||||||
redo: jest.fn(),
|
items: [],
|
||||||
saveToHistory: jest.fn(),
|
views: []
|
||||||
clearHistory: jest.fn()
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the store hooks
|
const makeScene = (extra?: Record<string, unknown>): Scene => ({
|
||||||
jest.mock('../../stores/modelStore', () => ({
|
connectors: {},
|
||||||
useModelStore: jest.fn((selector) => {
|
textBoxes: {},
|
||||||
const state = {
|
...extra
|
||||||
actions: mockModelStore
|
});
|
||||||
|
|
||||||
|
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', () => ({
|
jest.mock('../../stores/sceneStore', () => ({
|
||||||
useSceneStore: jest.fn((selector) => {
|
useSceneStoreApi: () => mockSceneStore
|
||||||
const state = {
|
}));
|
||||||
actions: mockSceneStore
|
|
||||||
};
|
jest.mock('../../stores/historyStore', () => ({
|
||||||
return selector ? selector(state) : state;
|
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', () => {
|
describe('useHistory', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
mockModelStore = createMockModelStore(makeModel('initial'));
|
||||||
|
mockSceneStore = createMockSceneStore(makeScene());
|
||||||
// Reset mock implementations
|
mockHistoryStore = createMockHistoryStore();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('undo/redo basic functionality', () => {
|
describe('canUndo / canRedo', () => {
|
||||||
it('should initialize with no undo/redo capability', () => {
|
it('should initialize with no undo/redo capability', () => {
|
||||||
const { result } = renderHook(() => useHistory());
|
const { result } = renderHook(() => useHistory());
|
||||||
|
|
||||||
expect(result.current.canUndo).toBe(false);
|
expect(result.current.canUndo).toBe(false);
|
||||||
expect(result.current.canRedo).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());
|
const { result } = renderHook(() => useHistory());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.saveToHistory();
|
result.current.saveSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockModelStore.saveToHistory).toHaveBeenCalled();
|
|
||||||
expect(mockSceneStore.saveToHistory).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should perform undo when model store has history', () => {
|
// Re-render to pick up store changes
|
||||||
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);
|
|
||||||
|
|
||||||
const { result: result2 } = renderHook(() => useHistory());
|
const { result: result2 } = renderHook(() => useHistory());
|
||||||
expect(result2.current.canUndo).toBe(true);
|
expect(result2.current.canUndo).toBe(true);
|
||||||
|
expect(result2.current.canRedo).toBe(false);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('saveSnapshot', () => {
|
||||||
it('should handle missing store actions gracefully', () => {
|
it('should capture model + scene and delegate to history store', () => {
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useHistory());
|
const { result } = renderHook(() => useHistory());
|
||||||
|
|
||||||
// Should not throw and return safe defaults
|
act(() => {
|
||||||
expect(result.current.canUndo).toBe(false);
|
result.current.saveSnapshot();
|
||||||
expect(result.current.canRedo).toBe(false);
|
});
|
||||||
|
|
||||||
|
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(() => {
|
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.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 state = mockHistoryStore.getState();
|
||||||
const { result } = renderHook(() => useHistory());
|
expect(state.past).toHaveLength(0);
|
||||||
|
expect(state.future).toHaveLength(0);
|
||||||
act(() => {
|
expect(state.gestureInProgress).toBe(false);
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,129 +1,94 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useModelStore } from 'src/stores/modelStore';
|
import { useModelStoreApi } from 'src/stores/modelStore';
|
||||||
import { useSceneStore } from 'src/stores/sceneStore';
|
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 = () => {
|
export const useHistory = () => {
|
||||||
// Track if we're in a transaction to prevent nested history saves
|
const modelStoreApi = useModelStoreApi();
|
||||||
const transactionInProgress = useRef(false);
|
const sceneStoreApi = useSceneStoreApi();
|
||||||
|
const historyStoreApi = useHistoryStoreApi();
|
||||||
|
|
||||||
// Get store actions
|
const canUndo = useHistoryStore((s) => s.past.length > 0);
|
||||||
const modelActions = useModelStore((state) => {
|
const canRedo = useHistoryStore((s) => s.future.length > 0);
|
||||||
return state?.actions;
|
|
||||||
});
|
|
||||||
const sceneActions = useSceneStore((state) => {
|
|
||||||
return state?.actions;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get history state
|
const getCurrentEntry = useCallback((): HistoryEntry => {
|
||||||
const modelCanUndo = useModelStore((state) => {
|
const model = modelStoreApi.getState();
|
||||||
return state?.actions?.canUndo?.() ?? false;
|
const scene = sceneStoreApi.getState();
|
||||||
});
|
return {
|
||||||
const sceneCanUndo = useSceneStore((state) => {
|
model: extractModelData(model),
|
||||||
return state?.actions?.canUndo?.() ?? false;
|
scene: extractSceneData(scene)
|
||||||
});
|
};
|
||||||
const modelCanRedo = useModelStore((state) => {
|
}, [modelStoreApi, sceneStoreApi]);
|
||||||
return state?.actions?.canRedo?.() ?? false;
|
|
||||||
});
|
|
||||||
const sceneCanRedo = useSceneStore((state) => {
|
|
||||||
return state?.actions?.canRedo?.() ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived values
|
const applyEntry = useCallback(
|
||||||
const canUndo = modelCanUndo || sceneCanUndo;
|
(entry: HistoryEntry) => {
|
||||||
const canRedo = modelCanRedo || sceneCanRedo;
|
modelStoreApi.getState().actions.set(entry.model);
|
||||||
|
sceneStoreApi.getState().actions.set(entry.scene);
|
||||||
// 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
|
|
||||||
},
|
},
|
||||||
[modelActions, sceneActions]
|
[modelStoreApi, sceneStoreApi]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const saveSnapshot = useCallback(() => {
|
||||||
|
historyStoreApi.getState().actions.saveSnapshot(getCurrentEntry());
|
||||||
|
}, [historyStoreApi, getCurrentEntry]);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (!modelActions || !sceneActions) return false;
|
const entry = historyStoreApi.getState().actions.undo(getCurrentEntry());
|
||||||
|
if (entry) {
|
||||||
let undoPerformed = false;
|
applyEntry(entry);
|
||||||
|
return true;
|
||||||
// Try to undo model first, then scene
|
|
||||||
if (modelActions.canUndo()) {
|
|
||||||
undoPerformed = modelActions.undo() || undoPerformed;
|
|
||||||
}
|
}
|
||||||
if (sceneActions.canUndo()) {
|
return false;
|
||||||
undoPerformed = sceneActions.undo() || undoPerformed;
|
}, [historyStoreApi, getCurrentEntry, applyEntry]);
|
||||||
}
|
|
||||||
|
|
||||||
return undoPerformed;
|
|
||||||
}, [modelActions, sceneActions]);
|
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (!modelActions || !sceneActions) return false;
|
const entry = historyStoreApi.getState().actions.redo(getCurrentEntry());
|
||||||
|
if (entry) {
|
||||||
let redoPerformed = false;
|
applyEntry(entry);
|
||||||
|
return true;
|
||||||
// Try to redo model first, then scene
|
|
||||||
if (modelActions.canRedo()) {
|
|
||||||
redoPerformed = modelActions.redo() || redoPerformed;
|
|
||||||
}
|
}
|
||||||
if (sceneActions.canRedo()) {
|
return false;
|
||||||
redoPerformed = sceneActions.redo() || redoPerformed;
|
}, [historyStoreApi, getCurrentEntry, applyEntry]);
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const clearHistory = useCallback(() => {
|
const clearHistory = useCallback(() => {
|
||||||
if (!modelActions || !sceneActions) return;
|
historyStoreApi.getState().actions.clearHistory();
|
||||||
|
}, [historyStoreApi]);
|
||||||
|
|
||||||
modelActions.clearHistory();
|
const beginGesture = useCallback(() => {
|
||||||
sceneActions.clearHistory();
|
historyStoreApi.getState().actions.beginGesture(getCurrentEntry());
|
||||||
}, [modelActions, sceneActions]);
|
}, [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 {
|
return {
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
saveToHistory,
|
saveSnapshot,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
transaction,
|
beginGesture,
|
||||||
isInTransaction: () => {
|
endGesture,
|
||||||
return transactionInProgress.current;
|
cancelGesture,
|
||||||
}
|
isGestureInProgress
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useState, useRef } from 'react';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import { InitialData, IconCollectionState } from 'src/types';
|
import { InitialData, IconCollectionState } from 'src/types';
|
||||||
import { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
|
import { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
getItemByIdOrThrow
|
getItemByIdOrThrow
|
||||||
} from 'src/utils';
|
} from 'src/utils';
|
||||||
import * as reducers from 'src/stores/reducers';
|
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 { useView } from 'src/hooks/useView';
|
||||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||||
import { modelSchema } from 'src/schemas/model';
|
import { modelSchema } from 'src/schemas/model';
|
||||||
@@ -17,9 +18,12 @@ import { modelSchema } from 'src/schemas/model';
|
|||||||
export const useInitialDataManager = () => {
|
export const useInitialDataManager = () => {
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const prevInitialData = useRef<InitialData | undefined>(undefined);
|
const prevInitialData = useRef<InitialData | undefined>(undefined);
|
||||||
const model = useModelStore((state) => {
|
const modelActions = useModelStore((state) => state.actions);
|
||||||
return state;
|
const { icons, colors } = useModelStore(
|
||||||
});
|
(state) => ({ icons: state.icons, colors: state.colors }),
|
||||||
|
shallow
|
||||||
|
);
|
||||||
|
const modelStoreApi = useModelStoreApi();
|
||||||
const uiStateActions = useUiStateStore((state) => {
|
const uiStateActions = useUiStateStore((state) => {
|
||||||
return state.actions;
|
return state.actions;
|
||||||
});
|
});
|
||||||
@@ -105,7 +109,7 @@ export const useInitialDataManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevInitialData.current = initialData;
|
prevInitialData.current = initialData;
|
||||||
model.actions.set(initialData);
|
modelActions.set(initialData);
|
||||||
|
|
||||||
const view = getItemByIdOrThrow(
|
const view = getItemByIdOrThrow(
|
||||||
initialData.views,
|
initialData.views,
|
||||||
@@ -143,13 +147,13 @@ export const useInitialDataManager = () => {
|
|||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
},
|
},
|
||||||
[changeView, model.actions, rendererEl, uiStateActions, editorMode]
|
[changeView, modelActions, rendererEl, uiStateActions, editorMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
load({ ...INITIAL_DATA, icons: model.icons, colors: model.colors });
|
load({ ...INITIAL_DATA, icons, colors });
|
||||||
uiStateActions.resetUiState();
|
uiStateActions.resetUiState();
|
||||||
}, [load, model.icons, model.colors, uiStateActions]);
|
}, [load, icons, colors, uiStateActions]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { shallow } from 'zustand/shallow';
|
import { shallow } from 'zustand/shallow';
|
||||||
import {
|
import {
|
||||||
ModelItem,
|
ModelItem,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
RECTANGLE_DEFAULTS,
|
RECTANGLE_DEFAULTS,
|
||||||
TEXTBOX_DEFAULTS
|
TEXTBOX_DEFAULTS
|
||||||
} from 'src/config';
|
} from 'src/config';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
|
|
||||||
export const useScene = () => {
|
export const useScene = () => {
|
||||||
const { views, colors, icons, items, version, title, description } =
|
const { views, colors, icons, items, version, title, description } =
|
||||||
@@ -42,10 +43,10 @@ export const useScene = () => {
|
|||||||
shallow
|
shallow
|
||||||
);
|
);
|
||||||
const currentViewId = useUiStateStore((state) => state.view);
|
const currentViewId = useUiStateStore((state) => state.view);
|
||||||
const transactionInProgress = useRef(false);
|
|
||||||
|
|
||||||
const modelStoreApi = useModelStoreApi();
|
const modelStoreApi = useModelStoreApi();
|
||||||
const sceneStoreApi = useSceneStoreApi();
|
const sceneStoreApi = useSceneStoreApi();
|
||||||
|
const { saveSnapshot, beginGesture, endGesture } = useHistory();
|
||||||
|
|
||||||
const currentView = useMemo(() => {
|
const currentView = useMemo(() => {
|
||||||
if (!views || !currentViewId) {
|
if (!views || !currentViewId) {
|
||||||
@@ -138,59 +139,45 @@ export const useScene = () => {
|
|||||||
|
|
||||||
const setState = useCallback(
|
const setState = useCallback(
|
||||||
(newState: State) => {
|
(newState: State) => {
|
||||||
modelStoreApi.getState().actions.set(newState.model, true);
|
modelStoreApi.getState().actions.set(newState.model);
|
||||||
sceneStoreApi.getState().actions.set(newState.scene, true);
|
sceneStoreApi.getState().actions.set(newState.scene);
|
||||||
},
|
},
|
||||||
[modelStoreApi, sceneStoreApi]
|
[modelStoreApi, sceneStoreApi]
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveToHistoryBeforeChange = useCallback(() => {
|
|
||||||
if (transactionInProgress.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
modelStoreApi.getState().actions.saveToHistory();
|
|
||||||
sceneStoreApi.getState().actions.saveToHistory();
|
|
||||||
}, [modelStoreApi, sceneStoreApi]);
|
|
||||||
|
|
||||||
const createModelItem = useCallback(
|
const createModelItem = useCallback(
|
||||||
(newModelItem: ModelItem) => {
|
(newModelItem: ModelItem) => {
|
||||||
if (!transactionInProgress.current) {
|
saveSnapshot();
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = reducers.createModelItem(newModelItem, getState());
|
const newState = reducers.createModelItem(newModelItem, getState());
|
||||||
setState(newState);
|
setState(newState);
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[getState, setState, saveToHistoryBeforeChange]
|
[getState, setState, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateModelItem = useCallback(
|
const updateModelItem = useCallback(
|
||||||
(id: string, updates: Partial<ModelItem>) => {
|
(id: string, updates: Partial<ModelItem>) => {
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.updateModelItem(id, updates, getState());
|
const newState = reducers.updateModelItem(id, updates, getState());
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, saveToHistoryBeforeChange]
|
[getState, setState, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteModelItem = useCallback(
|
const deleteModelItem = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.deleteModelItem(id, getState());
|
const newState = reducers.deleteModelItem(id, getState());
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, saveToHistoryBeforeChange]
|
[getState, setState, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createViewItem = useCallback(
|
const createViewItem = useCallback(
|
||||||
(newViewItem: ViewItem, currentState?: State) => {
|
(newViewItem: ViewItem, currentState?: State) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
if (!transactionInProgress.current) {
|
saveSnapshot();
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateToUse = currentState || getState();
|
const stateToUse = currentState || getState();
|
||||||
|
|
||||||
@@ -202,16 +189,14 @@ export const useScene = () => {
|
|||||||
setState(newState);
|
setState(newState);
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateViewItem = useCallback(
|
const updateViewItem = useCallback(
|
||||||
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
|
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
|
||||||
if (!currentViewId) return getState();
|
if (!currentViewId) return getState();
|
||||||
|
|
||||||
if (!transactionInProgress.current) {
|
saveSnapshot();
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateToUse = currentState || getState();
|
const stateToUse = currentState || getState();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
@@ -222,14 +207,14 @@ export const useScene = () => {
|
|||||||
setState(newState);
|
setState(newState);
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteViewItem = useCallback(
|
const deleteViewItem = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'DELETE_VIEWITEM',
|
action: 'DELETE_VIEWITEM',
|
||||||
payload: id,
|
payload: id,
|
||||||
@@ -237,14 +222,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createConnector = useCallback(
|
const createConnector = useCallback(
|
||||||
(newConnector: Connector) => {
|
(newConnector: Connector) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'CREATE_CONNECTOR',
|
action: 'CREATE_CONNECTOR',
|
||||||
payload: newConnector,
|
payload: newConnector,
|
||||||
@@ -252,14 +237,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateConnector = useCallback(
|
const updateConnector = useCallback(
|
||||||
(id: string, updates: Partial<Connector>) => {
|
(id: string, updates: Partial<Connector>) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'UPDATE_CONNECTOR',
|
action: 'UPDATE_CONNECTOR',
|
||||||
payload: { id, ...updates },
|
payload: { id, ...updates },
|
||||||
@@ -267,14 +252,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteConnector = useCallback(
|
const deleteConnector = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'DELETE_CONNECTOR',
|
action: 'DELETE_CONNECTOR',
|
||||||
payload: id,
|
payload: id,
|
||||||
@@ -282,14 +267,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createTextBox = useCallback(
|
const createTextBox = useCallback(
|
||||||
(newTextBox: TextBox) => {
|
(newTextBox: TextBox) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'CREATE_TEXTBOX',
|
action: 'CREATE_TEXTBOX',
|
||||||
payload: newTextBox,
|
payload: newTextBox,
|
||||||
@@ -297,16 +282,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTextBox = useCallback(
|
const updateTextBox = useCallback(
|
||||||
(id: string, updates: Partial<TextBox>, currentState?: State) => {
|
(id: string, updates: Partial<TextBox>, currentState?: State) => {
|
||||||
if (!currentViewId) return currentState || getState();
|
if (!currentViewId) return currentState || getState();
|
||||||
|
|
||||||
if (!transactionInProgress.current) {
|
saveSnapshot();
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateToUse = currentState || getState();
|
const stateToUse = currentState || getState();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
@@ -317,14 +300,14 @@ export const useScene = () => {
|
|||||||
setState(newState);
|
setState(newState);
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteTextBox = useCallback(
|
const deleteTextBox = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'DELETE_TEXTBOX',
|
action: 'DELETE_TEXTBOX',
|
||||||
payload: id,
|
payload: id,
|
||||||
@@ -332,14 +315,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createRectangle = useCallback(
|
const createRectangle = useCallback(
|
||||||
(newRectangle: Rectangle) => {
|
(newRectangle: Rectangle) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'CREATE_RECTANGLE',
|
action: 'CREATE_RECTANGLE',
|
||||||
payload: newRectangle,
|
payload: newRectangle,
|
||||||
@@ -347,16 +330,14 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateRectangle = useCallback(
|
const updateRectangle = useCallback(
|
||||||
(id: string, updates: Partial<Rectangle>, currentState?: State) => {
|
(id: string, updates: Partial<Rectangle>, currentState?: State) => {
|
||||||
if (!currentViewId) return currentState || getState();
|
if (!currentViewId) return currentState || getState();
|
||||||
|
|
||||||
if (!transactionInProgress.current) {
|
saveSnapshot();
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateToUse = currentState || getState();
|
const stateToUse = currentState || getState();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
@@ -367,14 +348,14 @@ export const useScene = () => {
|
|||||||
setState(newState);
|
setState(newState);
|
||||||
return newState;
|
return newState;
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteRectangle = useCallback(
|
const deleteRectangle = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!currentViewId) return;
|
if (!currentViewId) return;
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
saveSnapshot();
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
action: 'DELETE_RECTANGLE',
|
action: 'DELETE_RECTANGLE',
|
||||||
payload: id,
|
payload: id,
|
||||||
@@ -382,32 +363,24 @@ export const useScene = () => {
|
|||||||
});
|
});
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
[getState, setState, currentViewId, saveToHistoryBeforeChange]
|
[getState, setState, currentViewId, saveSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const transaction = useCallback(
|
const transaction = useCallback(
|
||||||
(operations: () => void) => {
|
(operations: () => void) => {
|
||||||
if (transactionInProgress.current) {
|
beginGesture();
|
||||||
operations();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveToHistoryBeforeChange();
|
|
||||||
transactionInProgress.current = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
operations();
|
operations();
|
||||||
} finally {
|
} finally {
|
||||||
transactionInProgress.current = false;
|
endGesture();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveToHistoryBeforeChange]
|
[beginGesture, endGesture]
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeIcon = useCallback(
|
const placeIcon = useCallback(
|
||||||
(params: { modelItem: ModelItem; viewItem: ViewItem }) => {
|
(params: { modelItem: ModelItem; viewItem: ViewItem }) => {
|
||||||
saveToHistoryBeforeChange();
|
beginGesture();
|
||||||
transactionInProgress.current = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stateAfterModelItem = createModelItem(params.modelItem);
|
const stateAfterModelItem = createModelItem(params.modelItem);
|
||||||
@@ -416,10 +389,10 @@ export const useScene = () => {
|
|||||||
createViewItem(params.viewItem, stateAfterModelItem);
|
createViewItem(params.viewItem, stateAfterModelItem);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
transactionInProgress.current = false;
|
endGesture();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createModelItem, createViewItem, saveToHistoryBeforeChange]
|
[createModelItem, createViewItem, beginGesture, endGesture]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSceneStore } from 'src/stores/sceneStore';
|
|||||||
import * as reducers from 'src/stores/reducers';
|
import * as reducers from 'src/stores/reducers';
|
||||||
import { Model } from 'src/types';
|
import { Model } from 'src/types';
|
||||||
import { INITIAL_SCENE_STATE } from 'src/config';
|
import { INITIAL_SCENE_STATE } from 'src/config';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
|
|
||||||
export const useView = () => {
|
export const useView = () => {
|
||||||
const uiStateActions = useUiStateStore((state) => {
|
const uiStateActions = useUiStateStore((state) => {
|
||||||
@@ -14,6 +15,8 @@ export const useView = () => {
|
|||||||
return state.actions;
|
return state.actions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { clearHistory } = useHistory();
|
||||||
|
|
||||||
const changeView = useCallback(
|
const changeView = useCallback(
|
||||||
(viewId: string, model: Model) => {
|
(viewId: string, model: Model) => {
|
||||||
const newState = reducers.view({
|
const newState = reducers.view({
|
||||||
@@ -24,8 +27,9 @@ export const useView = () => {
|
|||||||
|
|
||||||
sceneActions.set(newState.scene);
|
sceneActions.set(newState.scene);
|
||||||
uiStateActions.setView(viewId);
|
uiStateActions.setView(viewId);
|
||||||
|
clearHistory();
|
||||||
},
|
},
|
||||||
[uiStateActions, sceneActions]
|
[uiStateActions, sceneActions, clearHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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;
|
if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return;
|
||||||
|
|
||||||
const itemAtTile = getItemAtTile({
|
const itemAtTile = getItemAtTile({
|
||||||
@@ -72,8 +72,9 @@ export const Connector: ModeActions = {
|
|||||||
if (uiState.connectorInteractionMode === 'click') {
|
if (uiState.connectorInteractionMode === 'click') {
|
||||||
// Click mode: handle first and second clicks
|
// Click mode: handle first and second clicks
|
||||||
if (!uiState.mode.startAnchor) {
|
if (!uiState.mode.startAnchor) {
|
||||||
// First click: store the start position
|
// First click: begin gesture and store the start position
|
||||||
const startAnchor = itemAtTile?.type === 'ITEM'
|
history.beginGesture();
|
||||||
|
const startAnchor = itemAtTile?.type === 'ITEM'
|
||||||
? { itemId: itemAtTile.id }
|
? { itemId: itemAtTile.id }
|
||||||
: { tile: uiState.mouse.position.tile };
|
: { tile: uiState.mouse.position.tile };
|
||||||
|
|
||||||
@@ -144,7 +145,8 @@ export const Connector: ModeActions = {
|
|||||||
// Don't delete connectors to empty space - they're valid
|
// Don't delete connectors to empty space - they're valid
|
||||||
// Only validate minimum path length will be handled by the update
|
// 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({
|
uiState.actions.setMode({
|
||||||
type: 'CONNECTOR',
|
type: 'CONNECTOR',
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
@@ -155,7 +157,8 @@ export const Connector: ModeActions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Drag mode: original behavior
|
// Drag mode: begin gesture before creating
|
||||||
|
history.beginGesture();
|
||||||
const newConnector: ConnectorI = {
|
const newConnector: ConnectorI = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
color: scene.colors[0].id,
|
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;
|
if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return;
|
||||||
|
|
||||||
// Only handle mouseup for drag mode
|
// Only handle mouseup for drag mode
|
||||||
@@ -191,6 +194,7 @@ export const Connector: ModeActions = {
|
|||||||
// Don't delete connectors to empty space - they're valid
|
// Don't delete connectors to empty space - they're valid
|
||||||
// Validation is handled in the reducer layer
|
// Validation is handled in the reducer layer
|
||||||
|
|
||||||
|
history.endGesture();
|
||||||
uiState.actions.setMode({
|
uiState.actions.setMode({
|
||||||
type: 'CONNECTOR',
|
type: 'CONNECTOR',
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
|
|||||||
@@ -50,40 +50,36 @@ const dragItems = (
|
|||||||
const hasUpdates = newTiles || textBoxRefs.length > 0 || rectangleRefs.length > 0;
|
const hasUpdates = newTiles || textBoxRefs.length > 0 || rectangleRefs.length > 0;
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
// Wrap ALL updates in a single transaction with state chaining
|
let currentState: State | undefined;
|
||||||
// This ensures each update builds on the previous one's state
|
|
||||||
scene.transaction(() => {
|
|
||||||
let currentState: State | undefined;
|
|
||||||
|
|
||||||
// 1. Update nodes
|
// 1. Update nodes
|
||||||
if (newTiles) {
|
if (newTiles) {
|
||||||
itemRefs.forEach((item, index) => {
|
itemRefs.forEach((item, index) => {
|
||||||
currentState = scene.updateViewItem(item.id, {
|
currentState = scene.updateViewItem(item.id, {
|
||||||
tile: newTiles[index]
|
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)
|
|
||||||
}, currentState);
|
}, currentState);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Update rectangles (chained from textbox state)
|
// 2. Update textboxes (chained from node state)
|
||||||
rectangleRefs.forEach((item) => {
|
textBoxRefs.forEach((item) => {
|
||||||
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
|
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
|
||||||
currentState = scene.updateRectangle(item.id, {
|
currentState = scene.updateTextBox(item.id, {
|
||||||
from: CoordsUtils.add(rectangle.from, delta),
|
tile: CoordsUtils.add(textBox.tile, delta)
|
||||||
to: CoordsUtils.add(rectangle.to, delta)
|
}, currentState);
|
||||||
}, 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) => {
|
anchorRefs.forEach((item) => {
|
||||||
const connector = getAnchorParent(item.id, scene.connectors);
|
const connector = getAnchorParent(item.id, scene.connectors);
|
||||||
|
|
||||||
@@ -125,13 +121,15 @@ const dragItems = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DragItems: ModeActions = {
|
export const DragItems: ModeActions = {
|
||||||
entry: ({ uiState, rendererRef }) => {
|
entry: ({ uiState, rendererRef, history }) => {
|
||||||
if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return;
|
if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return;
|
||||||
|
|
||||||
|
history.beginGesture();
|
||||||
const renderer = rendererRef;
|
const renderer = rendererRef;
|
||||||
renderer.style.userSelect = 'none';
|
renderer.style.userSelect = 'none';
|
||||||
},
|
},
|
||||||
exit: ({ rendererRef }) => {
|
exit: ({ rendererRef, history }) => {
|
||||||
|
history.endGesture();
|
||||||
const renderer = rendererRef;
|
const renderer = rendererRef;
|
||||||
renderer.style.userSelect = 'auto';
|
renderer.style.userSelect = 'auto';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { produce } from 'immer';
|
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';
|
import { screenToIso, isPointInPolygon } from 'src/utils';
|
||||||
|
|
||||||
// Helper to find all items whose centers are within the freehand polygon
|
// Helper to find all items whose centers are within the freehand polygon
|
||||||
const getItemsInFreehandBounds = (
|
const getItemsInFreehandBounds = (
|
||||||
pathTiles: Coords[],
|
pathTiles: Coords[],
|
||||||
scene: any
|
scene: SceneItemsSnapshot
|
||||||
): ItemReference[] => {
|
): ItemReference[] => {
|
||||||
const items: ItemReference[] = [];
|
const items: ItemReference[] = [];
|
||||||
|
|
||||||
if (pathTiles.length < 3) return items;
|
if (pathTiles.length < 3) return items;
|
||||||
|
|
||||||
// Check all nodes/items
|
// Check all nodes/items
|
||||||
scene.items.forEach((item: any) => {
|
scene.items.forEach((item) => {
|
||||||
if (isPointInPolygon(item.tile, pathTiles)) {
|
if (isPointInPolygon(item.tile, pathTiles)) {
|
||||||
items.push({ type: 'ITEM', id: item.id });
|
items.push({ type: 'ITEM', id: item.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
||||||
scene.rectangles.forEach((rectangle: any) => {
|
scene.rectangles.forEach((rectangle) => {
|
||||||
const corners = [
|
const corners = [
|
||||||
rectangle.from,
|
rectangle.from,
|
||||||
{ x: rectangle.to.x, y: rectangle.from.y },
|
{ x: rectangle.to.x, y: rectangle.from.y },
|
||||||
@@ -36,7 +36,7 @@ const getItemsInFreehandBounds = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check all text boxes
|
// Check all text boxes
|
||||||
scene.textBoxes.forEach((textBox: any) => {
|
scene.textBoxes.forEach((textBox) => {
|
||||||
if (isPointInPolygon(textBox.tile, pathTiles)) {
|
if (isPointInPolygon(textBox.tile, pathTiles)) {
|
||||||
items.push({ type: 'TEXTBOX', id: textBox.id });
|
items.push({ type: 'TEXTBOX', id: textBox.id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { ModeActions, ItemReference } from 'src/types';
|
import { ModeActions, ItemReference, Coords, SceneItemsSnapshot } from 'src/types';
|
||||||
import { CoordsUtils, isWithinBounds, hasMovedTile } from 'src/utils';
|
import { isWithinBounds, hasMovedTile } from 'src/utils';
|
||||||
|
|
||||||
// Helper to find all items within the lasso bounds
|
// Helper to find all items within the lasso bounds
|
||||||
const getItemsInBounds = (
|
const getItemsInBounds = (
|
||||||
startTile: { x: number; y: number },
|
startTile: Coords,
|
||||||
endTile: { x: number; y: number },
|
endTile: Coords,
|
||||||
scene: any
|
scene: SceneItemsSnapshot
|
||||||
): ItemReference[] => {
|
): ItemReference[] => {
|
||||||
const items: ItemReference[] = [];
|
const items: ItemReference[] = [];
|
||||||
|
|
||||||
// Check all nodes/items
|
// Check all nodes/items
|
||||||
scene.items.forEach((item: any) => {
|
scene.items.forEach((item) => {
|
||||||
if (isWithinBounds(item.tile, [startTile, endTile])) {
|
if (isWithinBounds(item.tile, [startTile, endTile])) {
|
||||||
items.push({ type: 'ITEM', id: item.id });
|
items.push({ type: 'ITEM', id: item.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
|
||||||
scene.rectangles.forEach((rectangle: any) => {
|
scene.rectangles.forEach((rectangle) => {
|
||||||
const corners = [
|
const corners = [
|
||||||
rectangle.from,
|
rectangle.from,
|
||||||
{ x: rectangle.to.x, y: rectangle.from.y },
|
{ x: rectangle.to.x, y: rectangle.from.y },
|
||||||
@@ -37,7 +37,7 @@ const getItemsInBounds = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check all text boxes
|
// Check all text boxes
|
||||||
scene.textBoxes.forEach((textBox: any) => {
|
scene.textBoxes.forEach((textBox) => {
|
||||||
if (isWithinBounds(textBox.tile, [startTile, endTile])) {
|
if (isWithinBounds(textBox.tile, [startTile, endTile])) {
|
||||||
items.push({ type: 'TEXTBOX', id: textBox.id });
|
items.push({ type: 'TEXTBOX', id: textBox.id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ export const DrawRectangle: ModeActions = {
|
|||||||
to: uiState.mouse.position.tile
|
to: uiState.mouse.position.tile
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mousedown: ({ uiState, scene, isRendererInteraction }) => {
|
mousedown: ({ uiState, scene, isRendererInteraction, history }) => {
|
||||||
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction)
|
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
history.beginGesture();
|
||||||
const newRectangleId = generateId();
|
const newRectangleId = generateId();
|
||||||
|
|
||||||
scene.createRectangle({
|
scene.createRectangle({
|
||||||
@@ -41,9 +42,10 @@ export const DrawRectangle: ModeActions = {
|
|||||||
|
|
||||||
uiState.actions.setMode(newMode);
|
uiState.actions.setMode(newMode);
|
||||||
},
|
},
|
||||||
mouseup: ({ uiState }) => {
|
mouseup: ({ uiState, history }) => {
|
||||||
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return;
|
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return;
|
||||||
|
|
||||||
|
history.endGesture();
|
||||||
uiState.actions.setMode({
|
uiState.actions.setMode({
|
||||||
type: 'CURSOR',
|
type: 'CURSOR',
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
import { ModeActions } from 'src/types';
|
import { ModeActions } from 'src/types';
|
||||||
|
|
||||||
export const TransformRectangle: ModeActions = {
|
export const TransformRectangle: ModeActions = {
|
||||||
entry: () => {},
|
entry: ({ history }) => {
|
||||||
|
history.beginGesture();
|
||||||
|
},
|
||||||
exit: () => {},
|
exit: () => {},
|
||||||
mousemove: ({ uiState, scene }) => {
|
mousemove: ({ uiState, scene }) => {
|
||||||
if (
|
if (
|
||||||
@@ -63,9 +65,10 @@ export const TransformRectangle: ModeActions = {
|
|||||||
mousedown: () => {
|
mousedown: () => {
|
||||||
// MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`)
|
// MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`)
|
||||||
},
|
},
|
||||||
mouseup: ({ uiState }) => {
|
mouseup: ({ uiState, history }) => {
|
||||||
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
|
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
|
||||||
|
|
||||||
|
history.endGesture();
|
||||||
uiState.actions.setMode({
|
uiState.actions.setMode({
|
||||||
type: 'CURSOR',
|
type: 'CURSOR',
|
||||||
mousedownItem: null,
|
mousedownItem: null,
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ export const TextBox: ModeActions = {
|
|||||||
tile: uiState.mouse.position.tile
|
tile: uiState.mouse.position.tile
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mouseup: ({ uiState, scene, isRendererInteraction }) => {
|
mouseup: ({ uiState, scene, isRendererInteraction, history }) => {
|
||||||
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
|
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
|
||||||
|
|
||||||
if (!isRendererInteraction) {
|
if (!isRendererInteraction) {
|
||||||
scene.deleteTextBox(uiState.mode.id);
|
history.cancelGesture();
|
||||||
} else {
|
} else {
|
||||||
|
history.endGesture();
|
||||||
uiState.actions.setItemControls({
|
uiState.actions.setItemControls({
|
||||||
type: 'TEXTBOX',
|
type: 'TEXTBOX',
|
||||||
id: uiState.mode.id
|
id: uiState.mode.id
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const useInteractionManager = () => {
|
|||||||
const modelStoreApi = useModelStoreApi();
|
const modelStoreApi = useModelStoreApi();
|
||||||
const scene = useScene();
|
const scene = useScene();
|
||||||
const { size: rendererSize } = useResizeObserver(rendererEl);
|
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 { createTextBox } = scene;
|
||||||
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
|
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
|
||||||
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
|
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
|
||||||
@@ -131,7 +131,7 @@ export const useInteractionManager = () => {
|
|||||||
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
|
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
|
||||||
|
|
||||||
if (isConnectionInProgress && connectorMode.id) {
|
if (isConnectionInProgress && connectorMode.id) {
|
||||||
scene.deleteConnector(connectorMode.id);
|
cancelGesture();
|
||||||
|
|
||||||
uiState.actions.setMode({
|
uiState.actions.setMode({
|
||||||
type: 'CONNECTOR',
|
type: 'CONNECTOR',
|
||||||
@@ -158,9 +158,13 @@ export const useInteractionManager = () => {
|
|||||||
|
|
||||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
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) {
|
if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canUndo) {
|
if (canUndo && !isUndoBlocked) {
|
||||||
undo();
|
undo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +175,7 @@ export const useInteractionManager = () => {
|
|||||||
(e.key.toLowerCase() === 'z' && e.shiftKey))
|
(e.key.toLowerCase() === 'z' && e.shiftKey))
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canRedo) {
|
if (canRedo && !isUndoBlocked) {
|
||||||
redo();
|
redo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +234,7 @@ export const useInteractionManager = () => {
|
|||||||
});
|
});
|
||||||
} else if (hotkeyMapping.text && key === hotkeyMapping.text) {
|
} else if (hotkeyMapping.text && key === hotkeyMapping.text) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
beginGesture();
|
||||||
const textBoxId = generateId();
|
const textBoxId = generateId();
|
||||||
createTextBox({
|
createTextBox({
|
||||||
...TEXTBOX_DEFAULTS,
|
...TEXTBOX_DEFAULTS,
|
||||||
@@ -265,7 +270,7 @@ export const useInteractionManager = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
return window.removeEventListener('keydown', handleKeyDown);
|
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(
|
const processMouseUpdate = useCallback(
|
||||||
(nextMouse: Mouse, e: SlimMouseEvent) => {
|
(nextMouse: Mouse, e: SlimMouseEvent) => {
|
||||||
@@ -284,6 +289,7 @@ export const useInteractionManager = () => {
|
|||||||
const baseState: State = {
|
const baseState: State = {
|
||||||
model,
|
model,
|
||||||
scene,
|
scene,
|
||||||
|
history: { beginGesture, endGesture, cancelGesture, isGestureInProgress },
|
||||||
uiState,
|
uiState,
|
||||||
rendererRef: rendererRef.current,
|
rendererRef: rendererRef.current,
|
||||||
rendererSize,
|
rendererSize,
|
||||||
@@ -307,7 +313,7 @@ export const useInteractionManager = () => {
|
|||||||
modeFunction(baseState);
|
modeFunction(baseState);
|
||||||
reducerTypeRef.current = uiState.mode.type;
|
reducerTypeRef.current = uiState.mode.type;
|
||||||
},
|
},
|
||||||
[uiStateApi, modelStoreApi, scene, rendererSize]
|
[uiStateApi, modelStoreApi, scene, rendererSize, beginGesture, endGesture, cancelGesture, isGestureInProgress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onMouseEvent = useCallback(
|
const onMouseEvent = useCallback(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ModelStore, Model } from 'src/types';
|
||||||
import { INITIAL_DATA } from 'src/config';
|
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 = () => {
|
const initialState = () => {
|
||||||
return createStore<ModelStoreWithHistory>((set, get) => {
|
return createStore<ModelStore>((set, get) => {
|
||||||
const initialModel = { ...INITIAL_DATA };
|
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 {
|
return {
|
||||||
...initialModel,
|
...initialModel,
|
||||||
history: createHistoryState(initialModel),
|
|
||||||
actions: {
|
actions: {
|
||||||
get,
|
get,
|
||||||
set: (updates: Partial<Model>, skipHistory = false) => {
|
set: (updates: Partial<Model>) => {
|
||||||
if (!skipHistory) {
|
|
||||||
saveToHistory();
|
|
||||||
}
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
return { ...state, ...updates };
|
return { ...state, ...updates };
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
saveToHistory,
|
|
||||||
clearHistory
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -183,7 +44,7 @@ export const ModelProvider = ({ children }: ProviderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useModelStore<T>(
|
export function useModelStore<T>(
|
||||||
selector: (state: ModelStoreWithHistory) => T,
|
selector: (state: ModelStore) => T,
|
||||||
equalityFn?: (left: T, right: T) => boolean
|
equalityFn?: (left: T, right: T) => boolean
|
||||||
) {
|
) {
|
||||||
const store = useContext(ModelContext);
|
const store = useContext(ModelContext);
|
||||||
|
|||||||
@@ -2,156 +2,22 @@ import React, { createContext, useRef, useContext } from 'react';
|
|||||||
import { createStore, useStore } from 'zustand';
|
import { createStore, useStore } from 'zustand';
|
||||||
import { SceneStore, Scene } from 'src/types';
|
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 = () => {
|
const initialState = () => {
|
||||||
return createStore<SceneStoreWithHistory>((set, get) => {
|
return createStore<SceneStore>((set, get) => {
|
||||||
const initialScene: Scene = {
|
const initialScene: Scene = {
|
||||||
connectors: {},
|
connectors: {},
|
||||||
textBoxes: {}
|
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 {
|
return {
|
||||||
...initialScene,
|
...initialScene,
|
||||||
history: createSceneHistoryState(initialScene),
|
|
||||||
actions: {
|
actions: {
|
||||||
get,
|
get,
|
||||||
set: (updates: Partial<Scene>, skipHistory = false) => {
|
set: (updates: Partial<Scene>) => {
|
||||||
if (!skipHistory) {
|
|
||||||
saveToHistory();
|
|
||||||
}
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
return { ...state, ...updates };
|
return { ...state, ...updates };
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
saveToHistory,
|
|
||||||
clearHistory
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -180,7 +46,7 @@ export const SceneProvider = ({ children }: ProviderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useSceneStore<T>(
|
export function useSceneStore<T>(
|
||||||
selector: (state: SceneStoreWithHistory) => T,
|
selector: (state: SceneStore) => T,
|
||||||
equalityFn?: (left: T, right: T) => boolean
|
equalityFn?: (left: T, right: T) => boolean
|
||||||
) {
|
) {
|
||||||
const store = useContext(SceneContext);
|
const store = useContext(SceneContext);
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { ModelStore, UiStateStore, Size } from 'src/types';
|
import { ModelStore, UiStateStore, Size } from 'src/types';
|
||||||
import { useScene } from 'src/hooks/useScene';
|
import { useScene } from 'src/hooks/useScene';
|
||||||
|
import { useHistory } from 'src/hooks/useHistory';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
model: ModelStore;
|
model: ModelStore;
|
||||||
scene: ReturnType<typeof useScene>;
|
scene: ReturnType<typeof useScene>;
|
||||||
uiState: UiStateStore;
|
uiState: UiStateStore;
|
||||||
|
history: Pick<
|
||||||
|
ReturnType<typeof useHistory>,
|
||||||
|
'beginGesture' | 'endGesture' | 'cancelGesture' | 'isGestureInProgress'
|
||||||
|
>;
|
||||||
rendererRef: HTMLElement;
|
rendererRef: HTMLElement;
|
||||||
rendererSize: Size;
|
rendererSize: Size;
|
||||||
isRendererInteraction: boolean;
|
isRendererInteraction: boolean;
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
connectorStyleOptions,
|
connectorStyleOptions,
|
||||||
connectorLineTypeOptions
|
connectorLineTypeOptions
|
||||||
} from 'src/schemas';
|
} from 'src/schemas';
|
||||||
import { StoreApi } from 'zustand';
|
|
||||||
|
|
||||||
export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas';
|
export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas';
|
||||||
export type Model = z.infer<typeof modelSchema>;
|
export type Model = z.infer<typeof modelSchema>;
|
||||||
export type ModelItems = z.infer<typeof modelItemsSchema>;
|
export type ModelItems = z.infer<typeof modelItemsSchema>;
|
||||||
@@ -39,17 +37,7 @@ export type Rectangle = z.infer<typeof rectangleSchema>;
|
|||||||
|
|
||||||
export type ModelStore = Model & {
|
export type ModelStore = Model & {
|
||||||
actions: {
|
actions: {
|
||||||
get: StoreApi<ModelStore>['getState'];
|
get: () => ModelStore;
|
||||||
set: StoreApi<ModelStore>['setState'];
|
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 { 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 = {
|
export const tileOriginOptions = {
|
||||||
CENTER: 'CENTER',
|
CENTER: 'CENTER',
|
||||||
@@ -50,7 +56,7 @@ export interface Scene {
|
|||||||
|
|
||||||
export type SceneStore = Scene & {
|
export type SceneStore = Scene & {
|
||||||
actions: {
|
actions: {
|
||||||
get: StoreApi<SceneStore>['getState'];
|
get: () => SceneStore;
|
||||||
set: StoreApi<SceneStore>['setState'];
|
set: (updates: Partial<Scene>) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export interface DragItemsMode {
|
|||||||
type: 'DRAG_ITEMS';
|
type: 'DRAG_ITEMS';
|
||||||
showCursor: boolean;
|
showCursor: boolean;
|
||||||
items: ItemReference[];
|
items: ItemReference[];
|
||||||
isInitialMovement: Boolean;
|
isInitialMovement: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanMode {
|
export interface PanMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user