mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-25 07:28:55 -05:00
feat: implement comprehensive undo/redo system with keyboard shortcuts and UI integration
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
84
src/hooks/useHistory.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user