mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-04-23 08:31:16 -04:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user