feat: implement comprehensive undo/redo system with keyboard shortcuts and UI integration

This commit is contained in:
pi22by7
2025-07-02 22:37:56 +05:30
parent ac16e76e76
commit b9356d3c76
10 changed files with 749 additions and 60 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "isoflow",
"version": "1.0.11",
"version": "1.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "isoflow",
"version": "1.0.11",
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.10.6",

View File

@@ -7,7 +7,9 @@ import {
DataObject as ExportJsonIcon,
ImageOutlined as ExportImageIcon,
FolderOpen as FolderOpenIcon,
DeleteOutline as DeleteOutlineIcon
DeleteOutline as DeleteOutlineIcon,
Undo as UndoIcon,
Redo as RedoIcon
} from '@mui/icons-material';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
@@ -15,6 +17,7 @@ import { useUiStateStore } from 'src/stores/uiStateStore';
import { exportAsJSON, modelFromModelStore } from 'src/utils';
import { useInitialDataManager } from 'src/hooks/useInitialDataManager';
import { useModelStore } from 'src/stores/modelStore';
import { useHistory } from 'src/hooks/useHistory';
import { MenuItem } from './MenuItem';
export const MainMenu = () => {
@@ -32,6 +35,7 @@ export const MainMenu = () => {
return state.actions;
});
const initialDataManager = useInitialDataManager();
const { undo, redo, canUndo, canRedo, clearHistory } = useHistory();
const onToggleMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -64,6 +68,7 @@ export const MainMenu = () => {
fileReader.onload = async (e) => {
const modelData = JSON.parse(e.target?.result as string);
load(modelData);
clearHistory(); // Clear history when loading new model
};
fileReader.readAsText(file);
@@ -72,7 +77,7 @@ export const MainMenu = () => {
await fileInput.click();
uiStateActions.setIsMainMenuOpen(false);
}, [uiStateActions, load]);
}, [uiStateActions, load, clearHistory]);
const onExportAsJSON = useCallback(async () => {
exportAsJSON(model);
@@ -88,8 +93,19 @@ export const MainMenu = () => {
const onClearCanvas = useCallback(() => {
clear();
clearHistory(); // Clear history when clearing canvas
uiStateActions.setIsMainMenuOpen(false);
}, [uiStateActions, clear]);
}, [uiStateActions, clear, clearHistory]);
const handleUndo = useCallback(() => {
undo();
uiStateActions.setIsMainMenuOpen(false);
}, [undo, uiStateActions]);
const handleRedo = useCallback(() => {
redo();
uiStateActions.setIsMainMenuOpen(false);
}, [redo, uiStateActions]);
const sectionVisibility = useMemo(() => {
return {
@@ -133,6 +149,26 @@ export const MainMenu = () => {
}}
>
<Card sx={{ py: 1 }}>
{/* Undo/Redo Section */}
<MenuItem
onClick={handleUndo}
Icon={<UndoIcon />}
disabled={!canUndo}
>
Undo
</MenuItem>
<MenuItem
onClick={handleRedo}
Icon={<RedoIcon />}
disabled={!canRedo}
>
Redo
</MenuItem>
{(canUndo || canRedo) && sectionVisibility.actions && <Divider />}
{/* File Actions */}
{mainMenuOptions.includes('ACTION.OPEN') && (
<MenuItem onClick={onOpenModel} Icon={<FolderOpenIcon />}>
Open

View File

@@ -5,12 +5,18 @@ export interface Props {
onClick?: () => void;
Icon?: React.ReactNode;
children: string | React.ReactNode;
disabled?: boolean;
}
export const MenuItem = ({ onClick, Icon, children }: Props) => {
export const MenuItem = ({
onClick,
Icon,
children,
disabled = false
}: Props) => {
return (
<MuiMenuItem onClick={onClick}>
<ListItemIcon>{Icon}</ListItemIcon>
<MuiMenuItem onClick={onClick} disabled={disabled}>
<ListItemIcon sx={{ opacity: disabled ? 0.5 : 1 }}>{Icon}</ListItemIcon>
{children}
</MuiMenuItem>
);

View File

@@ -1,22 +1,26 @@
import React, { useCallback } from 'react';
import { Stack } from '@mui/material';
import { Stack, Divider } from '@mui/material';
import {
PanToolOutlined as PanToolIcon,
NearMeOutlined as NearMeIcon,
AddOutlined as AddIcon,
EastOutlined as ConnectorIcon,
CropSquareOutlined as CropSquareIcon,
Title as TitleIcon
Title as TitleIcon,
Undo as UndoIcon,
Redo as RedoIcon
} from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { IconButton } from 'src/components/IconButton/IconButton';
import { UiElement } from 'src/components/UiElement/UiElement';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import { TEXTBOX_DEFAULTS } from 'src/config';
import { generateId } from 'src/utils';
export const ToolMenu = () => {
const { createTextBox } = useScene();
const { undo, redo, canUndo, canRedo } = useHistory();
const mode = useUiStateStore((state) => {
return state.mode;
});
@@ -27,6 +31,14 @@ export const ToolMenu = () => {
return state.mouse.position.tile;
});
const handleUndo = useCallback(() => {
undo();
}, [undo]);
const handleRedo = useCallback(() => {
redo();
}, [redo]);
const createTextBoxProxy = useCallback(() => {
const textBoxId = generateId();
@@ -46,6 +58,23 @@ export const ToolMenu = () => {
return (
<UiElement>
<Stack direction="row">
{/* Undo/Redo Section */}
<IconButton
name="Undo (Ctrl+Z)"
Icon={<UndoIcon />}
onClick={handleUndo}
disabled={!canUndo}
/>
<IconButton
name="Redo (Ctrl+Y)"
Icon={<RedoIcon />}
onClick={handleRedo}
disabled={!canRedo}
/>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* Main Tools */}
<IconButton
name="Select"
Icon={<NearMeIcon />}

84
src/hooks/useHistory.ts Normal file
View File

@@ -0,0 +1,84 @@
import { useCallback } from 'react';
import { useModelStore } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore';
export const useHistory = () => {
// Call all hooks unconditionally at the top level with safe fallbacks
const modelActions = useModelStore((state) => {
return state?.actions;
});
const sceneActions = useSceneStore((state) => {
return state?.actions;
});
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;
});
// Derived values
const canUndo = modelCanUndo || sceneCanUndo;
const canRedo = modelCanRedo || sceneCanRedo;
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;
}
if (sceneActions.canUndo()) {
undoPerformed = sceneActions.undo() || undoPerformed;
}
return undoPerformed;
}, [modelActions, sceneActions]);
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;
}
if (sceneActions.canRedo()) {
redoPerformed = sceneActions.redo() || redoPerformed;
}
return redoPerformed;
}, [modelActions, sceneActions]);
const saveToHistory = useCallback(() => {
if (!modelActions || !sceneActions) return;
modelActions.saveToHistory();
sceneActions.saveToHistory();
}, [modelActions, sceneActions]);
const clearHistory = useCallback(() => {
if (!modelActions || !sceneActions) return;
modelActions.clearHistory();
sceneActions.clearHistory();
}, [modelActions, sceneActions]);
return {
undo,
redo,
canUndo,
canRedo,
saveToHistory,
clearHistory
};
};

View File

@@ -24,30 +24,55 @@ export const useScene = () => {
const model = useModelStore((state) => {
return state;
});
const scene = useSceneStore((state) => {
return state;
});
const currentViewId = useUiStateStore((state) => {
return state.view;
});
const currentView = useMemo(() => {
return getItemByIdOrThrow(model.views, currentViewId).value;
}, [currentViewId, model.views]);
// Handle case where view doesn't exist yet or stores aren't initialized
if (!model?.views || !currentViewId) {
return {
id: '',
name: 'Default View',
items: [],
connectors: [],
rectangles: [],
textBoxes: []
};
}
try {
return getItemByIdOrThrow(model.views, currentViewId).value;
} catch (error) {
// console.warn(`View "${currentViewId}" not found, using fallback`);
// Return first available view or empty view
return (
model.views[0] || {
id: currentViewId,
name: 'Default View',
items: [],
connectors: [],
rectangles: [],
textBoxes: []
}
);
}
}, [currentViewId, model?.views]);
const items = useMemo(() => {
return currentView.items ?? [];
}, [currentView.items]);
const colors = useMemo(() => {
return model.colors;
}, [model.colors]);
return model?.colors ?? [];
}, [model?.colors]);
const connectors = useMemo(() => {
return (currentView.connectors ?? []).map((connector) => {
const sceneConnector = scene.connectors[connector.id];
const sceneConnector = scene?.connectors?.[connector.id];
return {
...CONNECTOR_DEFAULTS,
@@ -55,7 +80,7 @@ export const useScene = () => {
...sceneConnector
};
});
}, [currentView.connectors, scene.connectors]);
}, [currentView.connectors, scene?.connectors]);
const rectangles = useMemo(() => {
return (currentView.rectangles ?? []).map((rectangle) => {
@@ -68,7 +93,7 @@ export const useScene = () => {
const textBoxes = useMemo(() => {
return (currentView.textBoxes ?? []).map((textBox) => {
const sceneTextBox = scene.textBoxes[textBox.id];
const sceneTextBox = scene?.textBoxes?.[textBox.id];
return {
...TEXTBOX_DEFAULTS,
@@ -76,49 +101,98 @@ export const useScene = () => {
...sceneTextBox
};
});
}, [currentView.textBoxes, scene.textBoxes]);
}, [currentView.textBoxes, scene?.textBoxes]);
const getState = useCallback(() => {
return {
model: model.actions.get(),
scene: scene.actions.get()
model: {
version: model?.version ?? '',
title: model?.title ?? '',
description: model?.description,
colors: model?.colors ?? [],
icons: model?.icons ?? [],
items: model?.items ?? [],
views: model?.views ?? []
},
scene: {
connectors: scene?.connectors ?? {},
textBoxes: scene?.textBoxes ?? {}
}
};
}, [model.actions, scene.actions]);
}, [model, scene]);
const setState = useCallback(
(newState: State) => {
model.actions.set(newState.model);
scene.actions.set(newState.scene);
if (model?.actions?.set && scene?.actions?.set) {
model.actions.set(newState.model, true); // Skip history since we're managing it here
scene.actions.set(newState.scene, true); // Skip history since we're managing it here
}
},
[model.actions, scene.actions]
[model?.actions, scene?.actions]
);
// Helper to save current state to history before making changes
const saveToHistoryBeforeChange = useCallback(() => {
model?.actions?.saveToHistory?.();
scene?.actions?.saveToHistory?.();
}, [model?.actions, scene?.actions]);
const createModelItem = useCallback(
(newModelItem: ModelItem) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange();
const newState = reducers.createModelItem(newModelItem, getState());
setState(newState);
},
[getState, setState]
[
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const updateModelItem = useCallback(
(id: string, updates: Partial<ModelItem>) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange();
const newState = reducers.updateModelItem(id, updates, getState());
setState(newState);
},
[getState, setState]
[
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const deleteModelItem = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange();
const newState = reducers.deleteModelItem(id, getState());
setState(newState);
},
[getState, setState]
[
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const createViewItem = useCallback(
(newViewItem: ViewItem) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'CREATE_VIEWITEM',
payload: newViewItem,
@@ -126,11 +200,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'UPDATE_VIEWITEM',
payload: { id, ...updates },
@@ -138,11 +222,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const deleteViewItem = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'DELETE_VIEWITEM',
payload: id,
@@ -150,11 +244,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const createConnector = useCallback(
(newConnector: Connector) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'CREATE_CONNECTOR',
payload: newConnector,
@@ -162,11 +266,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const updateConnector = useCallback(
(id: string, updates: Partial<Connector>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'UPDATE_CONNECTOR',
payload: { id, ...updates },
@@ -174,11 +288,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const deleteConnector = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'DELETE_CONNECTOR',
payload: id,
@@ -186,11 +310,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const createTextBox = useCallback(
(newTextBox: TextBox) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'CREATE_TEXTBOX',
payload: newTextBox,
@@ -198,11 +332,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const updateTextBox = useCallback(
(id: string, updates: Partial<TextBox>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'UPDATE_TEXTBOX',
payload: { id, ...updates },
@@ -210,11 +354,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const deleteTextBox = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'DELETE_TEXTBOX',
payload: id,
@@ -222,11 +376,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const createRectangle = useCallback(
(newRectangle: Rectangle) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'CREATE_RECTANGLE',
payload: newRectangle,
@@ -234,11 +398,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const updateRectangle = useCallback(
(id: string, updates: Partial<Rectangle>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'UPDATE_RECTANGLE',
payload: { id, ...updates },
@@ -246,11 +420,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const deleteRectangle = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'DELETE_RECTANGLE',
payload: id,
@@ -258,11 +442,21 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
const changeLayerOrder = useCallback(
(action: LayerOrderingAction, item: ItemReference) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
action: 'CHANGE_LAYER_ORDER',
payload: { action, item },
@@ -270,7 +464,14 @@ export const useScene = () => {
});
setState(newState);
},
[getState, setState, currentViewId]
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
);
return {

View File

@@ -5,6 +5,7 @@ import { ModeActions, State, SlimMouseEvent } from 'src/types';
import { getMouse, getItemAtTile } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
import { useHistory } from 'src/hooks/useHistory';
import { Cursor } from './modes/Cursor';
import { DragItems } from './modes/DragItems';
import { DrawRectangle } from './modes/Rectangle/DrawRectangle';
@@ -17,7 +18,6 @@ import { TextBox } from './modes/TextBox';
const modes: { [k in string]: ModeActions } = {
CURSOR: Cursor,
DRAG_ITEMS: DragItems,
// TODO: Adopt this notation for all modes (i.e. {node.type}.{action})
'RECTANGLE.DRAW': DrawRectangle,
'RECTANGLE.TRANSFORM': TransformRectangle,
CONNECTOR: Connector,
@@ -50,6 +50,48 @@ export const useInteractionManager = () => {
});
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory();
// Keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts when typing in input fields
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('.ql-editor') // Quill editor
) {
return;
}
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) {
undo();
}
}
if (
isCtrlOrCmd &&
(e.key.toLowerCase() === 'y' ||
(e.key.toLowerCase() === 'z' && e.shiftKey))
) {
e.preventDefault();
if (canRedo) {
redo();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
return window.removeEventListener('keydown', handleKeyDown);
};
}, [undo, redo, canUndo, canRedo]);
const onMouseEvent = useCallback(
(e: SlimMouseEvent) => {

View File

@@ -1,15 +1,160 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { ModelStore } from 'src/types';
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<ModelStore>((set, get) => {
return createStore<ModelStoreWithHistory>((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 {
...INITIAL_DATA,
...initialModel,
history: createHistoryState(initialModel),
actions: {
get,
set
set: (updates: Partial<Model>, skipHistory = false) => {
if (!skipHistory) {
saveToHistory();
}
set((state) => {
return { ...state, ...updates };
});
},
undo,
redo,
canUndo,
canRedo,
saveToHistory,
clearHistory
}
};
});
@@ -23,8 +168,6 @@ interface ProviderProps {
children: React.ReactNode;
}
// TODO: Typings below are pretty gnarly due to the way Zustand works.
// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061
export const ModelProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof initialState>>();
@@ -40,7 +183,7 @@ export const ModelProvider = ({ children }: ProviderProps) => {
};
export function useModelStore<T>(
selector: (state: ModelStore) => T,
selector: (state: ModelStoreWithHistory) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(ModelContext);
@@ -50,6 +193,5 @@ export function useModelStore<T>(
}
const value = useStore(store, selector, equalityFn);
return value;
}

View File

@@ -1,15 +1,157 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { SceneStore } from 'src/types';
import { SceneStore, Scene } from 'src/types';
export interface SceneHistoryState {
past: Scene[];
present: Scene;
future: Scene[];
maxHistorySize: number;
}
export interface SceneStoreWithHistory extends Omit<SceneStore, 'actions'> {
history: SceneHistoryState;
actions: {
get: () => SceneStoreWithHistory;
set: (scene: Partial<Scene>, skipHistory?: boolean) => void;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
saveToHistory: () => void;
clearHistory: () => void;
};
}
const MAX_HISTORY_SIZE = 50;
const createSceneHistoryState = (initialScene: Scene): SceneHistoryState => {
return {
past: [],
present: initialScene,
future: [],
maxHistorySize: MAX_HISTORY_SIZE
};
};
const extractSceneData = (state: SceneStoreWithHistory): Scene => {
return {
connectors: state.connectors,
textBoxes: state.textBoxes
};
};
const initialState = () => {
return createStore<SceneStore>((set, get) => {
return {
return createStore<SceneStoreWithHistory>((set, get) => {
const initialScene: Scene = {
connectors: {},
textBoxes: {},
textBoxes: {}
};
const saveToHistory = () => {
set((state) => {
const currentScene = extractSceneData(state);
const newPast = [...state.history.past, state.history.present];
// Limit history size
if (newPast.length > state.history.maxHistorySize) {
newPast.shift();
}
return {
...state,
history: {
...state.history,
past: newPast,
present: currentScene,
future: []
}
};
});
};
const undo = (): boolean => {
const { history } = get();
if (history.past.length === 0) return false;
const previous = history.past[history.past.length - 1];
const newPast = history.past.slice(0, history.past.length - 1);
set((state) => {
return {
...previous,
history: {
...state.history,
past: newPast,
present: previous,
future: [state.history.present, ...state.history.future]
}
};
});
return true;
};
const redo = (): boolean => {
const { history } = get();
if (history.future.length === 0) return false;
const next = history.future[0];
const newFuture = history.future.slice(1);
set((state) => {
return {
...next,
history: {
...state.history,
past: [...state.history.past, state.history.present],
present: next,
future: newFuture
}
};
});
return true;
};
const canUndo = () => {
return get().history.past.length > 0;
};
const canRedo = () => {
return get().history.future.length > 0;
};
const clearHistory = () => {
const currentState = get();
const currentScene = extractSceneData(currentState);
set((state) => {
return {
...state,
history: createSceneHistoryState(currentScene)
};
});
};
return {
...initialScene,
history: createSceneHistoryState(initialScene),
actions: {
get,
set
set: (updates: Partial<Scene>, skipHistory = false) => {
if (!skipHistory) {
saveToHistory();
}
set((state) => {
return { ...state, ...updates };
});
},
undo,
redo,
canUndo,
canRedo,
saveToHistory,
clearHistory
}
};
});
@@ -23,8 +165,6 @@ interface ProviderProps {
children: React.ReactNode;
}
// TODO: Typings below are pretty gnarly due to the way Zustand works.
// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061
export const SceneProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof initialState>>();
@@ -40,7 +180,7 @@ export const SceneProvider = ({ children }: ProviderProps) => {
};
export function useSceneStore<T>(
selector: (state: SceneStore) => T,
selector: (state: SceneStoreWithHistory) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(SceneContext);
@@ -50,6 +190,5 @@ export function useSceneStore<T>(
}
const value = useStore(store, selector, equalityFn);
return value;
}

View File

@@ -39,3 +39,13 @@ export type ModelStore = Model & {
set: StoreApi<ModelStore>['setState'];
};
};
export type {
ModelStoreWithHistory,
HistoryState as ModelHistoryState
} from 'src/stores/modelStore';
export type {
SceneStoreWithHistory,
SceneHistoryState
} from 'src/stores/sceneStore';