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:
Stan
2026-02-13 21:40:44 +00:00
parent 13b2fc840e
commit c3f5df23ca
45 changed files with 1841 additions and 884 deletions

View File

@@ -1,11 +1,13 @@
import React, { useEffect } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { Box } from '@mui/material';
import { shallow } from 'zustand/shallow';
import { theme } from 'src/styles/theme';
import { IsoflowProps } from 'src/types';
import { setWindowCursor, modelFromModelStore } from 'src/utils';
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { HistoryProvider } from 'src/stores/historyStore';
import { LocaleProvider } from 'src/stores/localeStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/components/Renderer/Renderer';
@@ -33,7 +35,7 @@ const App = ({
const initialDataManager = useInitialDataManager();
const model = useModelStore((state) => {
return modelFromModelStore(state);
});
}, shallow);
const { load } = initialDataManager;
@@ -99,9 +101,11 @@ export const Isoflow = (props: IsoflowProps) => {
<LocaleProvider locale={props.locale || enUS}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>
<App {...props} />
</UiStateProvider>
<HistoryProvider>
<UiStateProvider>
<App {...props} />
</UiStateProvider>
</HistoryProvider>
</SceneProvider>
</ModelProvider>
</LocaleProvider>

View File

@@ -0,0 +1,492 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { ModelProvider, useModelStoreApi } from 'src/stores/modelStore';
import { SceneProvider, useSceneStoreApi } from 'src/stores/sceneStore';
import { HistoryProvider, useHistoryStoreApi } from 'src/stores/historyStore';
import { UiStateProvider } from 'src/stores/uiStateStore';
import { useHistory } from 'src/hooks/useHistory';
/**
* Integration tests using real providers (no mocks).
* Verifies the unified history system works end-to-end
* across model, scene, and history stores.
*/
const AllProviders = ({ children }: { children: React.ReactNode }) => (
<ModelProvider>
<SceneProvider>
<HistoryProvider>
<UiStateProvider>{children}</UiStateProvider>
</HistoryProvider>
</SceneProvider>
</ModelProvider>
);
/** Hook that exposes history + imperative store APIs for testing */
function useTestHarness() {
const history = useHistory();
const modelApi = useModelStoreApi();
const sceneApi = useSceneStoreApi();
const historyApi = useHistoryStoreApi();
return { history, modelApi, sceneApi, historyApi };
}
describe('history integration (real providers)', () => {
const renderTestHook = () =>
renderHook(() => useTestHarness(), { wrapper: AllProviders });
describe('saveSnapshot + undo + redo round-trip', () => {
it('undo restores model state atomically', () => {
const { result } = renderTestHook();
// Save initial state as a snapshot
act(() => {
result.current.history.saveSnapshot();
});
// Mutate model
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Changed' });
});
// Undo should restore original title
let success = false;
act(() => {
success = result.current.history.undo();
});
expect(success).toBe(true);
expect(result.current.modelApi.getState().title).toBe('Untitled');
});
it('undo restores scene state atomically', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.saveSnapshot();
});
// Mutate scene
act(() => {
result.current.sceneApi.getState().actions.set({
connectors: {
'conn-1': {
path: {
tiles: [{ x: 0, y: 0 }],
rectangle: { x: 0, y: 0, width: 1, height: 1 }
}
}
}
});
});
let success = false;
act(() => {
success = result.current.history.undo();
});
expect(success).toBe(true);
expect(result.current.sceneApi.getState().connectors).toEqual({});
});
it('redo re-applies the undone state', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.saveSnapshot();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'V2' });
});
act(() => {
result.current.history.undo();
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
let success = false;
act(() => {
success = result.current.history.redo();
});
expect(success).toBe(true);
// Redo restores the state that was current when we undid (the V2 state)
expect(result.current.modelApi.getState().title).toBe('V2');
});
it('undo returns false when history is empty', () => {
const { result } = renderTestHook();
let success = true;
act(() => {
success = result.current.history.undo();
});
expect(success).toBe(false);
});
it('redo returns false when future is empty', () => {
const { result } = renderTestHook();
let success = true;
act(() => {
success = result.current.history.redo();
});
expect(success).toBe(false);
});
});
describe('gesture lifecycle produces single history entry', () => {
it('beginGesture + multiple mutations + endGesture = single undo step', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.beginGesture();
});
// Multiple mutations during gesture
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Step1' });
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Step2' });
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Step3' });
});
act(() => {
result.current.history.endGesture();
});
// Only 1 history entry (from beginGesture)
const historyState = result.current.historyApi.getState();
expect(historyState.past).toHaveLength(1);
// Undo restores pre-gesture state in one step
act(() => {
result.current.history.undo();
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
});
it('saveSnapshot is blocked during gesture', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.beginGesture();
});
// These should be no-ops
act(() => {
result.current.history.saveSnapshot();
result.current.history.saveSnapshot();
result.current.history.saveSnapshot();
});
act(() => {
result.current.history.endGesture();
});
// Only the beginGesture entry
expect(result.current.historyApi.getState().past).toHaveLength(1);
});
it('beginGesture is idempotent when already in gesture', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.beginGesture();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Changed' });
});
// Second beginGesture should be ignored
act(() => {
result.current.history.beginGesture();
});
act(() => {
result.current.history.endGesture();
});
// Only 1 entry
expect(result.current.historyApi.getState().past).toHaveLength(1);
// The saved state is from the FIRST beginGesture (pre-mutation)
expect(result.current.historyApi.getState().past[0].model.title).toBe(
'Untitled'
);
});
});
describe('cancelGesture restores pre-gesture state', () => {
it('restores model state on cancel', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.beginGesture();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'During gesture' });
});
expect(result.current.modelApi.getState().title).toBe('During gesture');
act(() => {
result.current.history.cancelGesture();
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
expect(result.current.historyApi.getState().gestureInProgress).toBe(false);
expect(result.current.historyApi.getState().past).toHaveLength(0);
});
it('restores scene state on cancel', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.beginGesture();
});
act(() => {
result.current.sceneApi.getState().actions.set({
connectors: {
'temp-conn': {
path: {
tiles: [{ x: 0, y: 0 }],
rectangle: { x: 0, y: 0, width: 1, height: 1 }
}
}
}
});
});
act(() => {
result.current.history.cancelGesture();
});
expect(result.current.sceneApi.getState().connectors).toEqual({});
});
it('cancelGesture is a no-op when no gesture in progress', () => {
const { result } = renderTestHook();
// Should not throw
act(() => {
result.current.history.cancelGesture();
});
expect(result.current.historyApi.getState().past).toHaveLength(0);
});
});
describe('undo + redo restores model+scene atomically', () => {
it('combined model+scene mutation undoes as one unit', () => {
const { result } = renderTestHook();
// Save pre-change snapshot
act(() => {
result.current.history.saveSnapshot();
});
// Mutate both stores
act(() => {
result.current.modelApi.getState().actions.set({ title: 'New Title' });
result.current.sceneApi.getState().actions.set({
textBoxes: {
'tb-1': { size: { width: 100, height: 50 } }
}
});
});
// Undo should restore both
act(() => {
result.current.history.undo();
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
expect(result.current.sceneApi.getState().textBoxes).toEqual({});
// Redo should bring both back
act(() => {
result.current.history.redo();
});
expect(result.current.modelApi.getState().title).toBe('New Title');
expect(result.current.sceneApi.getState().textBoxes).toEqual({
'tb-1': { size: { width: 100, height: 50 } }
});
});
});
describe('clearHistory resets everything', () => {
it('clears past, future, and gestureInProgress', () => {
const { result } = renderTestHook();
// Build up some history
act(() => {
result.current.history.saveSnapshot();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'V2' });
result.current.history.saveSnapshot();
});
act(() => {
result.current.history.undo();
});
// Verify we have past and future
expect(result.current.historyApi.getState().past.length).toBeGreaterThan(0);
expect(result.current.historyApi.getState().future.length).toBeGreaterThan(0);
act(() => {
result.current.history.clearHistory();
});
const state = result.current.historyApi.getState();
expect(state.past).toHaveLength(0);
expect(state.future).toHaveLength(0);
expect(state.gestureInProgress).toBe(false);
});
});
describe('new snapshot clears future (branching)', () => {
it('saving after undo discards redo stack', () => {
const { result } = renderTestHook();
act(() => {
result.current.history.saveSnapshot();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'V2' });
result.current.history.saveSnapshot();
});
// Undo once
act(() => {
result.current.history.undo();
});
expect(result.current.historyApi.getState().future.length).toBeGreaterThan(0);
// New action should clear future
act(() => {
result.current.modelApi.getState().actions.set({ title: 'V3' });
result.current.history.saveSnapshot();
});
expect(result.current.historyApi.getState().future).toHaveLength(0);
});
});
describe('multiple undo/redo traversals', () => {
it('can undo and redo through multiple states', () => {
const { result } = renderTestHook();
// Create 3 snapshots
act(() => {
result.current.history.saveSnapshot(); // saves "Untitled"
result.current.modelApi.getState().actions.set({ title: 'A' });
});
act(() => {
result.current.history.saveSnapshot(); // saves "A"
result.current.modelApi.getState().actions.set({ title: 'B' });
});
act(() => {
result.current.history.saveSnapshot(); // saves "B"
result.current.modelApi.getState().actions.set({ title: 'C' });
});
// Current state is "C", past has [Untitled, A, B]
expect(result.current.modelApi.getState().title).toBe('C');
// Undo 3 times
act(() => {
result.current.history.undo(); // restores "B", pushes "C" to future
});
expect(result.current.modelApi.getState().title).toBe('B');
act(() => {
result.current.history.undo(); // restores "A", pushes "B" to future
});
expect(result.current.modelApi.getState().title).toBe('A');
act(() => {
result.current.history.undo(); // restores "Untitled", pushes "A" to future
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
// No more undo
let success = true;
act(() => {
success = result.current.history.undo();
});
expect(success).toBe(false);
// Redo back through
act(() => {
result.current.history.redo();
});
expect(result.current.modelApi.getState().title).toBe('A');
act(() => {
result.current.history.redo();
});
expect(result.current.modelApi.getState().title).toBe('B');
act(() => {
result.current.history.redo();
});
expect(result.current.modelApi.getState().title).toBe('C');
// No more redo
success = true;
act(() => {
success = result.current.history.redo();
});
expect(success).toBe(false);
});
});
describe('gesture + undo interaction', () => {
it('gesture followed by undo restores pre-gesture state', () => {
const { result } = renderTestHook();
// Do a gesture (simulates drag, draw, etc.)
act(() => {
result.current.history.beginGesture();
});
act(() => {
result.current.modelApi.getState().actions.set({ title: 'Dragged' });
result.current.sceneApi.getState().actions.set({
connectors: {
'drag-conn': {
path: {
tiles: [{ x: 1, y: 1 }, { x: 2, y: 2 }],
rectangle: { x: 1, y: 1, width: 1, height: 1 }
}
}
}
});
});
act(() => {
result.current.history.endGesture();
});
// Now undo the entire gesture as one step
act(() => {
result.current.history.undo();
});
expect(result.current.modelApi.getState().title).toBe('Untitled');
expect(result.current.sceneApi.getState().connectors).toEqual({});
});
});
});

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { generateId, findNearestUnoccupiedTile } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { useModelStore } from 'src/stores/modelStore';
import { useModelStoreApi } from 'src/stores/modelStore';
import { VIEW_ITEM_DEFAULTS } from 'src/config';
import { ContextMenu } from './ContextMenu';
@@ -12,9 +12,7 @@ interface Props {
export const ContextMenuManager = ({ anchorEl }: Props) => {
const scene = useScene();
const model = useModelStore((state) => {
return state;
});
const modelStoreApi = useModelStoreApi();
const contextMenu = useUiStateStore((state) => {
return state.contextMenu;
});
@@ -36,10 +34,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
label: 'Add Node',
onClick: () => {
if (!contextMenu) return;
if (model.icons.length > 0) {
const { icons } = modelStoreApi.getState();
if (icons.length > 0) {
const modelItemId = generateId();
const firstIcon = model.icons[0];
const firstIcon = icons[0];
// Find nearest unoccupied tile (should return the same tile since context menu is for empty tiles)
const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile;
@@ -63,10 +62,11 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
label: 'Add Rectangle',
onClick: () => {
if (!contextMenu) return;
if (model.colors.length > 0) {
const { colors } = modelStoreApi.getState();
if (colors.length > 0) {
scene.createRectangle({
id: generateId(),
color: model.colors[0].id,
color: colors[0].id,
from: contextMenu.tile,
to: contextMenu.tile
});

View File

@@ -22,3 +22,5 @@ export const Cursor = memo(() => {
/>
);
});
Cursor.displayName = 'Cursor';

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Box } from '@mui/material';
import { shallow } from 'zustand/shallow';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
@@ -9,7 +10,8 @@ export const DebugUtils = () => {
const uiState = useUiStateStore(
({ scroll, mouse, zoom, mode, rendererEl }) => {
return { scroll, mouse, zoom, mode, rendererEl };
}
},
shallow
);
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);

View File

@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';
import { ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { HistoryProvider } from 'src/stores/historyStore';
import { UiStateProvider } from 'src/stores/uiStateStore';
import { DebugUtils } from '../DebugUtils';
@@ -13,7 +14,9 @@ describe('DebugUtils', () => {
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>{children}</UiStateProvider>
<HistoryProvider>
<UiStateProvider>{children}</UiStateProvider>
</HistoryProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>

View File

@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';
import { ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { HistoryProvider } from 'src/stores/historyStore';
import { UiStateProvider } from 'src/stores/uiStateStore';
import { SizeIndicator } from '../SizeIndicator';
@@ -13,7 +14,9 @@ describe('SizeIndicator', () => {
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>{children}</UiStateProvider>
<HistoryProvider>
<UiStateProvider>{children}</UiStateProvider>
</HistoryProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>

View File

@@ -55,3 +55,5 @@ export const IsoTileArea = memo(({
</Svg>
);
});
IsoTileArea.displayName = 'IsoTileArea';

View File

@@ -24,6 +24,7 @@ import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import {
Close as CloseIcon,
Add as AddIcon,
@@ -44,6 +45,7 @@ export const ConnectorControls = ({ id }: Props) => {
});
const connector = useConnector(id);
const { updateConnector, deleteConnector } = useScene();
const { beginGesture, endGesture } = useHistory();
const [useCustomColor, setUseCustomColor] = useState(
!!connector?.customColor
);
@@ -207,6 +209,8 @@ export const ConnectorControls = ({ id }: Props) => {
<TextField
label="Text"
value={label.text}
onFocus={beginGesture}
onBlur={endGesture}
onChange={(e) => {
return handleUpdateLabel(label.id, {
text: e.target.value

View File

@@ -4,6 +4,7 @@ import { ModelItem, ViewItem } from 'src/types';
import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor';
import { useModelItem } from 'src/hooks/useModelItem';
import { useModelStore } from 'src/stores/modelStore';
import { useHistory } from 'src/hooks/useHistory';
import { DeleteButton } from '../../components/DeleteButton';
import { Section } from '../../components/Section';
@@ -28,6 +29,7 @@ export const NodeSettings = ({
const modelItem = useModelItem(node.id);
const modelActions = useModelStore((state) => state.actions);
const icons = useModelStore((state) => state.icons);
const { beginGesture, endGesture, isGestureInProgress } = useHistory();
// Local state for smooth slider interaction
const currentIcon = icons.find(icon => icon.id === modelItem?.icon);
@@ -57,10 +59,13 @@ export const NodeSettings = ({
// Handle slider change with local state + debounced store update
const handleScaleChange = useCallback((e: Event, newScale: number | number[]) => {
if (!isGestureInProgress()) {
beginGesture();
}
const scale = newScale as number;
setLocalScale(scale); // Immediate UI update
updateIconScale(scale); // Debounced store update
}, [updateIconScale]);
}, [updateIconScale, beginGesture, isGestureInProgress]);
// Cleanup timeout on unmount
useEffect(() => {
@@ -80,6 +85,8 @@ export const NodeSettings = ({
<Section title="Name">
<TextField
value={modelItem.name}
onFocus={() => beginGesture()}
onBlur={() => endGesture()}
onChange={(e) => {
const text = e.target.value as string;
if (modelItem.name !== text) onModelItemUpdated({ name: text });
@@ -89,6 +96,8 @@ export const NodeSettings = ({
<Section title="Description">
<RichTextEditor
value={modelItem.description}
onFocus={() => beginGesture()}
onBlur={() => endGesture()}
onChange={(text) => {
if (modelItem.description !== text)
onModelItemUpdated({ description: text });
@@ -119,6 +128,7 @@ export const NodeSettings = ({
max={2.5}
value={localScale}
onChange={handleScaleChange}
onChangeCommitted={endGesture}
/>
</Section>
<Section>

View File

@@ -16,6 +16,7 @@ import { useTextBox } from 'src/hooks/useTextBox';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getIsoProjectionCss } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { DeleteButton } from '../components/DeleteButton';
@@ -30,6 +31,7 @@ export const TextBoxControls = ({ id }: Props) => {
});
const textBox = useTextBox(id);
const { updateTextBox, deleteTextBox } = useScene();
const { beginGesture, endGesture } = useHistory();
// If textBox doesn't exist, return null
if (!textBox) {
@@ -58,6 +60,8 @@ export const TextBoxControls = ({ id }: Props) => {
<Section title="Enter text">
<TextField
value={textBox.content}
onFocus={beginGesture}
onBlur={endGesture}
onChange={(e) => {
updateTextBox(textBox.id, { content: e.target.value as string });
}}

View File

@@ -12,6 +12,7 @@ import {
Settings as SettingsIcon,
} from '@mui/icons-material';
import { shallow } from 'zustand/shallow';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { useUiStateStore } from 'src/stores/uiStateStore';
@@ -32,7 +33,7 @@ export const MainMenu = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const model = useModelStore((state) => {
return modelFromModelStore(state);
});
}, shallow);
const isMainMenuOpen = useUiStateStore((state) => {
return state.isMainMenuOpen;
});
@@ -120,13 +121,13 @@ export const MainMenu = () => {
}, [uiStateActions, clear, clearHistory]);
const handleUndo = useCallback(() => {
undo();
uiStateActions.setIsMainMenuOpen(false);
const success = undo();
if (success) uiStateActions.setIsMainMenuOpen(false);
}, [undo, uiStateActions]);
const handleRedo = useCallback(() => {
redo();
uiStateActions.setIsMainMenuOpen(false);
const success = redo();
if (success) uiStateActions.setIsMainMenuOpen(false);
}, [redo, uiStateActions]);
const onOpenSettings = useCallback(() => {

View File

@@ -6,6 +6,8 @@ import RichTextEditorErrorBoundary from './RichTextEditorErrorBoundary';
interface Props {
value?: string;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
readOnly?: boolean;
height?: number;
styles?: React.CSSProperties;
@@ -42,6 +44,8 @@ const formats = [
export const RichTextEditor = ({
value,
onChange,
onFocus,
onBlur,
readOnly,
height = 120,
styles
@@ -92,6 +96,8 @@ export const RichTextEditor = ({
value={value ?? ''}
readOnly={readOnly}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
formats={formats}
modules={modules}
/>

View File

@@ -60,3 +60,5 @@ export const SceneLayer = memo(({
</Box>
);
});
SceneLayer.displayName = 'SceneLayer';

View File

@@ -124,3 +124,5 @@ export const ConnectorLabel = memo(({ connector: sceneConnector }: Props) => {
</>
);
});
ConnectorLabel.displayName = 'ConnectorLabel';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { useMemo, memo } from 'react';
import { useScene } from 'src/hooks/useScene';
import { ConnectorLabel } from './ConnectorLabel';
@@ -6,21 +6,27 @@ interface Props {
connectors: ReturnType<typeof useScene>['connectors'];
}
export const ConnectorLabels = ({ connectors }: Props) => {
export const ConnectorLabels = memo(({ connectors }: Props) => {
const labeledConnectors = useMemo(
() =>
connectors.filter((connector) => {
return Boolean(
connector.description ||
connector.startLabel ||
connector.endLabel ||
(connector.labels && connector.labels.length > 0)
);
}),
[connectors]
);
return (
<>
{connectors
.filter((connector) => {
return Boolean(
connector.description ||
connector.startLabel ||
connector.endLabel ||
(connector.labels && connector.labels.length > 0)
);
})
.map((connector) => {
return <ConnectorLabel key={connector.id} connector={connector} />;
})}
{labeledConnectors.map((connector) => {
return <ConnectorLabel key={connector.id} connector={connector} />;
})}
</>
);
};
});
ConnectorLabels.displayName = 'ConnectorLabels';

View File

@@ -327,3 +327,5 @@ export const Connector = memo(({ connector: _connector, isSelected }: Props) =>
</Box>
);
});
Connector.displayName = 'Connector';

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { useMemo, memo } from 'react';
import type { useScene } from 'src/hooks/useScene';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Connector } from './Connector';
@@ -7,7 +7,7 @@ interface Props {
connectors: ReturnType<typeof useScene>['connectors'];
}
export const Connectors = ({ connectors }: Props) => {
export const Connectors = memo(({ connectors }: Props) => {
const itemControls = useUiStateStore((state) => {
return state.itemControls;
});
@@ -27,9 +27,11 @@ export const Connectors = ({ connectors }: Props) => {
return null;
}, [mode, itemControls]);
const reversedConnectors = useMemo(() => [...connectors].reverse(), [connectors]);
return (
<>
{[...connectors].reverse().map((connector) => {
{reversedConnectors.map((connector) => {
return (
<Connector
key={connector.id}
@@ -40,4 +42,6 @@ export const Connectors = ({ connectors }: Props) => {
})}
</>
);
};
});
Connectors.displayName = 'Connectors';

View File

@@ -94,3 +94,5 @@ export const Node = memo(({ node, order }: Props) => {
</Box>
);
});
Node.displayName = 'Node';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { useMemo, memo } from 'react';
import { ViewItem } from 'src/types';
import { Node } from './Node/Node';
@@ -6,14 +6,18 @@ interface Props {
nodes: ViewItem[];
}
export const Nodes = ({ nodes }: Props) => {
export const Nodes = memo(({ nodes }: Props) => {
const reversedNodes = useMemo(() => [...nodes].reverse(), [nodes]);
return (
<>
{[...nodes].reverse().map((node) => {
{reversedNodes.map((node) => {
return (
<Node key={node.id} order={-node.tile.x - node.tile.y} node={node} />
);
})}
</>
);
};
});
Nodes.displayName = 'Nodes';

View File

@@ -31,3 +31,5 @@ export const Rectangle = memo(({ from, to, color: colorId, customColor }: Props)
/>
);
});
Rectangle.displayName = 'Rectangle';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { useMemo, memo } from 'react';
import { useScene } from 'src/hooks/useScene';
import { Rectangle } from './Rectangle';
@@ -6,12 +6,16 @@ interface Props {
rectangles: ReturnType<typeof useScene>['rectangles'];
}
export const Rectangles = ({ rectangles }: Props) => {
export const Rectangles = memo(({ rectangles }: Props) => {
const reversedRectangles = useMemo(() => [...rectangles].reverse(), [rectangles]);
return (
<>
{[...rectangles].reverse().map((rectangle) => {
{reversedRectangles.map((rectangle) => {
return <Rectangle key={rectangle.id} {...rectangle} />;
})}
</>
);
};
});
Rectangles.displayName = 'Rectangles';

View File

@@ -50,3 +50,5 @@ export const TextBox = memo(({ textBox }: Props) => {
</Box>
);
});
TextBox.displayName = 'TextBox';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { useMemo, memo } from 'react';
import { useScene } from 'src/hooks/useScene';
import { TextBox } from './TextBox';
@@ -6,12 +6,16 @@ interface Props {
textBoxes: ReturnType<typeof useScene>['textBoxes'];
}
export const TextBoxes = ({ textBoxes }: Props) => {
export const TextBoxes = memo(({ textBoxes }: Props) => {
const reversedTextBoxes = useMemo(() => [...textBoxes].reverse(), [textBoxes]);
return (
<>
{[...textBoxes].reverse().map((textBox) => {
{reversedTextBoxes.map((textBox) => {
return <TextBox key={textBox.id} textBox={textBox} />;
})}
</>
);
};
});
TextBoxes.displayName = 'TextBoxes';

View File

@@ -1,329 +1,461 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { createStore } from 'zustand';
import { useHistory } from '../useHistory';
import type { HistoryStoreState, HistoryEntry } from 'src/stores/historyStore';
import type { Model, Scene } from 'src/types';
// Mock implementations
const mockModelStore = {
canUndo: jest.fn(),
canRedo: jest.fn(),
undo: jest.fn(),
redo: jest.fn(),
saveToHistory: jest.fn(),
clearHistory: jest.fn()
};
// --- Minimal mock data ---
const mockSceneStore = {
canUndo: jest.fn(),
canRedo: jest.fn(),
undo: jest.fn(),
redo: jest.fn(),
saveToHistory: jest.fn(),
clearHistory: jest.fn()
};
const makeModel = (title: string): Model => ({
title,
colors: [],
icons: [],
items: [],
views: []
});
// Mock the store hooks
jest.mock('../../stores/modelStore', () => ({
useModelStore: jest.fn((selector) => {
const state = {
actions: mockModelStore
const makeScene = (extra?: Record<string, unknown>): Scene => ({
connectors: {},
textBoxes: {},
...extra
});
const makeEntry = (title: string): HistoryEntry => ({
model: makeModel(title),
scene: makeScene()
});
// --- Mock stores ---
const createMockModelStore = (initial: Model) => {
return createStore<{
title: string;
colors: Model['colors'];
icons: Model['icons'];
items: Model['items'];
views: Model['views'];
version?: string;
description?: string;
actions: {
get: () => any;
set: (updates: Partial<Model>) => void;
};
return selector ? selector(state) : state;
})
}>((set, get) => ({
...initial,
actions: {
get,
set: (updates: Partial<Model>) => set((state) => ({ ...state, ...updates }))
}
}));
};
const createMockSceneStore = (initial: Scene) => {
return createStore<{
connectors: Scene['connectors'];
textBoxes: Scene['textBoxes'];
actions: {
get: () => any;
set: (updates: Partial<Scene>) => void;
};
}>((set, get) => ({
...initial,
actions: {
get,
set: (updates: Partial<Scene>) => set((state) => ({ ...state, ...updates }))
}
}));
};
const MAX_HISTORY = 50;
const createMockHistoryStore = () => {
return createStore<HistoryStoreState>((set, get) => ({
past: [],
future: [],
gestureInProgress: false,
maxSize: MAX_HISTORY,
actions: {
saveSnapshot: (currentEntry: HistoryEntry) => {
const { gestureInProgress, maxSize } = get();
if (gestureInProgress) return;
set((state) => {
const newPast = [...state.past, currentEntry];
if (newPast.length > maxSize) newPast.shift();
return { past: newPast, future: [] };
});
},
undo: (currentEntry: HistoryEntry) => {
const { past } = get();
if (past.length === 0) return null;
const previous = past[past.length - 1];
const newPast = past.slice(0, -1);
set({ past: newPast, future: [currentEntry, ...get().future] });
return previous;
},
redo: (currentEntry: HistoryEntry) => {
const { future } = get();
if (future.length === 0) return null;
const next = future[0];
const newFuture = future.slice(1);
set((state) => ({ past: [...state.past, currentEntry], future: newFuture }));
return next;
},
clearHistory: () => {
set({ past: [], future: [], gestureInProgress: false });
},
beginGesture: (currentEntry: HistoryEntry) => {
const { gestureInProgress, maxSize } = get();
if (gestureInProgress) return;
set((state) => {
const newPast = [...state.past, currentEntry];
if (newPast.length > maxSize) newPast.shift();
return { past: newPast, future: [], gestureInProgress: true };
});
},
endGesture: () => {
set({ gestureInProgress: false });
},
cancelGesture: () => {
const { past, gestureInProgress } = get();
if (!gestureInProgress || past.length === 0) {
set({ gestureInProgress: false });
return null;
}
const previous = past[past.length - 1];
const newPast = past.slice(0, -1);
set({ past: newPast, gestureInProgress: false });
return previous;
}
}
}));
};
// --- Mock context providers ---
let mockModelStore: ReturnType<typeof createMockModelStore>;
let mockSceneStore: ReturnType<typeof createMockSceneStore>;
let mockHistoryStore: ReturnType<typeof createMockHistoryStore>;
jest.mock('../../stores/modelStore', () => ({
useModelStoreApi: () => mockModelStore
}));
jest.mock('../../stores/sceneStore', () => ({
useSceneStore: jest.fn((selector) => {
const state = {
actions: mockSceneStore
};
return selector ? selector(state) : state;
useSceneStoreApi: () => mockSceneStore
}));
jest.mock('../../stores/historyStore', () => ({
useHistoryStore: (selector: (state: HistoryStoreState) => any) => {
// Need to subscribe to trigger re-renders, but in tests we just call getState
return selector(mockHistoryStore.getState());
},
useHistoryStoreApi: () => mockHistoryStore,
extractModelData: (state: any) => ({
version: state.version,
title: state.title,
description: state.description,
colors: state.colors,
icons: state.icons,
items: state.items,
views: state.views
}),
extractSceneData: (state: any) => ({
connectors: state.connectors,
textBoxes: state.textBoxes
})
}));
describe('useHistory', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset mock implementations
mockModelStore.canUndo.mockReturnValue(false);
mockModelStore.canRedo.mockReturnValue(false);
mockModelStore.undo.mockReturnValue(true);
mockModelStore.redo.mockReturnValue(true);
mockSceneStore.canUndo.mockReturnValue(false);
mockSceneStore.canRedo.mockReturnValue(false);
mockSceneStore.undo.mockReturnValue(true);
mockSceneStore.redo.mockReturnValue(true);
mockModelStore = createMockModelStore(makeModel('initial'));
mockSceneStore = createMockSceneStore(makeScene());
mockHistoryStore = createMockHistoryStore();
});
describe('undo/redo basic functionality', () => {
describe('canUndo / canRedo', () => {
it('should initialize with no undo/redo capability', () => {
const { result } = renderHook(() => useHistory());
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
it('should call saveToHistory on both stores', () => {
it('canUndo should be true after saveSnapshot', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.saveToHistory();
result.current.saveSnapshot();
});
expect(mockModelStore.saveToHistory).toHaveBeenCalled();
expect(mockSceneStore.saveToHistory).toHaveBeenCalled();
});
it('should perform undo when model store has history', () => {
mockModelStore.canUndo.mockReturnValue(true);
const { result } = renderHook(() => useHistory());
expect(result.current.canUndo).toBe(true);
act(() => {
const success = result.current.undo();
expect(success).toBe(true);
});
expect(mockModelStore.undo).toHaveBeenCalled();
});
it('should perform undo when scene store has history', () => {
mockSceneStore.canUndo.mockReturnValue(true);
const { result } = renderHook(() => useHistory());
expect(result.current.canUndo).toBe(true);
act(() => {
const success = result.current.undo();
expect(success).toBe(true);
});
expect(mockSceneStore.undo).toHaveBeenCalled();
});
it('should perform redo when model store has future', () => {
mockModelStore.canRedo.mockReturnValue(true);
const { result } = renderHook(() => useHistory());
expect(result.current.canRedo).toBe(true);
act(() => {
const success = result.current.redo();
expect(success).toBe(true);
});
expect(mockModelStore.redo).toHaveBeenCalled();
});
it('should return false when undo is called with no history', () => {
mockModelStore.undo.mockReturnValue(false);
mockSceneStore.undo.mockReturnValue(false);
const { result } = renderHook(() => useHistory());
act(() => {
const success = result.current.undo();
expect(success).toBe(false);
});
});
it('should return false when redo is called with no future', () => {
mockModelStore.redo.mockReturnValue(false);
mockSceneStore.redo.mockReturnValue(false);
const { result } = renderHook(() => useHistory());
act(() => {
const success = result.current.redo();
expect(success).toBe(false);
});
});
});
describe('transaction functionality', () => {
it('should save history before transaction and not during', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.transaction(() => {
// This should not trigger saveToHistory due to transaction
result.current.saveToHistory();
});
});
// Should save once before transaction starts
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
expect(mockSceneStore.saveToHistory).toHaveBeenCalledTimes(1);
});
it('should track transaction state correctly', () => {
const { result } = renderHook(() => useHistory());
expect(result.current.isInTransaction()).toBe(false);
act(() => {
result.current.transaction(() => {
expect(result.current.isInTransaction()).toBe(true);
});
});
expect(result.current.isInTransaction()).toBe(false);
});
it('should prevent nested transactions', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.transaction(() => {
// First transaction saves history
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
// Nested transaction should not save again
result.current.transaction(() => {
// Still in transaction
expect(result.current.isInTransaction()).toBe(true);
});
// Should still be 1 save
expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1);
});
});
});
it('should handle transaction errors gracefully', () => {
const { result } = renderHook(() => useHistory());
expect(() => {
act(() => {
result.current.transaction(() => {
throw new Error('Test error');
});
});
}).toThrow('Test error');
// Transaction should be cleaned up
expect(result.current.isInTransaction()).toBe(false);
});
});
describe('history management', () => {
it('should clear all history', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.clearHistory();
});
expect(mockModelStore.clearHistory).toHaveBeenCalled();
expect(mockSceneStore.clearHistory).toHaveBeenCalled();
});
it('should check both stores for undo capability', () => {
// Only model has undo
mockModelStore.canUndo.mockReturnValue(true);
mockSceneStore.canUndo.mockReturnValue(false);
const { result: result1 } = renderHook(() => useHistory());
expect(result1.current.canUndo).toBe(true);
// Only scene has undo
mockModelStore.canUndo.mockReturnValue(false);
mockSceneStore.canUndo.mockReturnValue(true);
// Re-render to pick up store changes
const { result: result2 } = renderHook(() => useHistory());
expect(result2.current.canUndo).toBe(true);
// Both have undo
mockModelStore.canUndo.mockReturnValue(true);
mockSceneStore.canUndo.mockReturnValue(true);
const { result: result3 } = renderHook(() => useHistory());
expect(result3.current.canUndo).toBe(true);
// Neither has undo
mockModelStore.canUndo.mockReturnValue(false);
mockSceneStore.canUndo.mockReturnValue(false);
const { result: result4 } = renderHook(() => useHistory());
expect(result4.current.canUndo).toBe(false);
});
it('should check both stores for redo capability', () => {
// Only model has redo
mockModelStore.canRedo.mockReturnValue(true);
mockSceneStore.canRedo.mockReturnValue(false);
const { result: result1 } = renderHook(() => useHistory());
expect(result1.current.canRedo).toBe(true);
// Only scene has redo
mockModelStore.canRedo.mockReturnValue(false);
mockSceneStore.canRedo.mockReturnValue(true);
const { result: result2 } = renderHook(() => useHistory());
expect(result2.current.canRedo).toBe(true);
expect(result2.current.canRedo).toBe(false);
});
});
describe('edge cases', () => {
it('should handle missing store actions gracefully', () => {
// Mock stores returning undefined actions
const useModelStore = require('../../stores/modelStore').useModelStore;
const useSceneStore = require('../../stores/sceneStore').useSceneStore;
useModelStore.mockImplementation((selector) => {
const state = { actions: undefined };
return selector ? selector(state) : state;
});
useSceneStore.mockImplementation((selector) => {
const state = { actions: undefined };
return selector ? selector(state) : state;
});
describe('saveSnapshot', () => {
it('should capture model + scene and delegate to history store', () => {
const { result } = renderHook(() => useHistory());
// Should not throw and return safe defaults
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
act(() => {
result.current.saveSnapshot();
});
const state = mockHistoryStore.getState();
expect(state.past).toHaveLength(1);
expect(state.past[0].model.title).toBe('initial');
expect(state.future).toHaveLength(0);
});
it('should clear future on new snapshot', () => {
// Seed some past and future
const entry1 = makeEntry('entry1');
const entry2 = makeEntry('entry2');
mockHistoryStore.setState({
past: [entry1],
future: [entry2]
});
const { result } = renderHook(() => useHistory());
act(() => {
result.current.saveSnapshot();
});
const state = mockHistoryStore.getState();
expect(state.future).toHaveLength(0);
expect(state.past).toHaveLength(2);
});
});
describe('undo', () => {
it('should return previous entry and apply to both stores atomically', () => {
const previousEntry = makeEntry('previous');
mockHistoryStore.setState({ past: [previousEntry] });
const { result } = renderHook(() => useHistory());
let success: boolean = false;
act(() => {
success = result.current.undo();
});
expect(success).toBe(true);
// Model store should now have the previous entry's data
expect(mockModelStore.getState().title).toBe('previous');
});
it('should return false when no history available', () => {
const { result } = renderHook(() => useHistory());
let success: boolean = true;
act(() => {
success = result.current.undo();
});
expect(success).toBe(false);
});
it('should push current state to future on undo', () => {
const previousEntry = makeEntry('previous');
mockHistoryStore.setState({ past: [previousEntry] });
const { result } = renderHook(() => useHistory());
act(() => {
result.current.undo();
});
const state = mockHistoryStore.getState();
expect(state.past).toHaveLength(0);
expect(state.future).toHaveLength(1);
expect(state.future[0].model.title).toBe('initial');
});
});
describe('redo', () => {
it('should return next entry and apply', () => {
const futureEntry = makeEntry('future');
mockHistoryStore.setState({ future: [futureEntry] });
const { result } = renderHook(() => useHistory());
let success: boolean = false;
act(() => {
success = result.current.redo();
});
expect(success).toBe(true);
expect(mockModelStore.getState().title).toBe('future');
});
it('should return false when no future available', () => {
const { result } = renderHook(() => useHistory());
let success: boolean = true;
act(() => {
success = result.current.redo();
});
expect(success).toBe(false);
});
it('should push current state to past on redo (not the redo entry)', () => {
const futureEntry = makeEntry('future');
mockHistoryStore.setState({ future: [futureEntry] });
const { result } = renderHook(() => useHistory());
act(() => {
result.current.redo();
});
const state = mockHistoryStore.getState();
// past should contain what was current before redo (title='initial'), not 'future'
expect(state.past).toHaveLength(1);
expect(state.past[0].model.title).toBe('initial');
});
it('undo+redo+undo cycle should not cause data loss', () => {
const { result } = renderHook(() => useHistory());
// Save snapshot (captures 'initial'), then change model to 'changed'
act(() => {
result.current.saveSnapshot();
});
mockModelStore.getState().actions.set({ title: 'changed' });
// Undo: should restore 'initial'
act(() => {
result.current.undo();
});
expect(mockModelStore.getState().title).toBe('initial');
// Redo: should restore 'changed'
act(() => {
result.current.redo();
});
expect(mockModelStore.getState().title).toBe('changed');
// Undo again: should restore 'initial' (not get stuck on 'changed')
act(() => {
result.current.undo();
});
expect(mockModelStore.getState().title).toBe('initial');
});
});
describe('gesture lifecycle', () => {
it('beginGesture should save snapshot and set gestureInProgress', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.beginGesture();
});
const state = mockHistoryStore.getState();
expect(state.gestureInProgress).toBe(true);
expect(state.past).toHaveLength(1);
expect(state.past[0].model.title).toBe('initial');
});
it('endGesture should clear gestureInProgress', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.beginGesture();
});
expect(mockHistoryStore.getState().gestureInProgress).toBe(true);
act(() => {
result.current.endGesture();
});
expect(mockHistoryStore.getState().gestureInProgress).toBe(false);
});
it('saveSnapshot should be blocked during gesture', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.beginGesture();
});
// past has 1 entry from beginGesture
expect(mockHistoryStore.getState().past).toHaveLength(1);
act(() => {
result.current.saveSnapshot();
result.current.saveSnapshot();
result.current.saveSnapshot();
});
// Should still be 1 - saves blocked during gesture
expect(mockHistoryStore.getState().past).toHaveLength(1);
});
it('cancelGesture should restore pre-gesture state', () => {
// Set up initial state
mockModelStore.getState().actions.set({ title: 'before-gesture' });
const { result } = renderHook(() => useHistory());
act(() => {
result.current.beginGesture();
});
// Simulate changes during gesture
mockModelStore.getState().actions.set({ title: 'during-gesture' });
expect(mockModelStore.getState().title).toBe('during-gesture');
act(() => {
result.current.cancelGesture();
});
// Should be restored to pre-gesture state
expect(mockModelStore.getState().title).toBe('before-gesture');
expect(mockHistoryStore.getState().gestureInProgress).toBe(false);
});
it('isGestureInProgress should reflect state', () => {
const { result } = renderHook(() => useHistory());
expect(result.current.isGestureInProgress()).toBe(false);
act(() => {
result.current.beginGesture();
});
expect(result.current.isGestureInProgress()).toBe(true);
act(() => {
result.current.endGesture();
});
expect(result.current.isGestureInProgress()).toBe(false);
});
});
describe('clearHistory', () => {
it('should reset all history state', () => {
mockHistoryStore.setState({
past: [makeEntry('a'), makeEntry('b')],
future: [makeEntry('c')],
gestureInProgress: true
});
const { result } = renderHook(() => useHistory());
act(() => {
expect(result.current.undo()).toBe(false);
expect(result.current.redo()).toBe(false);
// These should not throw
result.current.saveToHistory();
result.current.clearHistory();
result.current.transaction(() => {});
});
// Restore mocks for other tests
useModelStore.mockImplementation((selector) => {
const state = { actions: mockModelStore };
return selector ? selector(state) : state;
});
useSceneStore.mockImplementation((selector) => {
const state = { actions: mockSceneStore };
return selector ? selector(state) : state;
});
});
it('should not save history during active transaction', () => {
const { result } = renderHook(() => useHistory());
act(() => {
result.current.transaction(() => {
// Clear previous calls from transaction setup
mockModelStore.saveToHistory.mockClear();
mockSceneStore.saveToHistory.mockClear();
// Try to save during transaction
result.current.saveToHistory();
// Should not have saved
expect(mockModelStore.saveToHistory).not.toHaveBeenCalled();
expect(mockSceneStore.saveToHistory).not.toHaveBeenCalled();
});
});
const state = mockHistoryStore.getState();
expect(state.past).toHaveLength(0);
expect(state.future).toHaveLength(0);
expect(state.gestureInProgress).toBe(false);
});
});
});
});

View File

@@ -1,129 +1,94 @@
import { useCallback, useRef } from 'react';
import { useModelStore } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useCallback } from 'react';
import { useModelStoreApi } from 'src/stores/modelStore';
import { useSceneStoreApi } from 'src/stores/sceneStore';
import {
useHistoryStore,
useHistoryStoreApi,
extractModelData,
extractSceneData
} from 'src/stores/historyStore';
import type { HistoryEntry } from 'src/stores/historyStore';
export const useHistory = () => {
// Track if we're in a transaction to prevent nested history saves
const transactionInProgress = useRef(false);
const modelStoreApi = useModelStoreApi();
const sceneStoreApi = useSceneStoreApi();
const historyStoreApi = useHistoryStoreApi();
// Get store actions
const modelActions = useModelStore((state) => {
return state?.actions;
});
const sceneActions = useSceneStore((state) => {
return state?.actions;
});
const canUndo = useHistoryStore((s) => s.past.length > 0);
const canRedo = useHistoryStore((s) => s.future.length > 0);
// Get history state
const modelCanUndo = useModelStore((state) => {
return state?.actions?.canUndo?.() ?? false;
});
const sceneCanUndo = useSceneStore((state) => {
return state?.actions?.canUndo?.() ?? false;
});
const modelCanRedo = useModelStore((state) => {
return state?.actions?.canRedo?.() ?? false;
});
const sceneCanRedo = useSceneStore((state) => {
return state?.actions?.canRedo?.() ?? false;
});
const getCurrentEntry = useCallback((): HistoryEntry => {
const model = modelStoreApi.getState();
const scene = sceneStoreApi.getState();
return {
model: extractModelData(model),
scene: extractSceneData(scene)
};
}, [modelStoreApi, sceneStoreApi]);
// Derived values
const canUndo = modelCanUndo || sceneCanUndo;
const canRedo = modelCanRedo || sceneCanRedo;
// Transaction wrapper - groups multiple operations into single history entry
const transaction = useCallback(
(operations: () => void) => {
if (!modelActions || !sceneActions) return;
// Prevent nested transactions
if (transactionInProgress.current) {
operations();
return;
}
// Save current state before transaction
modelActions.saveToHistory();
sceneActions.saveToHistory();
// Mark transaction as in progress
transactionInProgress.current = true;
try {
// Execute all operations without saving intermediate history
operations();
} finally {
// Always reset transaction state
transactionInProgress.current = false;
}
// Note: We don't save after transaction - the final state is already current
const applyEntry = useCallback(
(entry: HistoryEntry) => {
modelStoreApi.getState().actions.set(entry.model);
sceneStoreApi.getState().actions.set(entry.scene);
},
[modelActions, sceneActions]
[modelStoreApi, sceneStoreApi]
);
const saveSnapshot = useCallback(() => {
historyStoreApi.getState().actions.saveSnapshot(getCurrentEntry());
}, [historyStoreApi, getCurrentEntry]);
const undo = useCallback(() => {
if (!modelActions || !sceneActions) return false;
let undoPerformed = false;
// Try to undo model first, then scene
if (modelActions.canUndo()) {
undoPerformed = modelActions.undo() || undoPerformed;
const entry = historyStoreApi.getState().actions.undo(getCurrentEntry());
if (entry) {
applyEntry(entry);
return true;
}
if (sceneActions.canUndo()) {
undoPerformed = sceneActions.undo() || undoPerformed;
}
return undoPerformed;
}, [modelActions, sceneActions]);
return false;
}, [historyStoreApi, getCurrentEntry, applyEntry]);
const redo = useCallback(() => {
if (!modelActions || !sceneActions) return false;
let redoPerformed = false;
// Try to redo model first, then scene
if (modelActions.canRedo()) {
redoPerformed = modelActions.redo() || redoPerformed;
const entry = historyStoreApi.getState().actions.redo(getCurrentEntry());
if (entry) {
applyEntry(entry);
return true;
}
if (sceneActions.canRedo()) {
redoPerformed = sceneActions.redo() || redoPerformed;
}
return redoPerformed;
}, [modelActions, sceneActions]);
const saveToHistory = useCallback(() => {
// Don't save during transactions
if (transactionInProgress.current) {
return;
}
if (!modelActions || !sceneActions) return;
modelActions.saveToHistory();
sceneActions.saveToHistory();
}, [modelActions, sceneActions]);
return false;
}, [historyStoreApi, getCurrentEntry, applyEntry]);
const clearHistory = useCallback(() => {
if (!modelActions || !sceneActions) return;
historyStoreApi.getState().actions.clearHistory();
}, [historyStoreApi]);
modelActions.clearHistory();
sceneActions.clearHistory();
}, [modelActions, sceneActions]);
const beginGesture = useCallback(() => {
historyStoreApi.getState().actions.beginGesture(getCurrentEntry());
}, [historyStoreApi, getCurrentEntry]);
const endGesture = useCallback(() => {
historyStoreApi.getState().actions.endGesture();
}, [historyStoreApi]);
const cancelGesture = useCallback(() => {
const entry = historyStoreApi.getState().actions.cancelGesture();
if (entry) {
applyEntry(entry);
}
}, [historyStoreApi, applyEntry]);
const isGestureInProgress = useCallback(() => {
return historyStoreApi.getState().gestureInProgress;
}, [historyStoreApi]);
return {
undo,
redo,
canUndo,
canRedo,
saveToHistory,
saveSnapshot,
clearHistory,
transaction,
isInTransaction: () => {
return transactionInProgress.current;
}
beginGesture,
endGesture,
cancelGesture,
isGestureInProgress
};
};

View File

@@ -1,4 +1,5 @@
import { useCallback, useState, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { InitialData, IconCollectionState } from 'src/types';
import { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
import {
@@ -9,7 +10,7 @@ import {
getItemByIdOrThrow
} from 'src/utils';
import * as reducers from 'src/stores/reducers';
import { useModelStore } from 'src/stores/modelStore';
import { useModelStore, useModelStoreApi } from 'src/stores/modelStore';
import { useView } from 'src/hooks/useView';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { modelSchema } from 'src/schemas/model';
@@ -17,9 +18,12 @@ import { modelSchema } from 'src/schemas/model';
export const useInitialDataManager = () => {
const [isReady, setIsReady] = useState(false);
const prevInitialData = useRef<InitialData | undefined>(undefined);
const model = useModelStore((state) => {
return state;
});
const modelActions = useModelStore((state) => state.actions);
const { icons, colors } = useModelStore(
(state) => ({ icons: state.icons, colors: state.colors }),
shallow
);
const modelStoreApi = useModelStoreApi();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
@@ -105,7 +109,7 @@ export const useInitialDataManager = () => {
}
prevInitialData.current = initialData;
model.actions.set(initialData);
modelActions.set(initialData);
const view = getItemByIdOrThrow(
initialData.views,
@@ -143,13 +147,13 @@ export const useInitialDataManager = () => {
setIsReady(true);
},
[changeView, model.actions, rendererEl, uiStateActions, editorMode]
[changeView, modelActions, rendererEl, uiStateActions, editorMode]
);
const clear = useCallback(() => {
load({ ...INITIAL_DATA, icons: model.icons, colors: model.colors });
load({ ...INITIAL_DATA, icons, colors });
uiStateActions.resetUiState();
}, [load, model.icons, model.colors, uiStateActions]);
}, [load, icons, colors, uiStateActions]);
return {
load,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import { shallow } from 'zustand/shallow';
import {
ModelItem,
@@ -18,6 +18,7 @@ import {
RECTANGLE_DEFAULTS,
TEXTBOX_DEFAULTS
} from 'src/config';
import { useHistory } from 'src/hooks/useHistory';
export const useScene = () => {
const { views, colors, icons, items, version, title, description } =
@@ -42,10 +43,10 @@ export const useScene = () => {
shallow
);
const currentViewId = useUiStateStore((state) => state.view);
const transactionInProgress = useRef(false);
const modelStoreApi = useModelStoreApi();
const sceneStoreApi = useSceneStoreApi();
const { saveSnapshot, beginGesture, endGesture } = useHistory();
const currentView = useMemo(() => {
if (!views || !currentViewId) {
@@ -138,59 +139,45 @@ export const useScene = () => {
const setState = useCallback(
(newState: State) => {
modelStoreApi.getState().actions.set(newState.model, true);
sceneStoreApi.getState().actions.set(newState.scene, true);
modelStoreApi.getState().actions.set(newState.model);
sceneStoreApi.getState().actions.set(newState.scene);
},
[modelStoreApi, sceneStoreApi]
);
const saveToHistoryBeforeChange = useCallback(() => {
if (transactionInProgress.current) {
return;
}
modelStoreApi.getState().actions.saveToHistory();
sceneStoreApi.getState().actions.saveToHistory();
}, [modelStoreApi, sceneStoreApi]);
const createModelItem = useCallback(
(newModelItem: ModelItem) => {
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
saveSnapshot();
const newState = reducers.createModelItem(newModelItem, getState());
setState(newState);
return newState;
},
[getState, setState, saveToHistoryBeforeChange]
[getState, setState, saveSnapshot]
);
const updateModelItem = useCallback(
(id: string, updates: Partial<ModelItem>) => {
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.updateModelItem(id, updates, getState());
setState(newState);
},
[getState, setState, saveToHistoryBeforeChange]
[getState, setState, saveSnapshot]
);
const deleteModelItem = useCallback(
(id: string) => {
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.deleteModelItem(id, getState());
setState(newState);
},
[getState, setState, saveToHistoryBeforeChange]
[getState, setState, saveSnapshot]
);
const createViewItem = useCallback(
(newViewItem: ViewItem, currentState?: State) => {
if (!currentViewId) return;
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
saveSnapshot();
const stateToUse = currentState || getState();
@@ -202,16 +189,14 @@ export const useScene = () => {
setState(newState);
return newState;
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
if (!currentViewId) return getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
saveSnapshot();
const stateToUse = currentState || getState();
const newState = reducers.view({
@@ -222,14 +207,14 @@ export const useScene = () => {
setState(newState);
return newState;
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const deleteViewItem = useCallback(
(id: string) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'DELETE_VIEWITEM',
payload: id,
@@ -237,14 +222,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const createConnector = useCallback(
(newConnector: Connector) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'CREATE_CONNECTOR',
payload: newConnector,
@@ -252,14 +237,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const updateConnector = useCallback(
(id: string, updates: Partial<Connector>) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'UPDATE_CONNECTOR',
payload: { id, ...updates },
@@ -267,14 +252,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const deleteConnector = useCallback(
(id: string) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'DELETE_CONNECTOR',
payload: id,
@@ -282,14 +267,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const createTextBox = useCallback(
(newTextBox: TextBox) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'CREATE_TEXTBOX',
payload: newTextBox,
@@ -297,16 +282,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const updateTextBox = useCallback(
(id: string, updates: Partial<TextBox>, currentState?: State) => {
if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
saveSnapshot();
const stateToUse = currentState || getState();
const newState = reducers.view({
@@ -317,14 +300,14 @@ export const useScene = () => {
setState(newState);
return newState;
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const deleteTextBox = useCallback(
(id: string) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'DELETE_TEXTBOX',
payload: id,
@@ -332,14 +315,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const createRectangle = useCallback(
(newRectangle: Rectangle) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'CREATE_RECTANGLE',
payload: newRectangle,
@@ -347,16 +330,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const updateRectangle = useCallback(
(id: string, updates: Partial<Rectangle>, currentState?: State) => {
if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
saveSnapshot();
const stateToUse = currentState || getState();
const newState = reducers.view({
@@ -367,14 +348,14 @@ export const useScene = () => {
setState(newState);
return newState;
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const deleteRectangle = useCallback(
(id: string) => {
if (!currentViewId) return;
saveToHistoryBeforeChange();
saveSnapshot();
const newState = reducers.view({
action: 'DELETE_RECTANGLE',
payload: id,
@@ -382,32 +363,24 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId, saveToHistoryBeforeChange]
[getState, setState, currentViewId, saveSnapshot]
);
const transaction = useCallback(
(operations: () => void) => {
if (transactionInProgress.current) {
operations();
return;
}
saveToHistoryBeforeChange();
transactionInProgress.current = true;
beginGesture();
try {
operations();
} finally {
transactionInProgress.current = false;
endGesture();
}
},
[saveToHistoryBeforeChange]
[beginGesture, endGesture]
);
const placeIcon = useCallback(
(params: { modelItem: ModelItem; viewItem: ViewItem }) => {
saveToHistoryBeforeChange();
transactionInProgress.current = true;
beginGesture();
try {
const stateAfterModelItem = createModelItem(params.modelItem);
@@ -416,10 +389,10 @@ export const useScene = () => {
createViewItem(params.viewItem, stateAfterModelItem);
}
} finally {
transactionInProgress.current = false;
endGesture();
}
},
[createModelItem, createViewItem, saveToHistoryBeforeChange]
[createModelItem, createViewItem, beginGesture, endGesture]
);
return {

View File

@@ -4,6 +4,7 @@ import { useSceneStore } from 'src/stores/sceneStore';
import * as reducers from 'src/stores/reducers';
import { Model } from 'src/types';
import { INITIAL_SCENE_STATE } from 'src/config';
import { useHistory } from 'src/hooks/useHistory';
export const useView = () => {
const uiStateActions = useUiStateStore((state) => {
@@ -14,6 +15,8 @@ export const useView = () => {
return state.actions;
});
const { clearHistory } = useHistory();
const changeView = useCallback(
(viewId: string, model: Model) => {
const newState = reducers.view({
@@ -24,8 +27,9 @@ export const useView = () => {
sceneActions.set(newState.scene);
uiStateActions.setView(viewId);
clearHistory();
},
[uiStateActions, sceneActions]
[uiStateActions, sceneActions, clearHistory]
);
return {

View File

@@ -61,7 +61,7 @@ export const Connector: ModeActions = {
}
}
},
mousedown: ({ uiState, scene, isRendererInteraction }) => {
mousedown: ({ uiState, scene, isRendererInteraction, history }) => {
if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return;
const itemAtTile = getItemAtTile({
@@ -72,8 +72,9 @@ export const Connector: ModeActions = {
if (uiState.connectorInteractionMode === 'click') {
// Click mode: handle first and second clicks
if (!uiState.mode.startAnchor) {
// First click: store the start position
const startAnchor = itemAtTile?.type === 'ITEM'
// First click: begin gesture and store the start position
history.beginGesture();
const startAnchor = itemAtTile?.type === 'ITEM'
? { itemId: itemAtTile.id }
: { tile: uiState.mouse.position.tile };
@@ -144,7 +145,8 @@ export const Connector: ModeActions = {
// Don't delete connectors to empty space - they're valid
// Only validate minimum path length will be handled by the update
// Reset for next connection
// End gesture and reset for next connection
history.endGesture();
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
@@ -155,7 +157,8 @@ export const Connector: ModeActions = {
}
}
} else {
// Drag mode: original behavior
// Drag mode: begin gesture before creating
history.beginGesture();
const newConnector: ConnectorI = {
id: generateId(),
color: scene.colors[0].id,
@@ -183,7 +186,7 @@ export const Connector: ModeActions = {
});
}
},
mouseup: ({ uiState, scene }) => {
mouseup: ({ uiState, scene, history }) => {
if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return;
// Only handle mouseup for drag mode
@@ -191,6 +194,7 @@ export const Connector: ModeActions = {
// Don't delete connectors to empty space - they're valid
// Validation is handled in the reducer layer
history.endGesture();
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,

View File

@@ -50,40 +50,36 @@ const dragItems = (
const hasUpdates = newTiles || textBoxRefs.length > 0 || rectangleRefs.length > 0;
if (hasUpdates) {
// Wrap ALL updates in a single transaction with state chaining
// This ensures each update builds on the previous one's state
scene.transaction(() => {
let currentState: State | undefined;
let currentState: State | undefined;
// 1. Update nodes
if (newTiles) {
itemRefs.forEach((item, index) => {
currentState = scene.updateViewItem(item.id, {
tile: newTiles[index]
}, currentState);
});
}
// 2. Update textboxes (chained from node state)
textBoxRefs.forEach((item) => {
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
currentState = scene.updateTextBox(item.id, {
tile: CoordsUtils.add(textBox.tile, delta)
// 1. Update nodes
if (newTiles) {
itemRefs.forEach((item, index) => {
currentState = scene.updateViewItem(item.id, {
tile: newTiles[index]
}, currentState);
});
}
// 3. Update rectangles (chained from textbox state)
rectangleRefs.forEach((item) => {
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
currentState = scene.updateRectangle(item.id, {
from: CoordsUtils.add(rectangle.from, delta),
to: CoordsUtils.add(rectangle.to, delta)
}, currentState);
});
// 2. Update textboxes (chained from node state)
textBoxRefs.forEach((item) => {
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
currentState = scene.updateTextBox(item.id, {
tile: CoordsUtils.add(textBox.tile, delta)
}, currentState);
});
// 3. Update rectangles (chained from textbox state)
rectangleRefs.forEach((item) => {
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
currentState = scene.updateRectangle(item.id, {
from: CoordsUtils.add(rectangle.from, delta),
to: CoordsUtils.add(rectangle.to, delta)
}, currentState);
});
}
// Handle connector anchors separately (they have different update logic)
// Handle connector anchors (gesture already in progress from entry)
anchorRefs.forEach((item) => {
const connector = getAnchorParent(item.id, scene.connectors);
@@ -125,13 +121,15 @@ const dragItems = (
};
export const DragItems: ModeActions = {
entry: ({ uiState, rendererRef }) => {
entry: ({ uiState, rendererRef, history }) => {
if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return;
history.beginGesture();
const renderer = rendererRef;
renderer.style.userSelect = 'none';
},
exit: ({ rendererRef }) => {
exit: ({ rendererRef, history }) => {
history.endGesture();
const renderer = rendererRef;
renderer.style.userSelect = 'auto';
},

View File

@@ -1,25 +1,25 @@
import { produce } from 'immer';
import { ModeActions, ItemReference, Coords } from 'src/types';
import { ModeActions, ItemReference, Coords, SceneItemsSnapshot } from 'src/types';
import { screenToIso, isPointInPolygon } from 'src/utils';
// Helper to find all items whose centers are within the freehand polygon
const getItemsInFreehandBounds = (
pathTiles: Coords[],
scene: any
scene: SceneItemsSnapshot
): ItemReference[] => {
const items: ItemReference[] = [];
if (pathTiles.length < 3) return items;
// Check all nodes/items
scene.items.forEach((item: any) => {
scene.items.forEach((item) => {
if (isPointInPolygon(item.tile, pathTiles)) {
items.push({ type: 'ITEM', id: item.id });
}
});
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
scene.rectangles.forEach((rectangle: any) => {
scene.rectangles.forEach((rectangle) => {
const corners = [
rectangle.from,
{ x: rectangle.to.x, y: rectangle.from.y },
@@ -36,7 +36,7 @@ const getItemsInFreehandBounds = (
});
// Check all text boxes
scene.textBoxes.forEach((textBox: any) => {
scene.textBoxes.forEach((textBox) => {
if (isPointInPolygon(textBox.tile, pathTiles)) {
items.push({ type: 'TEXTBOX', id: textBox.id });
}

View File

@@ -1,24 +1,24 @@
import { produce } from 'immer';
import { ModeActions, ItemReference } from 'src/types';
import { CoordsUtils, isWithinBounds, hasMovedTile } from 'src/utils';
import { ModeActions, ItemReference, Coords, SceneItemsSnapshot } from 'src/types';
import { isWithinBounds, hasMovedTile } from 'src/utils';
// Helper to find all items within the lasso bounds
const getItemsInBounds = (
startTile: { x: number; y: number },
endTile: { x: number; y: number },
scene: any
startTile: Coords,
endTile: Coords,
scene: SceneItemsSnapshot
): ItemReference[] => {
const items: ItemReference[] = [];
// Check all nodes/items
scene.items.forEach((item: any) => {
scene.items.forEach((item) => {
if (isWithinBounds(item.tile, [startTile, endTile])) {
items.push({ type: 'ITEM', id: item.id });
}
});
// Check all rectangles - they must be FULLY enclosed (all 4 corners inside)
scene.rectangles.forEach((rectangle: any) => {
scene.rectangles.forEach((rectangle) => {
const corners = [
rectangle.from,
{ x: rectangle.to.x, y: rectangle.from.y },
@@ -37,7 +37,7 @@ const getItemsInBounds = (
});
// Check all text boxes
scene.textBoxes.forEach((textBox: any) => {
scene.textBoxes.forEach((textBox) => {
if (isWithinBounds(textBox.tile, [startTile, endTile])) {
items.push({ type: 'TEXTBOX', id: textBox.id });
}

View File

@@ -22,10 +22,11 @@ export const DrawRectangle: ModeActions = {
to: uiState.mouse.position.tile
});
},
mousedown: ({ uiState, scene, isRendererInteraction }) => {
mousedown: ({ uiState, scene, isRendererInteraction, history }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction)
return;
history.beginGesture();
const newRectangleId = generateId();
scene.createRectangle({
@@ -41,9 +42,10 @@ export const DrawRectangle: ModeActions = {
uiState.actions.setMode(newMode);
},
mouseup: ({ uiState }) => {
mouseup: ({ uiState, history }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return;
history.endGesture();
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,

View File

@@ -7,7 +7,9 @@ import {
import { ModeActions } from 'src/types';
export const TransformRectangle: ModeActions = {
entry: () => {},
entry: ({ history }) => {
history.beginGesture();
},
exit: () => {},
mousemove: ({ uiState, scene }) => {
if (
@@ -63,9 +65,10 @@ export const TransformRectangle: ModeActions = {
mousedown: () => {
// MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`)
},
mouseup: ({ uiState }) => {
mouseup: ({ uiState, history }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
history.endGesture();
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: null,

View File

@@ -15,12 +15,13 @@ export const TextBox: ModeActions = {
tile: uiState.mouse.position.tile
});
},
mouseup: ({ uiState, scene, isRendererInteraction }) => {
mouseup: ({ uiState, scene, isRendererInteraction, history }) => {
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
if (!isRendererInteraction) {
scene.deleteTextBox(uiState.mode.id);
history.cancelGesture();
} else {
history.endGesture();
uiState.actions.setItemControls({
type: 'TEXTBOX',
id: uiState.mode.id

View File

@@ -106,7 +106,7 @@ export const useInteractionManager = () => {
const modelStoreApi = useModelStoreApi();
const scene = useScene();
const { size: rendererSize } = useResizeObserver(rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory();
const { undo, redo, canUndo, canRedo, beginGesture, endGesture, cancelGesture, isGestureInProgress } = useHistory();
const { createTextBox } = scene;
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
@@ -131,7 +131,7 @@ export const useInteractionManager = () => {
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
if (isConnectionInProgress && connectorMode.id) {
scene.deleteConnector(connectorMode.id);
cancelGesture();
uiState.actions.setMode({
type: 'CONNECTOR',
@@ -158,9 +158,13 @@ export const useInteractionManager = () => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Block undo/redo during active interactions or gestures
const blockingModes = ['CONNECTOR', 'RECTANGLE.DRAW', 'RECTANGLE.TRANSFORM', 'TEXTBOX', 'DRAG_ITEMS'];
const isUndoBlocked = blockingModes.includes(uiState.mode.type) || isGestureInProgress();
if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) {
if (canUndo && !isUndoBlocked) {
undo();
}
}
@@ -171,7 +175,7 @@ export const useInteractionManager = () => {
(e.key.toLowerCase() === 'z' && e.shiftKey))
) {
e.preventDefault();
if (canRedo) {
if (canRedo && !isUndoBlocked) {
redo();
}
}
@@ -230,6 +234,7 @@ export const useInteractionManager = () => {
});
} else if (hotkeyMapping.text && key === hotkeyMapping.text) {
e.preventDefault();
beginGesture();
const textBoxId = generateId();
createTextBox({
...TEXTBOX_DEFAULTS,
@@ -265,7 +270,7 @@ export const useInteractionManager = () => {
return () => {
return window.removeEventListener('keydown', handleKeyDown);
};
}, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene]);
}, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene, beginGesture, cancelGesture, isGestureInProgress]);
const processMouseUpdate = useCallback(
(nextMouse: Mouse, e: SlimMouseEvent) => {
@@ -284,6 +289,7 @@ export const useInteractionManager = () => {
const baseState: State = {
model,
scene,
history: { beginGesture, endGesture, cancelGesture, isGestureInProgress },
uiState,
rendererRef: rendererRef.current,
rendererSize,
@@ -307,7 +313,7 @@ export const useInteractionManager = () => {
modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type;
},
[uiStateApi, modelStoreApi, scene, rendererSize]
[uiStateApi, modelStoreApi, scene, rendererSize, beginGesture, endGesture, cancelGesture, isGestureInProgress]
);
const onMouseEvent = useCallback(

View File

@@ -0,0 +1,361 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import {
HistoryProvider,
useHistoryStore,
useHistoryStoreApi
} from '../historyStore';
import type { HistoryEntry } from '../historyStore';
const makeEntry = (id: number): HistoryEntry => ({
model: {
version: `v${id}`,
title: `Model ${id}`,
colors: [],
icons: [],
items: [],
views: []
},
scene: {
connectors: {},
textBoxes: {}
}
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<HistoryProvider>{children}</HistoryProvider>
);
describe('historyStore', () => {
describe('saveSnapshot', () => {
it('adds entry to past and clears future', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past),
future: useHistoryStore((s) => s.future)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
});
expect(result.current.past).toHaveLength(1);
expect(result.current.past[0].model.title).toBe('Model 1');
expect(result.current.future).toHaveLength(0);
});
it('enforces max size', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past)
}),
{ wrapper }
);
act(() => {
for (let i = 0; i < 55; i++) {
result.current.api.getState().actions.saveSnapshot(makeEntry(i));
}
});
expect(result.current.past).toHaveLength(50);
// Oldest entries should have been shifted off
expect(result.current.past[0].model.title).toBe('Model 5');
});
it('skips save during gesture', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(0));
});
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
});
// Only the beginGesture entry should be there
expect(result.current.past).toHaveLength(1);
expect(result.current.past[0].model.title).toBe('Model 0');
});
});
describe('undo', () => {
it('returns previous entry and moves current to future', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past),
future: useHistoryStore((s) => s.future)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
});
let undone: HistoryEntry | null = null;
act(() => {
undone = result.current.api.getState().actions.undo(makeEntry(2));
});
expect(undone).not.toBeNull();
expect(undone!.model.title).toBe('Model 1');
expect(result.current.past).toHaveLength(0);
expect(result.current.future).toHaveLength(1);
expect(result.current.future[0].model.title).toBe('Model 2');
});
it('returns null when past is empty', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi()
}),
{ wrapper }
);
let undone: HistoryEntry | null = null;
act(() => {
undone = result.current.api.getState().actions.undo(makeEntry(1));
});
expect(undone).toBeNull();
});
});
describe('redo', () => {
it('returns next entry and pushes current to past', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past),
future: useHistoryStore((s) => s.future)
}),
{ wrapper }
);
// Build up: save then undo
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
});
act(() => {
result.current.api.getState().actions.undo(makeEntry(2));
});
// Now redo, passing current state (makeEntry(1) since undo restored it)
let redone: HistoryEntry | null = null;
act(() => {
redone = result.current.api.getState().actions.redo(makeEntry(1));
});
expect(redone).not.toBeNull();
expect(redone!.model.title).toBe('Model 2');
expect(result.current.future).toHaveLength(0);
// past should contain the current state we passed, not the redo'd entry
expect(result.current.past).toHaveLength(1);
expect(result.current.past[0].model.title).toBe('Model 1');
});
it('returns null when future is empty', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi()
}),
{ wrapper }
);
let redone: HistoryEntry | null = null;
act(() => {
redone = result.current.api.getState().actions.redo(makeEntry(99));
});
expect(redone).toBeNull();
});
it('undo+redo+undo cycle preserves correct entries', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past),
future: useHistoryStore((s) => s.future)
}),
{ wrapper }
);
// State A is "current", save snapshot and change to B
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1)); // past=[A]
});
// Undo: current is B(=2), want to go back to A(=1)
let undone: HistoryEntry | null = null;
act(() => {
undone = result.current.api.getState().actions.undo(makeEntry(2));
});
expect(undone!.model.title).toBe('Model 1');
// past=[], future=[B(2)]
// Redo: current is A(=1), want to go forward to B(=2)
let redone: HistoryEntry | null = null;
act(() => {
redone = result.current.api.getState().actions.redo(makeEntry(1));
});
expect(redone!.model.title).toBe('Model 2');
// past=[A(1)], future=[]
// Undo again: current is B(=2), should go back to A(=1)
act(() => {
undone = result.current.api.getState().actions.undo(makeEntry(2));
});
expect(undone!.model.title).toBe('Model 1');
// past=[], future=[B(2)]
expect(result.current.past).toHaveLength(0);
expect(result.current.future).toHaveLength(1);
expect(result.current.future[0].model.title).toBe('Model 2');
});
});
describe('gesture lifecycle', () => {
it('beginGesture saves state and sets flag', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
gestureInProgress: useHistoryStore((s) => s.gestureInProgress),
past: useHistoryStore((s) => s.past)
}),
{ wrapper }
);
expect(result.current.gestureInProgress).toBe(false);
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(1));
});
expect(result.current.gestureInProgress).toBe(true);
expect(result.current.past).toHaveLength(1);
});
it('endGesture clears flag', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
gestureInProgress: useHistoryStore((s) => s.gestureInProgress)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(1));
});
act(() => {
result.current.api.getState().actions.endGesture();
});
expect(result.current.gestureInProgress).toBe(false);
});
it('cancelGesture pops past entry and clears flag', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
gestureInProgress: useHistoryStore((s) => s.gestureInProgress),
past: useHistoryStore((s) => s.past)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(1));
});
expect(result.current.past).toHaveLength(1);
let cancelled: HistoryEntry | null = null;
act(() => {
cancelled = result.current.api.getState().actions.cancelGesture();
});
expect(cancelled).not.toBeNull();
expect(cancelled!.model.title).toBe('Model 1');
expect(result.current.past).toHaveLength(0);
expect(result.current.gestureInProgress).toBe(false);
});
it('cancelGesture returns null when no gesture in progress', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi()
}),
{ wrapper }
);
let cancelled: HistoryEntry | null = null;
act(() => {
cancelled = result.current.api.getState().actions.cancelGesture();
});
expect(cancelled).toBeNull();
});
it('beginGesture is idempotent when already in gesture', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(1));
});
act(() => {
result.current.api.getState().actions.beginGesture(makeEntry(2));
});
// Should only have one entry (second beginGesture was a no-op)
expect(result.current.past).toHaveLength(1);
expect(result.current.past[0].model.title).toBe('Model 1');
});
});
describe('clearHistory', () => {
it('resets past, future, and gestureInProgress', () => {
const { result } = renderHook(
() => ({
api: useHistoryStoreApi(),
past: useHistoryStore((s) => s.past),
future: useHistoryStore((s) => s.future),
gestureInProgress: useHistoryStore((s) => s.gestureInProgress)
}),
{ wrapper }
);
act(() => {
result.current.api.getState().actions.saveSnapshot(makeEntry(1));
result.current.api.getState().actions.saveSnapshot(makeEntry(2));
});
act(() => {
result.current.api.getState().actions.clearHistory();
});
expect(result.current.past).toHaveLength(0);
expect(result.current.future).toHaveLength(0);
expect(result.current.gestureInProgress).toBe(false);
});
});
});

View File

@@ -0,0 +1,211 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { Model, Scene } from 'src/types';
export interface HistoryEntry {
model: Model;
scene: Scene;
}
export interface HistoryStoreState {
past: HistoryEntry[];
future: HistoryEntry[];
gestureInProgress: boolean;
maxSize: number;
actions: {
saveSnapshot: (currentEntry: HistoryEntry) => void;
undo: (currentEntry: HistoryEntry) => HistoryEntry | null;
redo: (currentEntry: HistoryEntry) => HistoryEntry | null;
clearHistory: () => void;
beginGesture: (currentEntry: HistoryEntry) => void;
endGesture: () => void;
cancelGesture: () => HistoryEntry | null;
};
}
const MAX_HISTORY_SIZE = 50;
const createHistoryStore = () => {
return createStore<HistoryStoreState>((set, get) => ({
past: [],
future: [],
gestureInProgress: false,
maxSize: MAX_HISTORY_SIZE,
actions: {
saveSnapshot: (currentEntry: HistoryEntry) => {
const { gestureInProgress, maxSize } = get();
if (gestureInProgress) return;
set((state) => {
const newPast = [...state.past, currentEntry];
if (newPast.length > maxSize) {
newPast.shift();
}
return {
past: newPast,
future: []
};
});
},
undo: (currentEntry: HistoryEntry) => {
const { past } = get();
if (past.length === 0) return null;
const previous = past[past.length - 1];
const newPast = past.slice(0, -1);
set({
past: newPast,
future: [currentEntry, ...get().future]
});
return previous;
},
redo: (currentEntry: HistoryEntry) => {
const { future } = get();
if (future.length === 0) return null;
const next = future[0];
const newFuture = future.slice(1);
set((state) => ({
past: [...state.past, currentEntry],
future: newFuture
}));
return next;
},
clearHistory: () => {
set({
past: [],
future: [],
gestureInProgress: false
});
},
beginGesture: (currentEntry: HistoryEntry) => {
const { gestureInProgress, maxSize } = get();
if (gestureInProgress) return;
set((state) => {
const newPast = [...state.past, currentEntry];
if (newPast.length > maxSize) {
newPast.shift();
}
return {
past: newPast,
future: [],
gestureInProgress: true
};
});
},
endGesture: () => {
set({ gestureInProgress: false });
},
cancelGesture: () => {
const { past, gestureInProgress } = get();
if (!gestureInProgress || past.length === 0) {
set({ gestureInProgress: false });
return null;
}
const previous = past[past.length - 1];
const newPast = past.slice(0, -1);
set({
past: newPast,
gestureInProgress: false
});
return previous;
}
}
}));
};
const HistoryContext = createContext<ReturnType<typeof createHistoryStore> | null>(
null
);
interface ProviderProps {
children: React.ReactNode;
}
export const HistoryProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof createHistoryStore> | undefined>(undefined);
if (!storeRef.current) {
storeRef.current = createHistoryStore();
}
return (
<HistoryContext.Provider value={storeRef.current}>
{children}
</HistoryContext.Provider>
);
};
export function useHistoryStore<T>(
selector: (state: HistoryStoreState) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(HistoryContext);
if (store === null) {
throw new Error('Missing HistoryProvider in the tree');
}
const value = useStore(store, selector, equalityFn);
return value;
}
export function useHistoryStoreApi() {
const store = useContext(HistoryContext);
if (store === null) {
throw new Error('Missing HistoryProvider in the tree');
}
return store;
}
export const extractModelData = (state: {
version?: string;
title: string;
description?: string;
colors: Model['colors'];
icons: Model['icons'];
items: Model['items'];
views: Model['views'];
}): Model => {
return {
version: state.version,
title: state.title,
description: state.description,
colors: state.colors,
icons: state.icons,
items: state.items,
views: state.views
};
};
export const extractSceneData = (state: {
connectors: Scene['connectors'];
textBoxes: Scene['textBoxes'];
}): Scene => {
return {
connectors: state.connectors,
textBoxes: state.textBoxes
};
};

View File

@@ -3,158 +3,19 @@ import { createStore, useStore } from 'zustand';
import { ModelStore, Model } from 'src/types';
import { INITIAL_DATA } from 'src/config';
export interface HistoryState {
past: Model[];
present: Model;
future: Model[];
maxHistorySize: number;
}
export interface ModelStoreWithHistory extends Omit<ModelStore, 'actions'> {
history: HistoryState;
actions: {
get: () => ModelStoreWithHistory;
set: (model: Partial<Model>, skipHistory?: boolean) => void;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
saveToHistory: () => void;
clearHistory: () => void;
};
}
const MAX_HISTORY_SIZE = 50;
const createHistoryState = (initialModel: Model): HistoryState => {
return {
past: [],
present: initialModel,
future: [],
maxHistorySize: MAX_HISTORY_SIZE
};
};
const extractModelData = (state: ModelStoreWithHistory): Model => {
return {
version: state.version,
title: state.title,
description: state.description,
colors: state.colors,
icons: state.icons,
items: state.items,
views: state.views
};
};
const initialState = () => {
return createStore<ModelStoreWithHistory>((set, get) => {
return createStore<ModelStore>((set, get) => {
const initialModel = { ...INITIAL_DATA };
const saveToHistory = () => {
set((state) => {
const currentModel = extractModelData(state);
const newPast = [...state.history.past, state.history.present];
// Limit history size to prevent memory issues
if (newPast.length > state.history.maxHistorySize) {
newPast.shift();
}
return {
...state,
history: {
...state.history,
past: newPast,
present: currentModel,
future: [] // Clear future when new action is performed
}
};
});
};
const undo = (): boolean => {
const { history } = get();
if (history.past.length === 0) return false;
const previous = history.past[history.past.length - 1];
const newPast = history.past.slice(0, history.past.length - 1);
set((state) => {
return {
...previous,
history: {
...state.history,
past: newPast,
present: previous,
future: [state.history.present, ...state.history.future]
}
};
});
return true;
};
const redo = (): boolean => {
const { history } = get();
if (history.future.length === 0) return false;
const next = history.future[0];
const newFuture = history.future.slice(1);
set((state) => {
return {
...next,
history: {
...state.history,
past: [...state.history.past, state.history.present],
present: next,
future: newFuture
}
};
});
return true;
};
const canUndo = () => {
return get().history.past.length > 0;
};
const canRedo = () => {
return get().history.future.length > 0;
};
const clearHistory = () => {
const currentState = get();
const currentModel = extractModelData(currentState);
set((state) => {
return {
...state,
history: createHistoryState(currentModel)
};
});
};
return {
...initialModel,
history: createHistoryState(initialModel),
actions: {
get,
set: (updates: Partial<Model>, skipHistory = false) => {
if (!skipHistory) {
saveToHistory();
}
set: (updates: Partial<Model>) => {
set((state) => {
return { ...state, ...updates };
});
},
undo,
redo,
canUndo,
canRedo,
saveToHistory,
clearHistory
}
}
};
});
@@ -183,7 +44,7 @@ export const ModelProvider = ({ children }: ProviderProps) => {
};
export function useModelStore<T>(
selector: (state: ModelStoreWithHistory) => T,
selector: (state: ModelStore) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(ModelContext);

View File

@@ -2,156 +2,22 @@ import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { SceneStore, Scene } from 'src/types';
export interface SceneHistoryState {
past: Scene[];
present: Scene;
future: Scene[];
maxHistorySize: number;
}
export interface SceneStoreWithHistory extends Omit<SceneStore, 'actions'> {
history: SceneHistoryState;
actions: {
get: () => SceneStoreWithHistory;
set: (scene: Partial<Scene>, skipHistory?: boolean) => void;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
saveToHistory: () => void;
clearHistory: () => void;
};
}
const MAX_HISTORY_SIZE = 50;
const createSceneHistoryState = (initialScene: Scene): SceneHistoryState => {
return {
past: [],
present: initialScene,
future: [],
maxHistorySize: MAX_HISTORY_SIZE
};
};
const extractSceneData = (state: SceneStoreWithHistory): Scene => {
return {
connectors: state.connectors,
textBoxes: state.textBoxes
};
};
const initialState = () => {
return createStore<SceneStoreWithHistory>((set, get) => {
return createStore<SceneStore>((set, get) => {
const initialScene: Scene = {
connectors: {},
textBoxes: {}
};
const saveToHistory = () => {
set((state) => {
const currentScene = extractSceneData(state);
const newPast = [...state.history.past, state.history.present];
// Limit history size
if (newPast.length > state.history.maxHistorySize) {
newPast.shift();
}
return {
...state,
history: {
...state.history,
past: newPast,
present: currentScene,
future: []
}
};
});
};
const undo = (): boolean => {
const { history } = get();
if (history.past.length === 0) return false;
const previous = history.past[history.past.length - 1];
const newPast = history.past.slice(0, history.past.length - 1);
set((state) => {
return {
...previous,
history: {
...state.history,
past: newPast,
present: previous,
future: [state.history.present, ...state.history.future]
}
};
});
return true;
};
const redo = (): boolean => {
const { history } = get();
if (history.future.length === 0) return false;
const next = history.future[0];
const newFuture = history.future.slice(1);
set((state) => {
return {
...next,
history: {
...state.history,
past: [...state.history.past, state.history.present],
present: next,
future: newFuture
}
};
});
return true;
};
const canUndo = () => {
return get().history.past.length > 0;
};
const canRedo = () => {
return get().history.future.length > 0;
};
const clearHistory = () => {
const currentState = get();
const currentScene = extractSceneData(currentState);
set((state) => {
return {
...state,
history: createSceneHistoryState(currentScene)
};
});
};
return {
...initialScene,
history: createSceneHistoryState(initialScene),
actions: {
get,
set: (updates: Partial<Scene>, skipHistory = false) => {
if (!skipHistory) {
saveToHistory();
}
set: (updates: Partial<Scene>) => {
set((state) => {
return { ...state, ...updates };
});
},
undo,
redo,
canUndo,
canRedo,
saveToHistory,
clearHistory
}
}
};
});
@@ -180,7 +46,7 @@ export const SceneProvider = ({ children }: ProviderProps) => {
};
export function useSceneStore<T>(
selector: (state: SceneStoreWithHistory) => T,
selector: (state: SceneStore) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(SceneContext);

View File

@@ -1,10 +1,15 @@
import { ModelStore, UiStateStore, Size } from 'src/types';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
export interface State {
model: ModelStore;
scene: ReturnType<typeof useScene>;
uiState: UiStateStore;
history: Pick<
ReturnType<typeof useHistory>,
'beginGesture' | 'endGesture' | 'cancelGesture' | 'isGestureInProgress'
>;
rendererRef: HTMLElement;
rendererSize: Size;
isRendererInteraction: boolean;

View File

@@ -17,8 +17,6 @@ import {
connectorStyleOptions,
connectorLineTypeOptions
} from 'src/schemas';
import { StoreApi } from 'zustand';
export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas';
export type Model = z.infer<typeof modelSchema>;
export type ModelItems = z.infer<typeof modelItemsSchema>;
@@ -39,17 +37,7 @@ export type Rectangle = z.infer<typeof rectangleSchema>;
export type ModelStore = Model & {
actions: {
get: StoreApi<ModelStore>['getState'];
set: StoreApi<ModelStore>['setState'];
get: () => ModelStore;
set: (updates: Partial<Model>) => void;
};
};
export type {
ModelStoreWithHistory,
HistoryState as ModelHistoryState
} from 'src/stores/modelStore';
export type {
SceneStoreWithHistory,
SceneHistoryState
} from 'src/stores/sceneStore';

View File

@@ -1,5 +1,11 @@
import { StoreApi } from 'zustand';
import type { Coords, Rect, Size } from './common';
import type { ViewItem, Rectangle, TextBox } from './model';
export interface SceneItemsSnapshot {
items: Pick<ViewItem, 'tile' | 'id'>[];
rectangles: Pick<Rectangle, 'from' | 'to' | 'id'>[];
textBoxes: Pick<TextBox, 'tile' | 'id'>[];
}
export const tileOriginOptions = {
CENTER: 'CENTER',
@@ -50,7 +56,7 @@ export interface Scene {
export type SceneStore = Scene & {
actions: {
get: StoreApi<SceneStore>['getState'];
set: StoreApi<SceneStore>['setState'];
get: () => SceneStore;
set: (updates: Partial<Scene>) => void;
};
};

View File

@@ -44,7 +44,7 @@ export interface DragItemsMode {
type: 'DRAG_ITEMS';
showCursor: boolean;
items: ItemReference[];
isInitialMovement: Boolean;
isInitialMovement: boolean;
}
export interface PanMode {