diff --git a/packages/fossflow-lib/src/hooks/useModelItem.ts b/packages/fossflow-lib/src/hooks/useModelItem.ts index a9abbb8..5d4d52f 100644 --- a/packages/fossflow-lib/src/hooks/useModelItem.ts +++ b/packages/fossflow-lib/src/hooks/useModelItem.ts @@ -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; }; diff --git a/packages/fossflow-lib/src/hooks/useScene.ts b/packages/fossflow-lib/src/hooks/useScene.ts index 0ab880b..49f2887 100644 --- a/packages/fossflow-lib/src/hooks/useScene.ts +++ b/packages/fossflow-lib/src/hooks/useScene.ts @@ -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) => { - 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, 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) => { - 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, 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, 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, diff --git a/packages/fossflow-lib/src/interaction/useInteractionManager.ts b/packages/fossflow-lib/src/interaction/useInteractionManager.ts index 4a75382..5330b96 100644 --- a/packages/fossflow-lib/src/interaction/useInteractionManager.ts +++ b/packages/fossflow-lib/src/interaction/useInteractionManager.ts @@ -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(undefined); const reducerTypeRef = useRef(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 ]); diff --git a/packages/fossflow-lib/src/interaction/usePanHandlers.ts b/packages/fossflow-lib/src/interaction/usePanHandlers.ts index a6e5969..20a240a 100644 --- a/packages/fossflow-lib/src/interaction/usePanHandlers.ts +++ b/packages/fossflow-lib/src/interaction/usePanHandlers.ts @@ -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(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 }; -}; \ No newline at end of file +}; diff --git a/packages/fossflow-lib/src/stores/modelStore.tsx b/packages/fossflow-lib/src/stores/modelStore.tsx index 0068dfe..178d3d0 100644 --- a/packages/fossflow-lib/src/stores/modelStore.tsx +++ b/packages/fossflow-lib/src/stores/modelStore.tsx @@ -195,3 +195,13 @@ export function useModelStore( 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; +} diff --git a/packages/fossflow-lib/src/stores/sceneStore.tsx b/packages/fossflow-lib/src/stores/sceneStore.tsx index da699ed..fa95eb8 100644 --- a/packages/fossflow-lib/src/stores/sceneStore.tsx +++ b/packages/fossflow-lib/src/stores/sceneStore.tsx @@ -192,3 +192,13 @@ export function useSceneStore( 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; +} diff --git a/packages/fossflow-lib/src/stores/uiStateStore.tsx b/packages/fossflow-lib/src/stores/uiStateStore.tsx index 73631c8..8ea792c 100644 --- a/packages/fossflow-lib/src/stores/uiStateStore.tsx +++ b/packages/fossflow-lib/src/stores/uiStateStore.tsx @@ -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(selector: (state: UiStateStore) => T) { +export function useUiStateStore( + 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; }