refactor: moves state to context

keeping state in context prevents shared state between multiple instances of isoflow
This commit is contained in:
Mark Mankarious
2023-08-07 15:52:17 +01:00
committed by GitHub
parent 7f12fbc052
commit ad347817ff
23 changed files with 279 additions and 193 deletions

View File

@@ -11,14 +11,14 @@ import {
GroupInput,
Scene
} from 'src/types';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useSceneStore, SceneProvider } from 'src/stores/sceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/components/Renderer/Renderer';
import { sceneToSceneInput } from 'src/utils';
import { LabelContainer } from 'src/components/Node/LabelContainer';
import { useWindowUtils } from 'src/hooks/useWindowUtils';
import { ItemControlsManager } from './components/ItemControls/ItemControlsManager';
import { useUiStateStore } from './stores/useUiStateStore';
import { UiStateProvider, useUiStateStore } from './stores/uiStateStore';
interface Props {
initialScene: SceneInput & {
@@ -30,12 +30,7 @@ interface Props {
height?: number | string;
}
const Isoflow = ({
initialScene,
width,
height = 500,
onSceneUpdated
}: Props) => {
const App = ({ initialScene, width, height = 500, onSceneUpdated }: Props) => {
useWindowUtils();
const sceneActions = useSceneStore((state) => {
return state.actions;
@@ -61,14 +56,8 @@ const Isoflow = ({
sceneActions.setScene(initialScene);
}, [initialScene, sceneActions]);
useSceneStore.subscribe((scene, prevScene) => {
if (!onSceneUpdated) return;
onSceneUpdated(sceneToSceneInput(scene), sceneToSceneInput(prevScene));
});
return (
<ThemeProvider theme={theme}>
<>
<GlobalStyles />
<Box
sx={{
@@ -82,6 +71,18 @@ const Isoflow = ({
{isToolbarVisible && <ItemControlsManager />}
<ToolMenu />
</Box>
</>
);
};
export const Isoflow = (props: Props) => {
return (
<ThemeProvider theme={theme}>
<SceneProvider>
<UiStateProvider>
<App {...props} />
</UiStateProvider>
</SceneProvider>
</ThemeProvider>
);
};

View File

@@ -5,7 +5,7 @@ import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { pathfinder, getBoundingBox, getBoundingBoxSize } from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
// connector: ConnectorI;

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Box } from '@mui/material';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { NodeContextMenu } from 'src/components/ContextMenu/NodeContextMenu';
import { EmptyTileContextMenu } from 'src/components/ContextMenu/EmptyTileContextMenu';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useSceneStore } from 'src/stores/sceneStore';
export const ContextMenuLayer = () => {
const contextMenu = useUiStateStore((state) => {

View File

@@ -3,7 +3,7 @@ import {
ArrowRightAlt as ConnectIcon,
Delete as DeleteIcon
} from '@mui/icons-material';
import { useNodeHooks } from 'src/stores/useSceneStore';
import { useNode } from 'src/hooks/useNode';
import { ContextMenu } from './components/ContextMenu';
import { ContextMenuItem } from './components/ContextMenuItem';
@@ -12,8 +12,7 @@ interface Props {
}
export const NodeContextMenu = ({ nodeId }: Props) => {
const { useGetNodeById } = useNodeHooks();
const node = useGetNodeById(nodeId);
const node = useNode(nodeId);
if (!node) return null;

View File

@@ -3,7 +3,7 @@ import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
onClick: () => void;

View File

@@ -3,7 +3,7 @@ import { Box, useTheme } from '@mui/material';
import gsap from 'gsap';
import { Coords, TileOriginEnum } from 'src/types';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
interface Props {

View File

@@ -5,7 +5,7 @@ import { Node, TileOriginEnum, Group as GroupI } from 'src/types';
import { getBoundingBox, getBoundingBoxSize } from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
nodes: Node[];

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { Card, useTheme } from '@mui/material';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { NodeControls } from './NodeControls/NodeControls';
import { ProjectControls } from './ProjectControls/ProjectControls';

View File

@@ -1,7 +1,8 @@
import React, { useState, useCallback } from 'react';
import { Tabs, Tab, Box } from '@mui/material';
import { Node } from 'src/types';
import { useSceneStore, useNodeHooks } from 'src/stores/useSceneStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useNode } from 'src/hooks/useNode';
import { ControlsContainer } from '../components/ControlsContainer';
import { Icons } from './IconSelection/IconSelection';
import { Header } from '../components/Header';
@@ -19,8 +20,7 @@ export const NodeControls = ({ nodeId }: Props) => {
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const { useGetNodeById } = useNodeHooks();
const node = useGetNodeById(nodeId);
const node = useNode(nodeId);
const onTabChanged = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);

View File

@@ -5,7 +5,7 @@ import { Size, Coords, TileOriginEnum, Node as NodeI } from 'src/types';
import { getProjectedTileSize, getColorVariant } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { LabelContainer } from './LabelContainer';
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Box } from '@mui/material';
import { Node as NodeI } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useInteractionManager } from 'src/interaction/useInteractionManager';
import { Grid } from 'src/components/Grid/Grid';
import { Cursor } from 'src/components/Cursor/Cursor';

View File

@@ -7,12 +7,9 @@ import {
NearMe as NearMeIcon,
CenterFocusStrong as CenterFocusStrongIcon
} from '@mui/icons-material';
import {
useUiStateStore,
MIN_ZOOM,
MAX_ZOOM
} from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
import { MAX_ZOOM, MIN_ZOOM } from 'src/config';
import { IconButton } from '../IconButton/IconButton';
export const ToolMenu = () => {

View File

@@ -15,3 +15,6 @@ export const NODE_DEFAULTS = {
labelHeight: 100,
color: DEFAULT_COLOR
};
export const ZOOM_INCREMENT = 0.2;
export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Size, Coords } from 'src/types';
import { getBoundingBox, getBoundingBoxSize, sortByPosition } from 'src/utils';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Coords, TileOriginEnum } from 'src/types';
import { getTilePosition as getTilePositionUtil } from 'src/utils';

16
src/hooks/useNode.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
export const useNode = (nodeId: string) => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
const node = useMemo(() => {
return nodes.find((n) => {
return n.id === nodeId;
});
}, [nodes, nodeId]);
return node;
};

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
export const useWindowUtils = () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { produce } from 'immer';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { CoordsUtils, screenToIso } from 'src/utils';
import { InteractionReducer, Mouse, State, Coords } from 'src/types';
import { DragItems } from './reducers/DragItems';

98
src/stores/sceneStore.tsx Normal file
View File

@@ -0,0 +1,98 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { v4 as uuid } from 'uuid';
import { produce } from 'immer';
import { NODE_DEFAULTS } from 'src/config';
import { Scene, SceneActions, Node, SceneItemTypeEnum } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import { sceneInputtoScene } from 'src/utils';
interface Actions {
actions: SceneActions;
}
type SceneStore = Scene & Actions;
const initialState = () => {
return createStore<SceneStore>((set, get) => {
return {
nodes: [],
connectors: [],
groups: [],
icons: [],
actions: {
setScene: (scene) => {
sceneInput.parse(scene);
const newScene = sceneInputtoScene(scene);
set(newScene);
},
updateNode: (id, updates) => {
const { nodes } = get();
const nodeIndex = nodes.findIndex((node) => {
return node.id === id;
});
if (nodeIndex === -1) {
return;
}
const newNodes = produce(nodes, (draftState) => {
draftState[nodeIndex] = { ...draftState[nodeIndex], ...updates };
});
set({ nodes: newNodes });
},
createNode: (position) => {
const { nodes, icons } = get();
const newNode: Node = {
...NODE_DEFAULTS,
id: uuid(),
type: SceneItemTypeEnum.NODE,
iconId: icons[0].id,
position,
isSelected: false
};
set({ nodes: [...nodes, newNode] });
}
}
};
});
};
const SceneContext = createContext<ReturnType<typeof initialState> | null>(
null
);
interface ProviderProps {
children: React.ReactNode;
}
// TODO: Typings below are pretty gnarly due to the way Zustand works.
// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061
export const SceneProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof initialState>>();
if (!storeRef.current) {
storeRef.current = initialState();
}
return (
<SceneContext.Provider value={storeRef.current}>
{children}
</SceneContext.Provider>
);
};
export function useSceneStore<T>(selector: (state: SceneStore) => T) {
const store = useContext(SceneContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
const value = useStore(store, selector);
return value;
}

105
src/stores/uiStateStore.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React, { createContext, useContext, useRef } from 'react';
import { createStore, useStore } from 'zustand';
import { CoordsUtils, incrementZoom, decrementZoom } from 'src/utils';
import { UiState, UiStateActions } from 'src/types';
interface Actions {
actions: UiStateActions;
}
type UiStateStore = UiState & Actions;
const initialState = () => {
return createStore<UiStateStore>((set, get) => {
return {
isToolbarVisible: true,
mode: {
type: 'CURSOR',
showCursor: true,
mousedown: null
},
mouse: {
position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() },
mousedown: null,
delta: null
},
itemControls: null,
contextMenu: null,
scroll: {
position: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
},
zoom: 1,
rendererSize: { width: 0, height: 0 },
actions: {
setMode: (mode) => {
set({ mode });
},
incrementZoom: () => {
const { zoom } = get();
set({ zoom: incrementZoom(zoom) });
},
decrementZoom: () => {
const { zoom } = get();
set({ zoom: decrementZoom(zoom) });
},
setZoom: (zoom) => {
set({ zoom });
},
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
setSidebar: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
},
setRendererSize: (rendererSize) => {
set({ rendererSize });
},
setToolbarVisibility: (visible) => {
set({ isToolbarVisible: visible });
}
}
};
});
};
const UiStateContext = createContext<ReturnType<typeof initialState> | null>(
null
);
interface ProviderProps {
children: React.ReactNode;
}
// TODO: Typings below are pretty gnarly due to the way Zustand works.
// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061
export const UiStateProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof initialState>>();
if (!storeRef.current) {
storeRef.current = initialState();
}
return (
<UiStateContext.Provider value={storeRef.current}>
{children}
</UiStateContext.Provider>
);
};
export function useUiStateStore<T>(selector: (state: UiStateStore) => T) {
const store = useContext(UiStateContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
const value = useStore(store, selector);
return value;
}

View File

@@ -1,77 +0,0 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import { produce } from 'immer';
import { NODE_DEFAULTS } from 'src/config';
import { Scene, SceneActions, Node, SceneItemTypeEnum } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import { sceneInputtoScene } from 'src/utils';
export type UseSceneStore = Scene & {
actions: SceneActions;
};
// TODO: Optimise lookup time by having a store of tile coords and what items they contain
export const useSceneStore = create<UseSceneStore>((set, get) => {
return {
nodes: [],
connectors: [],
groups: [],
icons: [],
actions: {
setScene: (scene) => {
sceneInput.parse(scene);
const newScene = sceneInputtoScene(scene);
set(newScene);
},
updateNode: (id, updates) => {
const { nodes } = get();
const nodeIndex = nodes.findIndex((node) => {
return node.id === id;
});
if (nodeIndex === -1) {
return;
}
const newNodes = produce(nodes, (draftState) => {
draftState[nodeIndex] = { ...draftState[nodeIndex], ...updates };
});
set({ nodes: newNodes });
},
createNode: (position) => {
const { nodes, icons } = get();
const newNode: Node = {
...NODE_DEFAULTS,
id: uuid(),
type: SceneItemTypeEnum.NODE,
iconId: icons[0].id,
position,
isSelected: false
};
set({ nodes: [...nodes, newNode] });
}
}
};
});
export const useNodeHooks = () => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
const useGetNodeById = useCallback(
(id: string) => {
return nodes.find((node) => {
return node.id === id;
});
},
[nodes]
);
return { useGetNodeById };
};

View File

@@ -1,72 +0,0 @@
import { create } from 'zustand';
import { clamp, roundToOneDecimalPlace, CoordsUtils } from 'src/utils';
import { UiState, UiStateActions } from 'src/types';
// TODO: Move into the defaults file
const ZOOM_INCREMENT = 0.2;
export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;
export type UseUiStateStore = UiState & {
actions: UiStateActions;
};
export const useUiStateStore = create<UseUiStateStore>((set, get) => {
return {
isToolbarVisible: true,
mode: {
type: 'CURSOR',
showCursor: true,
mousedown: null
},
mouse: {
position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() },
mousedown: null,
delta: null
},
itemControls: null,
contextMenu: null,
scroll: {
position: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
},
zoom: 1,
rendererSize: { width: 0, height: 0 },
actions: {
setMode: (mode) => {
set({ mode });
},
incrementZoom: () => {
const { zoom } = get();
const targetZoom = clamp(zoom + ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
set({ zoom: roundToOneDecimalPlace(targetZoom) });
},
decrementZoom: () => {
const { zoom } = get();
const targetZoom = clamp(zoom - ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
set({ zoom: roundToOneDecimalPlace(targetZoom) });
},
setZoom: (zoom) => {
set({ zoom });
},
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
setSidebar: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
},
setRendererSize: (rendererSize) => {
set({ rendererSize });
},
setToolbarVisibility: (visible) => {
set({ isToolbarVisible: visible });
}
}
};
});

View File

@@ -1,6 +1,12 @@
import { TILE_PROJECTION_MULTIPLIERS, UNPROJECTED_TILE_SIZE } from 'src/config';
import {
TILE_PROJECTION_MULTIPLIERS,
UNPROJECTED_TILE_SIZE,
ZOOM_INCREMENT,
MAX_ZOOM,
MIN_ZOOM
} from 'src/config';
import { Coords, TileOriginEnum, Node, Size, Scroll } from 'src/types';
import { CoordsUtils } from 'src/utils';
import { CoordsUtils, clamp, roundToOneDecimalPlace } from 'src/utils';
interface GetProjectedTileSize {
zoom: number;
@@ -194,3 +200,13 @@ export const filterNodesByTile = ({ tile, nodes }: GetNodesByTile): Node[] => {
return CoordsUtils.isEqual(node.position, tile);
});
};
export const incrementZoom = (zoom: number) => {
const newZoom = clamp(zoom + ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
return roundToOneDecimalPlace(newZoom);
};
export const decrementZoom = (zoom: number) => {
const newZoom = clamp(zoom - ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
return roundToOneDecimalPlace(newZoom);
};