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

View File

@@ -4,14 +4,12 @@ import { useModelStore } from 'src/stores/modelStore';
import { getItemById } from 'src/utils';
export const useModelItem = (id: string): ModelItem | null => {
const model = useModelStore((state) => {
return state;
});
const items = useModelStore((state) => state.items);
const modelItem = useMemo(() => {
const item = getItemById(model.items, id);
const item = getItemById(items, id);
return item ? item.value : null;
}, [id, model.items]);
}, [id, items]);
return modelItem;
};

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import {
ModelItem,
ViewItem,
@@ -7,8 +8,8 @@ import {
Rectangle
} from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStore } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore, useModelStoreApi } from 'src/stores/modelStore';
import { useSceneStore, useSceneStoreApi } from 'src/stores/sceneStore';
import * as reducers from 'src/stores/reducers';
import type { State } from 'src/stores/reducers/types';
import { getItemByIdOrThrow } from 'src/utils';
@@ -19,20 +20,35 @@ import {
} from 'src/config';
export const useScene = () => {
const model = useModelStore((state) => {
return state;
});
const scene = useSceneStore((state) => {
return state;
});
const currentViewId = useUiStateStore((state) => {
return state.view;
});
const { views, colors, icons, items, version, title, description } =
useModelStore(
(state) => ({
views: state.views,
colors: state.colors,
icons: state.icons,
items: state.items,
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 modelStoreApi = useModelStoreApi();
const sceneStoreApi = useSceneStoreApi();
const currentView = useMemo(() => {
// Handle case where view doesn't exist yet or stores aren't initialized
if (!model?.views || !currentViewId) {
if (!views || !currentViewId) {
return {
id: '',
name: 'Default View',
@@ -44,12 +60,10 @@ export const useScene = () => {
}
try {
return getItemByIdOrThrow(model.views, currentViewId).value;
return getItemByIdOrThrow(views, currentViewId).value;
} catch (error) {
// console.warn(`View "${currentViewId}" not found, using fallback`);
// Return first available view or empty view
return (
model.views[0] || {
views[0] || {
id: currentViewId,
name: 'Default View',
items: [],
@@ -59,19 +73,19 @@ export const useScene = () => {
}
);
}
}, [currentViewId, model?.views]);
}, [currentViewId, views]);
const items = useMemo(() => {
const itemsList = useMemo(() => {
return currentView.items ?? [];
}, [currentView.items]);
const colors = useMemo(() => {
return model?.colors ?? [];
}, [model?.colors]);
const colorsList = useMemo(() => {
return colors ?? [];
}, [colors]);
const connectors = useMemo(() => {
const connectorsList = useMemo(() => {
return (currentView.connectors ?? []).map((connector) => {
const sceneConnector = scene?.connectors?.[connector.id];
const sceneConnector = sceneConnectors?.[connector.id];
return {
...CONNECTOR_DEFAULTS,
@@ -79,9 +93,9 @@ export const useScene = () => {
...sceneConnector
};
});
}, [currentView.connectors, scene?.connectors]);
}, [currentView.connectors, sceneConnectors]);
const rectangles = useMemo(() => {
const rectanglesList = useMemo(() => {
return (currentView.rectangles ?? []).map((rectangle) => {
return {
...RECTANGLE_DEFAULTS,
@@ -90,9 +104,9 @@ export const useScene = () => {
});
}, [currentView.rectangles]);
const textBoxes = useMemo(() => {
const textBoxesList = useMemo(() => {
return (currentView.textBoxes ?? []).map((textBox) => {
const sceneTextBox = scene?.textBoxes?.[textBox.id];
const sceneTextBox = sceneTextBoxes?.[textBox.id];
return {
...TEXTBOX_DEFAULTS,
@@ -100,110 +114,84 @@ export const useScene = () => {
...sceneTextBox
};
});
}, [currentView.textBoxes, scene?.textBoxes]);
}, [currentView.textBoxes, sceneTextBoxes]);
const getState = useCallback(() => {
const getState = useCallback((): State => {
const model = modelStoreApi.getState();
const scene = sceneStoreApi.getState();
return {
model: {
version: model?.version ?? '',
title: model?.title ?? '',
description: model?.description,
colors: model?.colors ?? [],
icons: model?.icons ?? [],
items: model?.items ?? [],
views: model?.views ?? []
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 ?? {}
connectors: scene.connectors,
textBoxes: scene.textBoxes
}
};
}, [model, scene]);
}, [modelStoreApi, sceneStoreApi]);
const setState = useCallback(
(newState: State) => {
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
}
modelStoreApi.getState().actions.set(newState.model, true);
sceneStoreApi.getState().actions.set(newState.scene, true);
},
[model?.actions, scene?.actions]
[modelStoreApi, sceneStoreApi]
);
const saveToHistoryBeforeChange = useCallback(() => {
// Prevent multiple saves during grouped operations
if (transactionInProgress.current) {
return;
}
model?.actions?.saveToHistory?.();
scene?.actions?.saveToHistory?.();
}, [model?.actions, scene?.actions]);
modelStoreApi.getState().actions.saveToHistory();
sceneStoreApi.getState().actions.saveToHistory();
}, [modelStoreApi, sceneStoreApi]);
const createModelItem = useCallback(
(newModelItem: ModelItem) => {
if (!model?.actions || !scene?.actions) return getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
const newState = reducers.createModelItem(newModelItem, getState());
setState(newState);
return newState; // Return the new state for chaining
return newState;
},
[
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, saveToHistoryBeforeChange]
);
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,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, saveToHistoryBeforeChange]
);
const deleteModelItem = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions) return;
saveToHistoryBeforeChange();
const newState = reducers.deleteModelItem(id, getState());
setState(newState);
},
[
getState,
setState,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, saveToHistoryBeforeChange]
);
const createViewItem = useCallback(
(newViewItem: ViewItem, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
// Use provided state or get current state
const stateToUse = currentState || getState();
const newState = reducers.view({
@@ -214,19 +202,12 @@ export const useScene = () => {
setState(newState);
return newState;
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return getState();
if (!currentViewId) return getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
@@ -239,21 +220,14 @@ export const useScene = () => {
ctx: { viewId: currentViewId, state: stateToUse }
});
setState(newState);
return newState; // Return for chaining
return newState;
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const deleteViewItem = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -263,19 +237,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const createConnector = useCallback(
(newConnector: Connector) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -285,19 +252,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const updateConnector = useCallback(
(id: string, updates: Partial<Connector>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -307,19 +267,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const deleteConnector = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -329,19 +282,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const createTextBox = useCallback(
(newTextBox: TextBox) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -351,19 +297,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const updateTextBox = useCallback(
(id: string, updates: Partial<TextBox>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return currentState || getState();
if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
@@ -378,19 +317,12 @@ export const useScene = () => {
setState(newState);
return newState;
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const deleteTextBox = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -400,19 +332,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const createRectangle = useCallback(
(newRectangle: Rectangle) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -422,19 +347,12 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const updateRectangle = useCallback(
(id: string, updates: Partial<Rectangle>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return currentState || getState();
if (!currentViewId) return currentState || getState();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
@@ -449,19 +367,12 @@ export const useScene = () => {
setState(newState);
return newState;
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const deleteRectangle = useCallback(
(id: string) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
if (!currentViewId) return;
saveToHistoryBeforeChange();
const newState = reducers.view({
@@ -471,81 +382,52 @@ export const useScene = () => {
});
setState(newState);
},
[
getState,
setState,
currentViewId,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[getState, setState, currentViewId, saveToHistoryBeforeChange]
);
const transaction = useCallback(
(operations: () => void) => {
if (!model?.actions || !scene?.actions) return;
// Prevent nested transactions
if (transactionInProgress.current) {
operations();
return;
}
// Save state before transaction
saveToHistoryBeforeChange();
// Mark transaction as in progress
transactionInProgress.current = true;
try {
// Execute all operations without saving intermediate history
operations();
} finally {
// Always reset transaction state
transactionInProgress.current = false;
}
},
[saveToHistoryBeforeChange, model?.actions, scene?.actions]
[saveToHistoryBeforeChange]
);
const placeIcon = useCallback(
(params: { modelItem: ModelItem; viewItem: ViewItem }) => {
if (!model?.actions || !scene?.actions) return;
// Save history before the transaction
saveToHistoryBeforeChange();
// Mark transaction as in progress
transactionInProgress.current = true;
try {
// Create model item first and get the updated state
const stateAfterModelItem = createModelItem(params.modelItem);
// Create view item using the updated state
if (stateAfterModelItem) {
createViewItem(params.viewItem, stateAfterModelItem);
}
} finally {
// Always reset transaction state
transactionInProgress.current = false;
}
},
[
createModelItem,
createViewItem,
saveToHistoryBeforeChange,
model?.actions,
scene?.actions
]
[createModelItem, createViewItem, saveToHistoryBeforeChange]
);
return {
items,
connectors,
colors,
rectangles,
textBoxes,
items: itemsList,
connectors: connectorsList,
colors: colorsList,
rectangles: rectanglesList,
textBoxes: textBoxesList,
currentView,
createModelItem,
updateModelItem,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import { useModelStore } from 'src/stores/modelStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStoreApi } from 'src/stores/modelStore';
import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent, Mouse } from 'src/types';
import { DialogTypeEnum } from 'src/types/ui';
import { getMouse, getItemAtTile, generateId, incrementZoom, decrementZoom } from 'src/utils';
@@ -21,7 +21,6 @@ import { Lasso } from './modes/Lasso';
import { FreehandLasso } from './modes/FreehandLasso';
import { usePanHandlers } from './usePanHandlers';
// Added some throttling in for the mouse updates, this was causing unnexessary re-renders - Stan
interface PendingMouseUpdate {
mouse: Mouse;
event: SlimMouseEvent;
@@ -36,7 +35,6 @@ const useRAFThrottle = () => {
pendingUpdateRef.current = { mouse, event };
callbackRef.current = callback;
// Only schedule a new frame if one isn't already pending
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
@@ -49,7 +47,6 @@ const useRAFThrottle = () => {
}, []);
const flushUpdate = useCallback(() => {
// Immediately process pending update (for mousedown/mouseup)
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
@@ -100,46 +97,42 @@ const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => {
export const useInteractionManager = () => {
const rendererRef = useRef<HTMLElement | undefined>(undefined);
const reducerTypeRef = useRef<string | undefined>(undefined);
const uiState = useUiStateStore((state) => {
return state;
});
const model = useModelStore((state) => {
return state;
});
const modeType = useUiStateStore((state) => state.mode.type);
const rendererEl = useUiStateStore((state) => state.rendererEl);
const editorMode = useUiStateStore((state) => state.editorMode);
const uiStateApi = useUiStateStoreApi();
const modelStoreApi = useModelStoreApi();
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const { size: rendererSize } = useResizeObserver(rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory();
const { createTextBox } = scene;
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle();
// Keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// ESC key handling - should work even in input fields
const uiState = uiStateApi.getState();
if (e.key === 'Escape') {
e.preventDefault();
// Priority 1: Close ItemControls (node menus) if open
if (uiState.itemControls) {
uiState.actions.setItemControls(null);
return;
}
// Priority 2: Cancel in-progress connector
if (uiState.mode.type === 'CONNECTOR') {
const connectorMode = uiState.mode;
// Check if connection is in progress
const isConnectionInProgress =
(uiState.connectorInteractionMode === 'click' && connectorMode.isConnecting) ||
(uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null);
if (isConnectionInProgress && connectorMode.id) {
// Delete the temporary connector
scene.deleteConnector(connectorMode.id);
// Reset connector mode to initial state
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
@@ -153,13 +146,12 @@ export const useInteractionManager = () => {
return;
}
// 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
target.closest('.ql-editor')
) {
return;
}
@@ -184,25 +176,20 @@ export const useInteractionManager = () => {
}
}
// Help dialog shortcut
if (e.key === 'F1') {
e.preventDefault();
uiState.actions.setDialog(DialogTypeEnum.HELP);
}
// Tool hotkeys
const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile];
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') {
e.preventDefault();
// Trigger icon change mode
const event = new CustomEvent('quickIconChange');
window.dispatchEvent(event);
}
// Check if key matches any hotkey
if (hotkeyMapping.select && key === hotkeyMapping.select) {
e.preventDefault();
uiState.actions.setMode({
@@ -278,12 +265,15 @@ export const useInteractionManager = () => {
return () => {
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(
(nextMouse: Mouse, e: SlimMouseEvent) => {
if (!rendererRef.current) return;
const uiState = uiStateApi.getState();
const model = modelStoreApi.getState();
const mode = modes[uiState.mode.type];
const modeFunction = getModeFunction(mode, e);
@@ -317,14 +307,13 @@ export const useInteractionManager = () => {
modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type;
},
[model, scene, uiState, rendererSize]
[uiStateApi, modelStoreApi, scene, rendererSize]
);
const onMouseEvent = useCallback(
(e: SlimMouseEvent) => {
if (!rendererRef.current) return;
// Check pan handlers first
if (e.type === 'mousedown' && handlePanMouseDown(e)) {
return;
}
@@ -332,6 +321,8 @@ export const useInteractionManager = () => {
return;
}
const uiState = uiStateApi.getState();
const nextMouse = getMouse({
interactiveElement: rendererRef.current,
zoom: uiState.zoom,
@@ -341,26 +332,24 @@ export const useInteractionManager = () => {
rendererSize
});
// For mousedown and mouseup, process immediately for responsiveness
// For mousemove, throttle updates to align with renderer frame rate
if (e.type === 'mousemove') {
scheduleUpdate(nextMouse, e, (update) => {
processMouseUpdate(update.mouse, update.event);
});
} else {
// Flush any pending mousemove update before processing mousedown/mouseup
flushUpdate();
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(
(e: SlimMouseEvent) => {
e.preventDefault();
// Don't show context menu if right-click pan is enabled
const uiState = uiStateApi.getState();
if (uiState.panSettings.rightClickPan) {
return;
}
@@ -383,11 +372,11 @@ export const useInteractionManager = () => {
});
}
},
[uiState.mouse, scene, uiState.actions, uiState.panSettings]
[uiStateApi, scene]
);
useEffect(() => {
if (uiState.mode.type === 'INTERACTIONS_DISABLED') return;
if (modeType === 'INTERACTIONS_DISABLED') return;
const el = window;
@@ -422,10 +411,10 @@ export const useInteractionManager = () => {
};
const onScroll = (e: WheelEvent) => {
const uiState = uiStateApi.getState();
const zoomToCursor = uiState.zoomSettings.zoomToCursor;
const oldZoom = uiState.zoom;
// Calculate new zoom level
let newZoom: number;
if (e.deltaY > 0) {
newZoom = decrementZoom(oldZoom);
@@ -433,34 +422,24 @@ export const useInteractionManager = () => {
newZoom = incrementZoom(oldZoom);
}
// If zoom didn't change (at min/max), no need to adjust scroll
if (newZoom === oldZoom) {
return;
}
if (zoomToCursor && rendererRef.current && rendererSize) {
// Get mouse position relative to the renderer viewport
const rect = rendererRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate mouse position relative to viewport center
const mouseRelativeToCenterX = mouseX - rendererSize.width / 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 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 newScrollY = mouseRelativeToCenterY - worldY * newZoom;
// Apply zoom and adjusted scroll together
uiState.actions.setZoom(newZoom);
uiState.actions.setScroll({
position: {
@@ -470,7 +449,6 @@ export const useInteractionManager = () => {
offset: uiState.scroll.offset
});
} else {
// Original behavior: zoom to center
uiState.actions.setZoom(newZoom);
}
};
@@ -482,7 +460,7 @@ export const useInteractionManager = () => {
el.addEventListener('touchstart', onTouchStart);
el.addEventListener('touchmove', onTouchMove);
el.addEventListener('touchend', onTouchEnd);
uiState.rendererEl?.addEventListener('wheel', onScroll, { passive: true });
rendererEl?.addEventListener('wheel', onScroll, { passive: true });
return () => {
el.removeEventListener('mousemove', onMouseEvent);
@@ -492,20 +470,17 @@ export const useInteractionManager = () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
uiState.rendererEl?.removeEventListener('wheel', onScroll);
cleanup(); // Cancel any pending RAF updates
rendererEl?.removeEventListener('wheel', onScroll);
cleanup();
};
}, [
uiState.editorMode,
editorMode,
modeType,
onMouseEvent,
uiState.mode.type,
onContextMenu,
uiState.actions,
uiState.rendererEl,
uiState.zoom,
uiState.scroll,
uiState.zoomSettings,
rendererEl,
rendererSize,
uiStateApi,
cleanup
]);

View File

@@ -1,100 +1,89 @@
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 { useScene } from 'src/hooks/useScene';
import { SlimMouseEvent } from 'src/types';
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 isPanningRef = useRef(false);
const panMethodRef = useRef<string | null>(null);
// Helper to start panning
const startPan = useCallback((method: string) => {
if (uiState.mode.type !== 'PAN') {
if (modeType !== 'PAN') {
isPanningRef.current = true;
panMethodRef.current = method;
uiState.actions.setMode({
actions.setMode({
type: 'PAN',
showCursor: false
});
}
}, [uiState.mode.type, uiState.actions]);
}, [modeType, actions]);
// Helper to end panning
const endPan = useCallback(() => {
if (isPanningRef.current) {
isPanningRef.current = false;
panMethodRef.current = null;
uiState.actions.setMode({
actions.setMode({
type: 'CURSOR',
showCursor: true,
mousedownItem: null
});
}
}, [uiState.actions]);
}, [actions]);
// Check if click is on empty area
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({
tile: uiState.mouse.position.tile,
tile: mouseTile,
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 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) {
e.preventDefault();
startPan('middle');
return true;
}
// Right click pan (button 2)
if (e.button === 2 && panSettings.rightClickPan) {
e.preventDefault();
startPan('right');
return true;
}
// Left button (0) with modifiers or empty area
if (e.button === 0) {
// Ctrl + click pan
if (panSettings.ctrlClickPan && e.ctrlKey) {
e.preventDefault();
startPan('ctrl');
return true;
}
// Alt + click pan
if (panSettings.altClickPan && e.altKey) {
e.preventDefault();
startPan('alt');
return true;
}
// Empty area click pan
if (panSettings.emptyAreaClickPan && isEmptyArea(e)) {
startPan('empty');
return true;
}
}
return false;
}, [uiState.panSettings, startPan, isEmptyArea]);
// Enhanced mouse up handler
return false;
}, [panSettings, startPan, isEmptyArea]);
const handleMouseUp = useCallback((e: SlimMouseEvent): boolean => {
if (isPanningRef.current) {
endPan();
@@ -103,10 +92,8 @@ export const usePanHandlers = () => {
return false;
}, [endPan]);
// Keyboard pan handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in input fields
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
@@ -117,13 +104,13 @@ export const usePanHandlers = () => {
return;
}
const panSettings = uiState.panSettings;
const speed = panSettings.keyboardPanSpeed;
const currentState = uiStateApi.getState();
const currentPanSettings = currentState.panSettings;
const speed = currentPanSettings.keyboardPanSpeed;
let dx = 0;
let dy = 0;
// Arrow keys
if (panSettings.arrowKeysPan) {
if (currentPanSettings.arrowKeysPan) {
if (e.key === 'ArrowUp') {
dy = speed;
e.preventDefault();
@@ -139,8 +126,7 @@ export const usePanHandlers = () => {
}
}
// WASD keys
if (panSettings.wasdPan) {
if (currentPanSettings.wasdPan) {
const key = e.key.toLowerCase();
if (key === 'w') {
dy = speed;
@@ -157,8 +143,7 @@ export const usePanHandlers = () => {
}
}
// IJKL keys
if (panSettings.ijklPan) {
if (currentPanSettings.ijklPan) {
const key = e.key.toLowerCase();
if (key === 'i') {
dy = speed;
@@ -175,26 +160,26 @@ export const usePanHandlers = () => {
}
}
// Apply pan if any movement
if (dx !== 0 || dy !== 0) {
const currentScroll = currentState.scroll;
const newPosition = CoordsUtils.add(
uiState.scroll.position,
currentScroll.position,
{ x: dx, y: dy }
);
uiState.actions.setScroll({
currentState.actions.setScroll({
position: newPosition,
offset: uiState.scroll.offset
offset: currentScroll.offset
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [uiState.panSettings, uiState.scroll, uiState.actions]);
}, [uiStateApi]);
return {
handleMouseDown,
handleMouseUp,
isPanning: isPanningRef.current
};
};
};

View File

@@ -195,3 +195,13 @@ export function useModelStore<T>(
const value = useStore(store, selector, equalityFn);
return value;
}
export function useModelStoreApi() {
const store = useContext(ModelContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
return store;
}

View File

@@ -192,3 +192,13 @@ export function useSceneStore<T>(
const value = useStore(store, selector, equalityFn);
return value;
}
export function useSceneStoreApi() {
const store = useContext(SceneContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
return store;
}

View File

@@ -8,7 +8,7 @@ import {
} from 'src/utils';
import { UiStateStore } from 'src/types';
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_ZOOM_SETTINGS } from 'src/config/zoomSettings';
import { DEFAULT_LABEL_SETTINGS } from 'src/config/labelSettings';
@@ -104,7 +104,7 @@ const initialState = () => {
setRendererEl: (el: HTMLDivElement) => {
set({ rendererEl: el });
},
setHotkeyProfile: (hotkeyProfile: any) => {
setHotkeyProfile: (hotkeyProfile: HotkeyProfile) => {
set({ hotkeyProfile });
},
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);
if (store === null) {
throw new Error('Missing provider in the tree');
}
const value = useStore(store, selector);
const value = useStore(store, selector, equalityFn);
return value;
}