unfucked useScene and friends - everything was subscribing to entire stores via (state) => state so every mouse move re-rendered ~30 components because setMouse lives in uiState and useScene pulls from all three stores. swapped to granular selectors with shallow equality for reactive stuff, added useModelStoreApi/useSceneStoreApi for imperative getState() inside callbacks so all 16 CRUD functions are stable refs now. useModelItem was pulling entire state including undo history, usePanHandlers was re-rendering on every mouse move, useInteractionManager was tearing down and re-registering event listeners nearly every frame. now mouse moves hit ~3 components, callbacks never recreate, event listeners only re-register on mode switch, store history arrays are excluded from subscriptions. also added missing equalityFn param to uiStateStore and fixed the hotkeyProfile typing. 7 files, -137 lines net (#225)

Signed-off-by: Stan <xkz0@protonmail.com>
This commit is contained in:
Stan
2026-02-06 07:37:30 +00:00
committed by GitHub
parent 011f0aff1d
commit 4fe68a3b45
7 changed files with 198 additions and 335 deletions
@@ -4,14 +4,12 @@ import { useModelStore } from 'src/stores/modelStore';
import { getItemById } from 'src/utils'; import { getItemById } from 'src/utils';
export const useModelItem = (id: string): ModelItem | null => { export const useModelItem = (id: string): ModelItem | null => {
const model = useModelStore((state) => { const items = useModelStore((state) => state.items);
return state;
});
const modelItem = useMemo(() => { const modelItem = useMemo(() => {
const item = getItemById(model.items, id); const item = getItemById(items, id);
return item ? item.value : null; return item ? item.value : null;
}, [id, model.items]); }, [id, items]);
return modelItem; return modelItem;
}; };
+98 -216
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import { import {
ModelItem, ModelItem,
ViewItem, ViewItem,
@@ -7,8 +8,8 @@ import {
Rectangle Rectangle
} from 'src/types'; } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore'; import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStore } from 'src/stores/modelStore'; import { useModelStore, useModelStoreApi } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore'; import { useSceneStore, useSceneStoreApi } from 'src/stores/sceneStore';
import * as reducers from 'src/stores/reducers'; import * as reducers from 'src/stores/reducers';
import type { State } from 'src/stores/reducers/types'; import type { State } from 'src/stores/reducers/types';
import { getItemByIdOrThrow } from 'src/utils'; import { getItemByIdOrThrow } from 'src/utils';
@@ -19,20 +20,35 @@ import {
} from 'src/config'; } from 'src/config';
export const useScene = () => { export const useScene = () => {
const model = useModelStore((state) => { const { views, colors, icons, items, version, title, description } =
return state; useModelStore(
}); (state) => ({
const scene = useSceneStore((state) => { views: state.views,
return state; colors: state.colors,
}); icons: state.icons,
const currentViewId = useUiStateStore((state) => { items: state.items,
return state.view; version: state.version,
}); title: state.title,
description: state.description
}),
shallow
);
const { connectors: sceneConnectors, textBoxes: sceneTextBoxes } =
useSceneStore(
(state) => ({
connectors: state.connectors,
textBoxes: state.textBoxes
}),
shallow
);
const currentViewId = useUiStateStore((state) => state.view);
const transactionInProgress = useRef(false); const transactionInProgress = useRef(false);
const modelStoreApi = useModelStoreApi();
const sceneStoreApi = useSceneStoreApi();
const currentView = useMemo(() => { const currentView = useMemo(() => {
// Handle case where view doesn't exist yet or stores aren't initialized if (!views || !currentViewId) {
if (!model?.views || !currentViewId) {
return { return {
id: '', id: '',
name: 'Default View', name: 'Default View',
@@ -44,12 +60,10 @@ export const useScene = () => {
} }
try { try {
return getItemByIdOrThrow(model.views, currentViewId).value; return getItemByIdOrThrow(views, currentViewId).value;
} catch (error) { } catch (error) {
// console.warn(`View "${currentViewId}" not found, using fallback`);
// Return first available view or empty view
return ( return (
model.views[0] || { views[0] || {
id: currentViewId, id: currentViewId,
name: 'Default View', name: 'Default View',
items: [], items: [],
@@ -59,19 +73,19 @@ export const useScene = () => {
} }
); );
} }
}, [currentViewId, model?.views]); }, [currentViewId, views]);
const items = useMemo(() => { const itemsList = useMemo(() => {
return currentView.items ?? []; return currentView.items ?? [];
}, [currentView.items]); }, [currentView.items]);
const colors = useMemo(() => { const colorsList = useMemo(() => {
return model?.colors ?? []; return colors ?? [];
}, [model?.colors]); }, [colors]);
const connectors = useMemo(() => { const connectorsList = useMemo(() => {
return (currentView.connectors ?? []).map((connector) => { return (currentView.connectors ?? []).map((connector) => {
const sceneConnector = scene?.connectors?.[connector.id]; const sceneConnector = sceneConnectors?.[connector.id];
return { return {
...CONNECTOR_DEFAULTS, ...CONNECTOR_DEFAULTS,
@@ -79,9 +93,9 @@ export const useScene = () => {
...sceneConnector ...sceneConnector
}; };
}); });
}, [currentView.connectors, scene?.connectors]); }, [currentView.connectors, sceneConnectors]);
const rectangles = useMemo(() => { const rectanglesList = useMemo(() => {
return (currentView.rectangles ?? []).map((rectangle) => { return (currentView.rectangles ?? []).map((rectangle) => {
return { return {
...RECTANGLE_DEFAULTS, ...RECTANGLE_DEFAULTS,
@@ -90,9 +104,9 @@ export const useScene = () => {
}); });
}, [currentView.rectangles]); }, [currentView.rectangles]);
const textBoxes = useMemo(() => { const textBoxesList = useMemo(() => {
return (currentView.textBoxes ?? []).map((textBox) => { return (currentView.textBoxes ?? []).map((textBox) => {
const sceneTextBox = scene?.textBoxes?.[textBox.id]; const sceneTextBox = sceneTextBoxes?.[textBox.id];
return { return {
...TEXTBOX_DEFAULTS, ...TEXTBOX_DEFAULTS,
@@ -100,110 +114,84 @@ export const useScene = () => {
...sceneTextBox ...sceneTextBox
}; };
}); });
}, [currentView.textBoxes, scene?.textBoxes]); }, [currentView.textBoxes, sceneTextBoxes]);
const getState = useCallback(() => { const getState = useCallback((): State => {
const model = modelStoreApi.getState();
const scene = sceneStoreApi.getState();
return { return {
model: { model: {
version: model?.version ?? '', version: model.version,
title: model?.title ?? '', title: model.title,
description: model?.description, description: model.description,
colors: model?.colors ?? [], colors: model.colors,
icons: model?.icons ?? [], icons: model.icons,
items: model?.items ?? [], items: model.items,
views: model?.views ?? [] views: model.views
}, },
scene: { scene: {
connectors: scene?.connectors ?? {}, connectors: scene.connectors,
textBoxes: scene?.textBoxes ?? {} textBoxes: scene.textBoxes
} }
}; };
}, [model, scene]); }, [modelStoreApi, sceneStoreApi]);
const setState = useCallback( const setState = useCallback(
(newState: State) => { (newState: State) => {
if (model?.actions?.set && scene?.actions?.set) { modelStoreApi.getState().actions.set(newState.model, true);
model.actions.set(newState.model, true); // Skip history since we're managing it here sceneStoreApi.getState().actions.set(newState.scene, true);
scene.actions.set(newState.scene, true); // Skip history since we're managing it here
}
}, },
[model?.actions, scene?.actions] [modelStoreApi, sceneStoreApi]
); );
const saveToHistoryBeforeChange = useCallback(() => { const saveToHistoryBeforeChange = useCallback(() => {
// Prevent multiple saves during grouped operations
if (transactionInProgress.current) { if (transactionInProgress.current) {
return; return;
} }
model?.actions?.saveToHistory?.(); modelStoreApi.getState().actions.saveToHistory();
scene?.actions?.saveToHistory?.(); sceneStoreApi.getState().actions.saveToHistory();
}, [model?.actions, scene?.actions]); }, [modelStoreApi, sceneStoreApi]);
const createModelItem = useCallback( const createModelItem = useCallback(
(newModelItem: ModelItem) => { (newModelItem: ModelItem) => {
if (!model?.actions || !scene?.actions) return getState();
if (!transactionInProgress.current) { if (!transactionInProgress.current) {
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
} }
const newState = reducers.createModelItem(newModelItem, getState()); const newState = reducers.createModelItem(newModelItem, getState());
setState(newState); setState(newState);
return newState; // Return the new state for chaining return newState;
}, },
[ [getState, setState, saveToHistoryBeforeChange]
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const updateModelItem = useCallback( const updateModelItem = useCallback(
(id: string, updates: Partial<ModelItem>) => { (id: string, updates: Partial<ModelItem>) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.updateModelItem(id, updates, getState()); const newState = reducers.updateModelItem(id, updates, getState());
setState(newState); setState(newState);
}, },
[ [getState, setState, saveToHistoryBeforeChange]
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const deleteModelItem = useCallback( const deleteModelItem = useCallback(
(id: string) => { (id: string) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.deleteModelItem(id, getState()); const newState = reducers.deleteModelItem(id, getState());
setState(newState); setState(newState);
}, },
[ [getState, setState, saveToHistoryBeforeChange]
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const createViewItem = useCallback( const createViewItem = useCallback(
(newViewItem: ViewItem, currentState?: State) => { (newViewItem: ViewItem, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
if (!transactionInProgress.current) { if (!transactionInProgress.current) {
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
} }
// Use provided state or get current state
const stateToUse = currentState || getState(); const stateToUse = currentState || getState();
const newState = reducers.view({ const newState = reducers.view({
@@ -214,19 +202,12 @@ export const useScene = () => {
setState(newState); setState(newState);
return newState; return newState;
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const updateViewItem = useCallback( const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>, currentState?: State) => { (id: string, updates: Partial<ViewItem>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return getState(); if (!currentViewId) return getState();
if (!transactionInProgress.current) { if (!transactionInProgress.current) {
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
@@ -239,21 +220,14 @@ export const useScene = () => {
ctx: { viewId: currentViewId, state: stateToUse } ctx: { viewId: currentViewId, state: stateToUse }
}); });
setState(newState); setState(newState);
return newState; // Return for chaining return newState;
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const deleteViewItem = useCallback( const deleteViewItem = useCallback(
(id: string) => { (id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -263,19 +237,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const createConnector = useCallback( const createConnector = useCallback(
(newConnector: Connector) => { (newConnector: Connector) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -285,19 +252,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const updateConnector = useCallback( const updateConnector = useCallback(
(id: string, updates: Partial<Connector>) => { (id: string, updates: Partial<Connector>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -307,19 +267,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const deleteConnector = useCallback( const deleteConnector = useCallback(
(id: string) => { (id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -329,19 +282,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const createTextBox = useCallback( const createTextBox = useCallback(
(newTextBox: TextBox) => { (newTextBox: TextBox) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -351,19 +297,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const updateTextBox = useCallback( const updateTextBox = useCallback(
(id: string, updates: Partial<TextBox>, currentState?: State) => { (id: string, updates: Partial<TextBox>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return currentState || getState(); if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) { if (!transactionInProgress.current) {
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
@@ -378,19 +317,12 @@ export const useScene = () => {
setState(newState); setState(newState);
return newState; return newState;
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const deleteTextBox = useCallback( const deleteTextBox = useCallback(
(id: string) => { (id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -400,19 +332,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const createRectangle = useCallback( const createRectangle = useCallback(
(newRectangle: Rectangle) => { (newRectangle: Rectangle) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -422,19 +347,12 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const updateRectangle = useCallback( const updateRectangle = useCallback(
(id: string, updates: Partial<Rectangle>, currentState?: State) => { (id: string, updates: Partial<Rectangle>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return currentState || getState(); if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) { if (!transactionInProgress.current) {
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
@@ -449,19 +367,12 @@ export const useScene = () => {
setState(newState); setState(newState);
return newState; return newState;
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const deleteRectangle = useCallback( const deleteRectangle = useCallback(
(id: string) => { (id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return; if (!currentViewId) return;
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
const newState = reducers.view({ const newState = reducers.view({
@@ -471,81 +382,52 @@ export const useScene = () => {
}); });
setState(newState); setState(newState);
}, },
[ [getState, setState, currentViewId, saveToHistoryBeforeChange]
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
const transaction = useCallback( const transaction = useCallback(
(operations: () => void) => { (operations: () => void) => {
if (!model?.actions || !scene?.actions) return;
// Prevent nested transactions
if (transactionInProgress.current) { if (transactionInProgress.current) {
operations(); operations();
return; return;
} }
// Save state before transaction
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
// Mark transaction as in progress
transactionInProgress.current = true; transactionInProgress.current = true;
try { try {
// Execute all operations without saving intermediate history
operations(); operations();
} finally { } finally {
// Always reset transaction state
transactionInProgress.current = false; transactionInProgress.current = false;
} }
}, },
[saveToHistoryBeforeChange, model?.actions, scene?.actions] [saveToHistoryBeforeChange]
); );
const placeIcon = useCallback( const placeIcon = useCallback(
(params: { modelItem: ModelItem; viewItem: ViewItem }) => { (params: { modelItem: ModelItem; viewItem: ViewItem }) => {
if (!model?.actions || !scene?.actions) return;
// Save history before the transaction
saveToHistoryBeforeChange(); saveToHistoryBeforeChange();
// Mark transaction as in progress
transactionInProgress.current = true; transactionInProgress.current = true;
try { try {
// Create model item first and get the updated state
const stateAfterModelItem = createModelItem(params.modelItem); const stateAfterModelItem = createModelItem(params.modelItem);
// Create view item using the updated state
if (stateAfterModelItem) { if (stateAfterModelItem) {
createViewItem(params.viewItem, stateAfterModelItem); createViewItem(params.viewItem, stateAfterModelItem);
} }
} finally { } finally {
// Always reset transaction state
transactionInProgress.current = false; transactionInProgress.current = false;
} }
}, },
[ [createModelItem, createViewItem, saveToHistoryBeforeChange]
createModelItem,
createViewItem,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
); );
return { return {
items, items: itemsList,
connectors, connectors: connectorsList,
colors, colors: colorsList,
rectangles, rectangles: rectanglesList,
textBoxes, textBoxes: textBoxesList,
currentView, currentView,
createModelItem, createModelItem,
updateModelItem, updateModelItem,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useModelStore } from 'src/stores/modelStore'; import { useModelStoreApi } from 'src/stores/modelStore';
import { useUiStateStore } from 'src/stores/uiStateStore'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent, Mouse } from 'src/types'; import { ModeActions, State, SlimMouseEvent, Mouse } from 'src/types';
import { DialogTypeEnum } from 'src/types/ui'; import { DialogTypeEnum } from 'src/types/ui';
import { getMouse, getItemAtTile, generateId, incrementZoom, decrementZoom } from 'src/utils'; import { getMouse, getItemAtTile, generateId, incrementZoom, decrementZoom } from 'src/utils';
@@ -21,7 +21,6 @@ import { Lasso } from './modes/Lasso';
import { FreehandLasso } from './modes/FreehandLasso'; import { FreehandLasso } from './modes/FreehandLasso';
import { usePanHandlers } from './usePanHandlers'; import { usePanHandlers } from './usePanHandlers';
// Added some throttling in for the mouse updates, this was causing unnexessary re-renders - Stan
interface PendingMouseUpdate { interface PendingMouseUpdate {
mouse: Mouse; mouse: Mouse;
event: SlimMouseEvent; event: SlimMouseEvent;
@@ -36,7 +35,6 @@ const useRAFThrottle = () => {
pendingUpdateRef.current = { mouse, event }; pendingUpdateRef.current = { mouse, event };
callbackRef.current = callback; callbackRef.current = callback;
// Only schedule a new frame if one isn't already pending
if (rafIdRef.current === null) { if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null; rafIdRef.current = null;
@@ -49,7 +47,6 @@ const useRAFThrottle = () => {
}, []); }, []);
const flushUpdate = useCallback(() => { const flushUpdate = useCallback(() => {
// Immediately process pending update (for mousedown/mouseup)
if (rafIdRef.current !== null) { if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current); cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null; rafIdRef.current = null;
@@ -100,46 +97,42 @@ const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => {
export const useInteractionManager = () => { export const useInteractionManager = () => {
const rendererRef = useRef<HTMLElement | undefined>(undefined); const rendererRef = useRef<HTMLElement | undefined>(undefined);
const reducerTypeRef = useRef<string | undefined>(undefined); const reducerTypeRef = useRef<string | undefined>(undefined);
const uiState = useUiStateStore((state) => {
return state; const modeType = useUiStateStore((state) => state.mode.type);
}); const rendererEl = useUiStateStore((state) => state.rendererEl);
const model = useModelStore((state) => { const editorMode = useUiStateStore((state) => state.editorMode);
return state;
}); const uiStateApi = useUiStateStoreApi();
const modelStoreApi = useModelStoreApi();
const scene = useScene(); const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl); const { size: rendererSize } = useResizeObserver(rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory(); const { undo, redo, canUndo, canRedo } = useHistory();
const { createTextBox } = scene; const { createTextBox } = scene;
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers(); const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle(); const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
// Keyboard shortcuts for undo/redo
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// ESC key handling - should work even in input fields const uiState = uiStateApi.getState();
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
// Priority 1: Close ItemControls (node menus) if open
if (uiState.itemControls) { if (uiState.itemControls) {
uiState.actions.setItemControls(null); uiState.actions.setItemControls(null);
return; return;
} }
// Priority 2: Cancel in-progress connector
if (uiState.mode.type === 'CONNECTOR') { if (uiState.mode.type === 'CONNECTOR') {
const connectorMode = uiState.mode; const connectorMode = uiState.mode;
// Check if connection is in progress
const isConnectionInProgress = const isConnectionInProgress =
(uiState.connectorInteractionMode === 'click' && connectorMode.isConnecting) || (uiState.connectorInteractionMode === 'click' && connectorMode.isConnecting) ||
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null); (uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
if (isConnectionInProgress && connectorMode.id) { if (isConnectionInProgress && connectorMode.id) {
// Delete the temporary connector
scene.deleteConnector(connectorMode.id); scene.deleteConnector(connectorMode.id);
// Reset connector mode to initial state
uiState.actions.setMode({ uiState.actions.setMode({
type: 'CONNECTOR', type: 'CONNECTOR',
showCursor: true, showCursor: true,
@@ -153,13 +146,12 @@ export const useInteractionManager = () => {
return; return;
} }
// Don't handle shortcuts when typing in input fields
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.tagName === 'INPUT' || target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' || target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' || target.contentEditable === 'true' ||
target.closest('.ql-editor') // Quill editor target.closest('.ql-editor')
) { ) {
return; return;
} }
@@ -184,25 +176,20 @@ export const useInteractionManager = () => {
} }
} }
// Help dialog shortcut
if (e.key === 'F1') { if (e.key === 'F1') {
e.preventDefault(); e.preventDefault();
uiState.actions.setDialog(DialogTypeEnum.HELP); uiState.actions.setDialog(DialogTypeEnum.HELP);
} }
// Tool hotkeys
const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile]; const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile];
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
// Quick icon selection for selected node (when ItemControls is an ItemReference with type 'ITEM')
if (key === 'i' && uiState.itemControls && 'id' in uiState.itemControls && uiState.itemControls.type === 'ITEM') { if (key === 'i' && uiState.itemControls && 'id' in uiState.itemControls && uiState.itemControls.type === 'ITEM') {
e.preventDefault(); e.preventDefault();
// Trigger icon change mode
const event = new CustomEvent('quickIconChange'); const event = new CustomEvent('quickIconChange');
window.dispatchEvent(event); window.dispatchEvent(event);
} }
// Check if key matches any hotkey
if (hotkeyMapping.select && key === hotkeyMapping.select) { if (hotkeyMapping.select && key === hotkeyMapping.select) {
e.preventDefault(); e.preventDefault();
uiState.actions.setMode({ uiState.actions.setMode({
@@ -278,12 +265,15 @@ export const useInteractionManager = () => {
return () => { return () => {
return window.removeEventListener('keydown', handleKeyDown); return window.removeEventListener('keydown', handleKeyDown);
}; };
}, [undo, redo, canUndo, canRedo, uiState.hotkeyProfile, uiState.actions, createTextBox, uiState.mouse.position.tile, scene, uiState.itemControls, uiState.mode, uiState.connectorInteractionMode]); }, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene]);
const processMouseUpdate = useCallback( const processMouseUpdate = useCallback(
(nextMouse: Mouse, e: SlimMouseEvent) => { (nextMouse: Mouse, e: SlimMouseEvent) => {
if (!rendererRef.current) return; if (!rendererRef.current) return;
const uiState = uiStateApi.getState();
const model = modelStoreApi.getState();
const mode = modes[uiState.mode.type]; const mode = modes[uiState.mode.type];
const modeFunction = getModeFunction(mode, e); const modeFunction = getModeFunction(mode, e);
@@ -317,14 +307,13 @@ export const useInteractionManager = () => {
modeFunction(baseState); modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type; reducerTypeRef.current = uiState.mode.type;
}, },
[model, scene, uiState, rendererSize] [uiStateApi, modelStoreApi, scene, rendererSize]
); );
const onMouseEvent = useCallback( const onMouseEvent = useCallback(
(e: SlimMouseEvent) => { (e: SlimMouseEvent) => {
if (!rendererRef.current) return; if (!rendererRef.current) return;
// Check pan handlers first
if (e.type === 'mousedown' && handlePanMouseDown(e)) { if (e.type === 'mousedown' && handlePanMouseDown(e)) {
return; return;
} }
@@ -332,6 +321,8 @@ export const useInteractionManager = () => {
return; return;
} }
const uiState = uiStateApi.getState();
const nextMouse = getMouse({ const nextMouse = getMouse({
interactiveElement: rendererRef.current, interactiveElement: rendererRef.current,
zoom: uiState.zoom, zoom: uiState.zoom,
@@ -341,26 +332,24 @@ export const useInteractionManager = () => {
rendererSize rendererSize
}); });
// For mousedown and mouseup, process immediately for responsiveness
// For mousemove, throttle updates to align with renderer frame rate
if (e.type === 'mousemove') { if (e.type === 'mousemove') {
scheduleUpdate(nextMouse, e, (update) => { scheduleUpdate(nextMouse, e, (update) => {
processMouseUpdate(update.mouse, update.event); processMouseUpdate(update.mouse, update.event);
}); });
} else { } else {
// Flush any pending mousemove update before processing mousedown/mouseup
flushUpdate(); flushUpdate();
processMouseUpdate(nextMouse, e); processMouseUpdate(nextMouse, e);
} }
}, },
[uiState.zoom, uiState.scroll, uiState.mouse, rendererSize, handlePanMouseDown, handlePanMouseUp, scheduleUpdate, flushUpdate, processMouseUpdate] [uiStateApi, rendererSize, handlePanMouseDown, handlePanMouseUp, scheduleUpdate, flushUpdate, processMouseUpdate]
); );
const onContextMenu = useCallback( const onContextMenu = useCallback(
(e: SlimMouseEvent) => { (e: SlimMouseEvent) => {
e.preventDefault(); e.preventDefault();
// Don't show context menu if right-click pan is enabled const uiState = uiStateApi.getState();
if (uiState.panSettings.rightClickPan) { if (uiState.panSettings.rightClickPan) {
return; return;
} }
@@ -383,11 +372,11 @@ export const useInteractionManager = () => {
}); });
} }
}, },
[uiState.mouse, scene, uiState.actions, uiState.panSettings] [uiStateApi, scene]
); );
useEffect(() => { useEffect(() => {
if (uiState.mode.type === 'INTERACTIONS_DISABLED') return; if (modeType === 'INTERACTIONS_DISABLED') return;
const el = window; const el = window;
@@ -422,10 +411,10 @@ export const useInteractionManager = () => {
}; };
const onScroll = (e: WheelEvent) => { const onScroll = (e: WheelEvent) => {
const uiState = uiStateApi.getState();
const zoomToCursor = uiState.zoomSettings.zoomToCursor; const zoomToCursor = uiState.zoomSettings.zoomToCursor;
const oldZoom = uiState.zoom; const oldZoom = uiState.zoom;
// Calculate new zoom level
let newZoom: number; let newZoom: number;
if (e.deltaY > 0) { if (e.deltaY > 0) {
newZoom = decrementZoom(oldZoom); newZoom = decrementZoom(oldZoom);
@@ -433,34 +422,24 @@ export const useInteractionManager = () => {
newZoom = incrementZoom(oldZoom); newZoom = incrementZoom(oldZoom);
} }
// If zoom didn't change (at min/max), no need to adjust scroll
if (newZoom === oldZoom) { if (newZoom === oldZoom) {
return; return;
} }
if (zoomToCursor && rendererRef.current && rendererSize) { if (zoomToCursor && rendererRef.current && rendererSize) {
// Get mouse position relative to the renderer viewport
const rect = rendererRef.current.getBoundingClientRect(); const rect = rendererRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mouseY = e.clientY - rect.top;
// Calculate mouse position relative to viewport center
const mouseRelativeToCenterX = mouseX - rendererSize.width / 2; const mouseRelativeToCenterX = mouseX - rendererSize.width / 2;
const mouseRelativeToCenterY = mouseY - rendererSize.height / 2; const mouseRelativeToCenterY = mouseY - rendererSize.height / 2;
// The point under the cursor in world space (before zoom)
// World coordinates = (screen coordinates - scroll offset) / zoom
const worldX = (mouseRelativeToCenterX - uiState.scroll.position.x) / oldZoom; const worldX = (mouseRelativeToCenterX - uiState.scroll.position.x) / oldZoom;
const worldY = (mouseRelativeToCenterY - uiState.scroll.position.y) / oldZoom; const worldY = (mouseRelativeToCenterY - uiState.scroll.position.y) / oldZoom;
// After zooming, to keep the same world point under the cursor:
// screen coordinates = world coordinates * newZoom + scroll offset
// We want: mouseRelativeToCenterX = worldX * newZoom + newScrollX
// Therefore: newScrollX = mouseRelativeToCenterX - worldX * newZoom
const newScrollX = mouseRelativeToCenterX - worldX * newZoom; const newScrollX = mouseRelativeToCenterX - worldX * newZoom;
const newScrollY = mouseRelativeToCenterY - worldY * newZoom; const newScrollY = mouseRelativeToCenterY - worldY * newZoom;
// Apply zoom and adjusted scroll together
uiState.actions.setZoom(newZoom); uiState.actions.setZoom(newZoom);
uiState.actions.setScroll({ uiState.actions.setScroll({
position: { position: {
@@ -470,7 +449,6 @@ export const useInteractionManager = () => {
offset: uiState.scroll.offset offset: uiState.scroll.offset
}); });
} else { } else {
// Original behavior: zoom to center
uiState.actions.setZoom(newZoom); uiState.actions.setZoom(newZoom);
} }
}; };
@@ -482,7 +460,7 @@ export const useInteractionManager = () => {
el.addEventListener('touchstart', onTouchStart); el.addEventListener('touchstart', onTouchStart);
el.addEventListener('touchmove', onTouchMove); el.addEventListener('touchmove', onTouchMove);
el.addEventListener('touchend', onTouchEnd); el.addEventListener('touchend', onTouchEnd);
uiState.rendererEl?.addEventListener('wheel', onScroll, { passive: true }); rendererEl?.addEventListener('wheel', onScroll, { passive: true });
return () => { return () => {
el.removeEventListener('mousemove', onMouseEvent); el.removeEventListener('mousemove', onMouseEvent);
@@ -492,20 +470,17 @@ export const useInteractionManager = () => {
el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove); el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd); el.removeEventListener('touchend', onTouchEnd);
uiState.rendererEl?.removeEventListener('wheel', onScroll); rendererEl?.removeEventListener('wheel', onScroll);
cleanup(); // Cancel any pending RAF updates cleanup();
}; };
}, [ }, [
uiState.editorMode, editorMode,
modeType,
onMouseEvent, onMouseEvent,
uiState.mode.type,
onContextMenu, onContextMenu,
uiState.actions, rendererEl,
uiState.rendererEl,
uiState.zoom,
uiState.scroll,
uiState.zoomSettings,
rendererSize, rendererSize,
uiStateApi,
cleanup cleanup
]); ]);
@@ -1,100 +1,89 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore';
import { CoordsUtils, getItemAtTile } from 'src/utils'; import { CoordsUtils, getItemAtTile } from 'src/utils';
import { useScene } from 'src/hooks/useScene'; import { useScene } from 'src/hooks/useScene';
import { SlimMouseEvent } from 'src/types'; import { SlimMouseEvent } from 'src/types';
export const usePanHandlers = () => { export const usePanHandlers = () => {
const uiState = useUiStateStore((state) => state); const modeType = useUiStateStore((state) => state.mode.type);
const actions = useUiStateStore((state) => state.actions);
const panSettings = useUiStateStore((state) => state.panSettings);
const rendererEl = useUiStateStore((state) => state.rendererEl);
const mouseTile = useUiStateStore((state) => state.mouse.position.tile);
const uiStateApi = useUiStateStoreApi();
const scene = useScene(); const scene = useScene();
const isPanningRef = useRef(false); const isPanningRef = useRef(false);
const panMethodRef = useRef<string | null>(null); const panMethodRef = useRef<string | null>(null);
// Helper to start panning
const startPan = useCallback((method: string) => { const startPan = useCallback((method: string) => {
if (uiState.mode.type !== 'PAN') { if (modeType !== 'PAN') {
isPanningRef.current = true; isPanningRef.current = true;
panMethodRef.current = method; panMethodRef.current = method;
uiState.actions.setMode({ actions.setMode({
type: 'PAN', type: 'PAN',
showCursor: false showCursor: false
}); });
} }
}, [uiState.mode.type, uiState.actions]); }, [modeType, actions]);
// Helper to end panning
const endPan = useCallback(() => { const endPan = useCallback(() => {
if (isPanningRef.current) { if (isPanningRef.current) {
isPanningRef.current = false; isPanningRef.current = false;
panMethodRef.current = null; panMethodRef.current = null;
uiState.actions.setMode({ actions.setMode({
type: 'CURSOR', type: 'CURSOR',
showCursor: true, showCursor: true,
mousedownItem: null mousedownItem: null
}); });
} }
}, [uiState.actions]); }, [actions]);
// Check if click is on empty area
const isEmptyArea = useCallback((e: SlimMouseEvent): boolean => { const isEmptyArea = useCallback((e: SlimMouseEvent): boolean => {
if (!uiState.rendererEl || e.target !== uiState.rendererEl) return false; if (!rendererEl || e.target !== rendererEl) return false;
const itemAtTile = getItemAtTile({ const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile, tile: mouseTile,
scene scene
}); });
return !itemAtTile;
}, [uiState.rendererEl, uiState.mouse.position.tile, scene]);
// Enhanced mouse down handler return !itemAtTile;
}, [rendererEl, mouseTile, scene]);
const handleMouseDown = useCallback((e: SlimMouseEvent): boolean => { const handleMouseDown = useCallback((e: SlimMouseEvent): boolean => {
const panSettings = uiState.panSettings;
// Check for the specific button that was pressed and only handle that one
// This fixes the issue where enabling both middle and right click causes neither to work
// Middle click pan (button 1)
if (e.button === 1 && panSettings.middleClickPan) { if (e.button === 1 && panSettings.middleClickPan) {
e.preventDefault(); e.preventDefault();
startPan('middle'); startPan('middle');
return true; return true;
} }
// Right click pan (button 2)
if (e.button === 2 && panSettings.rightClickPan) { if (e.button === 2 && panSettings.rightClickPan) {
e.preventDefault(); e.preventDefault();
startPan('right'); startPan('right');
return true; return true;
} }
// Left button (0) with modifiers or empty area
if (e.button === 0) { if (e.button === 0) {
// Ctrl + click pan
if (panSettings.ctrlClickPan && e.ctrlKey) { if (panSettings.ctrlClickPan && e.ctrlKey) {
e.preventDefault(); e.preventDefault();
startPan('ctrl'); startPan('ctrl');
return true; return true;
} }
// Alt + click pan
if (panSettings.altClickPan && e.altKey) { if (panSettings.altClickPan && e.altKey) {
e.preventDefault(); e.preventDefault();
startPan('alt'); startPan('alt');
return true; return true;
} }
// Empty area click pan
if (panSettings.emptyAreaClickPan && isEmptyArea(e)) { if (panSettings.emptyAreaClickPan && isEmptyArea(e)) {
startPan('empty'); startPan('empty');
return true; return true;
} }
} }
return false;
}, [uiState.panSettings, startPan, isEmptyArea]);
// Enhanced mouse up handler return false;
}, [panSettings, startPan, isEmptyArea]);
const handleMouseUp = useCallback((e: SlimMouseEvent): boolean => { const handleMouseUp = useCallback((e: SlimMouseEvent): boolean => {
if (isPanningRef.current) { if (isPanningRef.current) {
endPan(); endPan();
@@ -103,10 +92,8 @@ export const usePanHandlers = () => {
return false; return false;
}, [endPan]); }, [endPan]);
// Keyboard pan handler
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in input fields
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.tagName === 'INPUT' || target.tagName === 'INPUT' ||
@@ -117,13 +104,13 @@ export const usePanHandlers = () => {
return; return;
} }
const panSettings = uiState.panSettings; const currentState = uiStateApi.getState();
const speed = panSettings.keyboardPanSpeed; const currentPanSettings = currentState.panSettings;
const speed = currentPanSettings.keyboardPanSpeed;
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
// Arrow keys if (currentPanSettings.arrowKeysPan) {
if (panSettings.arrowKeysPan) {
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
dy = speed; dy = speed;
e.preventDefault(); e.preventDefault();
@@ -139,8 +126,7 @@ export const usePanHandlers = () => {
} }
} }
// WASD keys if (currentPanSettings.wasdPan) {
if (panSettings.wasdPan) {
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if (key === 'w') { if (key === 'w') {
dy = speed; dy = speed;
@@ -157,8 +143,7 @@ export const usePanHandlers = () => {
} }
} }
// IJKL keys if (currentPanSettings.ijklPan) {
if (panSettings.ijklPan) {
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if (key === 'i') { if (key === 'i') {
dy = speed; dy = speed;
@@ -175,26 +160,26 @@ export const usePanHandlers = () => {
} }
} }
// Apply pan if any movement
if (dx !== 0 || dy !== 0) { if (dx !== 0 || dy !== 0) {
const currentScroll = currentState.scroll;
const newPosition = CoordsUtils.add( const newPosition = CoordsUtils.add(
uiState.scroll.position, currentScroll.position,
{ x: dx, y: dy } { x: dx, y: dy }
); );
uiState.actions.setScroll({ currentState.actions.setScroll({
position: newPosition, position: newPosition,
offset: uiState.scroll.offset offset: currentScroll.offset
}); });
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [uiState.panSettings, uiState.scroll, uiState.actions]); }, [uiStateApi]);
return { return {
handleMouseDown, handleMouseDown,
handleMouseUp, handleMouseUp,
isPanning: isPanningRef.current isPanning: isPanningRef.current
}; };
}; };
@@ -195,3 +195,13 @@ export function useModelStore<T>(
const value = useStore(store, selector, equalityFn); const value = useStore(store, selector, equalityFn);
return value; return value;
} }
export function useModelStoreApi() {
const store = useContext(ModelContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
return store;
}
@@ -192,3 +192,13 @@ export function useSceneStore<T>(
const value = useStore(store, selector, equalityFn); const value = useStore(store, selector, equalityFn);
return value; return value;
} }
export function useSceneStoreApi() {
const store = useContext(SceneContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
return store;
}
@@ -8,7 +8,7 @@ import {
} from 'src/utils'; } from 'src/utils';
import { UiStateStore } from 'src/types'; import { UiStateStore } from 'src/types';
import { INITIAL_UI_STATE } from 'src/config'; import { INITIAL_UI_STATE } from 'src/config';
import { DEFAULT_HOTKEY_PROFILE } from 'src/config/hotkeys'; import { DEFAULT_HOTKEY_PROFILE, HotkeyProfile } from 'src/config/hotkeys';
import { DEFAULT_PAN_SETTINGS } from 'src/config/panSettings'; import { DEFAULT_PAN_SETTINGS } from 'src/config/panSettings';
import { DEFAULT_ZOOM_SETTINGS } from 'src/config/zoomSettings'; import { DEFAULT_ZOOM_SETTINGS } from 'src/config/zoomSettings';
import { DEFAULT_LABEL_SETTINGS } from 'src/config/labelSettings'; import { DEFAULT_LABEL_SETTINGS } from 'src/config/labelSettings';
@@ -104,7 +104,7 @@ const initialState = () => {
setRendererEl: (el: HTMLDivElement) => { setRendererEl: (el: HTMLDivElement) => {
set({ rendererEl: el }); set({ rendererEl: el });
}, },
setHotkeyProfile: (hotkeyProfile: any) => { setHotkeyProfile: (hotkeyProfile: HotkeyProfile) => {
set({ hotkeyProfile }); set({ hotkeyProfile });
}, },
setPanSettings: (panSettings) => { setPanSettings: (panSettings) => {
@@ -154,14 +154,17 @@ export const UiStateProvider = ({ children }: ProviderProps) => {
); );
}; };
export function useUiStateStore<T>(selector: (state: UiStateStore) => T) { export function useUiStateStore<T>(
selector: (state: UiStateStore) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(UiStateContext); const store = useContext(UiStateContext);
if (store === null) { if (store === null) {
throw new Error('Missing provider in the tree'); throw new Error('Missing provider in the tree');
} }
const value = useStore(store, selector); const value = useStore(store, selector, equalityFn);
return value; return value;
} }