feat: implements group rendering (UI not implemented yet)

This commit is contained in:
Mark Mankarious
2023-07-26 13:04:27 +01:00
parent 75ed4b6547
commit d9daa68b0d
29 changed files with 733 additions and 341 deletions

View File

@@ -27,6 +27,7 @@
"consistent-return": [0],
"react/no-unused-prop-types": ["warn"],
"react/require-default-props": [0],
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState"] }]
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState"] }],
"arrow-body-style": ["error", "always"]
}
}

View File

@@ -2,4 +2,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
modulePaths: ['node_modules', '<rootDir>']
};

View File

@@ -3,7 +3,7 @@ import { ThemeProvider } from '@mui/material/styles';
import { Box } from '@mui/material';
import { theme } from 'src/styles/theme';
import { ToolMenu } from 'src/components/ToolMenu/ToolMenu';
import { SceneInput } from 'src/validation/SceneSchema';
import { SceneInput } from 'src/validation/SceneInput';
import { useSceneStore, Scene } from 'src/stores/useSceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/renderer/Renderer';
@@ -18,27 +18,31 @@ interface Props {
}
const InnerApp = React.memo(
({ height, width }: Pick<Props, 'height' | 'width'>) => (
<ThemeProvider theme={theme}>
<GlobalStyles />
<Box
sx={{
width: width ?? '100%',
height,
position: 'relative',
overflow: 'hidden'
}}
>
<Renderer />
<ItemControlsManager />
<ToolMenu />
</Box>
</ThemeProvider>
)
({ height, width }: Pick<Props, 'height' | 'width'>) => {
return (
<ThemeProvider theme={theme}>
<GlobalStyles />
<Box
sx={{
width: width ?? '100%',
height,
position: 'relative',
overflow: 'hidden'
}}
>
<Renderer />
<ItemControlsManager />
<ToolMenu />
</Box>
</ThemeProvider>
);
}
);
const App = ({ initialScene, width, height, onSceneUpdated }: Props) => {
const sceneActions = useSceneStore((state) => state.actions);
const sceneActions = useSceneStore((state) => {
return state.actions;
});
useEffect(() => {
const convertedInput = sceneInputtoScene(initialScene);
@@ -55,7 +59,9 @@ const App = ({ initialScene, width, height, onSceneUpdated }: Props) => {
};
const useIsoflow = () => {
const updateNode = useSceneStore((state) => state.actions.updateNode);
const updateNode = useSceneStore((state) => {
return state.actions.updateNode;
});
return {
updateNode

View File

@@ -33,7 +33,7 @@ export const ToolMenu = () => {
name="Select"
Icon={<NearMeIcon />}
onClick={() =>
uiStateStoreActions.setMode({ type: 'CURSOR', mousedownItems: null })
uiStateStoreActions.setMode({ type: 'CURSOR', mousedown: null })
}
size={theme.customVars.toolMenu.height}
isActive={mode.type === 'CURSOR'}

View File

@@ -1,8 +1,94 @@
// This is a development entry point for the app.
// It is not used in production or included in the build.
import React, { useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom/client';
import GlobalStyles from '@mui/material/GlobalStyles';
import Isoflow, { useIsoflow, SceneInput } from './App';
import { mockScene } from './mockData';
import type {
SceneInput,
IconInput,
GroupInput,
NodeInput
} from 'src/validation/SceneInput';
import Isoflow, { useIsoflow } from './App';
const icons: IconInput[] = [
{
id: 'block',
name: 'Block',
url: 'https://isoflow.io/static/assets/icons/networking/primitive.svg',
category: 'Networking'
},
{
id: 'pyramid',
name: 'Pyramid',
url: 'https://isoflow.io/static/assets/icons/networking/pyramid.svg',
category: 'Networking'
},
{
id: 'sphere',
name: 'Sphere',
url: 'https://isoflow.io/static/assets/icons/networking/sphere.svg',
category: 'Networking'
},
{
id: 'diamond',
name: 'Diamond',
url: 'https://isoflow.io/static/assets/icons/networking/diamond.svg',
category: 'Networking'
},
{
id: 'cube',
name: 'Cube',
url: 'https://isoflow.io/static/assets/icons/networking/cube.svg'
},
{
id: 'pyramid',
name: 'Pyramid',
url: 'https://isoflow.io/static/assets/icons/networking/pyramid.svg',
category: 'Generic'
},
{
id: 'sphere',
name: 'Sphere',
url: 'https://isoflow.io/static/assets/icons/networking/sphere.svg',
category: 'Generic'
},
{
id: 'diamond',
name: 'Diamond',
url: 'https://isoflow.io/static/assets/icons/networking/diamond.svg',
category: 'Generic'
}
];
const groups: GroupInput[] = [
{
id: 'Group1',
label: 'Group 1',
nodeIds: ['Node1', 'Node2']
}
];
const nodes: NodeInput[] = [
{
id: 'Node1',
label: 'Node 1',
iconId: 'block',
position: {
x: 0,
y: 0
}
},
{
id: 'Node2',
label: 'Node 2',
iconId: 'pyramid',
position: {
x: 3,
y: 0
}
}
];
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@@ -35,13 +121,23 @@ const DataLayer = () => {
}}
/>
<Isoflow
initialScene={mockScene}
initialScene={{
icons,
nodes,
connectors: [],
groups,
gridSize: {
width: 51,
height: 51
}
}}
height="100vh"
onSceneUpdated={onSceneUpdated}
/>
</>
);
};
root.render(
<React.StrictMode>
<DataLayer />

View File

@@ -9,32 +9,32 @@ export const Cursor: InteractionReducer = {
if (
draftState.mouse.delta === null ||
!draftState.mouse.delta.tile.isEqual(Coords.fromObject({ x: 0, y: 0 }))
) {
// User has moved tile since the last mousedown event
if (
draftState.mode.mousedownItems &&
draftState.mode.mousedownItems.nodes.length > 0
) {
// User's last mousedown action was on a scene item
draftState.mouse.delta?.tile.isEqual(Coords.zero())
)
return;
// User has moved tile since the last event
if (draftState.mode.mousedown) {
// User is in mousedown mode
if (draftState.mode.mousedown.items.nodes.length > 0) {
// User's last mousedown action was on a node
draftState.mode = {
type: 'DRAG_ITEMS',
items: draftState.mode.mousedownItems
items: draftState.mode.mousedown.items
};
return;
}
// WIP: Lasso selection
// if (draftState.mode.mousedownItems?.nodes.length === 0) {
// // User's last mousedown action was on an empty tile
// draftState.mode = {
// type: 'LASSO',
// selection: {
// startTile: draftState.mouse.position.tile,
// endTile: draftState.mouse.position.tile
// },
// isDragging: false
// };
// }
draftState.mode = {
type: 'LASSO',
selection: {
startTile: draftState.mode.mousedown.tile,
endTile: draftState.mouse.position.tile,
items: []
},
isDragging: false
};
}
},
mousedown: (draftState) => {
@@ -45,25 +45,30 @@ export const Cursor: InteractionReducer = {
sortedSceneItems: draftState.scene
});
draftState.mode.mousedownItems = itemsAtTile;
draftState.mode.mousedown = {
items: itemsAtTile,
tile: draftState.mouse.position.tile
};
},
mouseup: (draftState) => {
if (draftState.mode.type !== 'CURSOR') return;
draftState.scene.nodes = draftState.scene.nodes.map((node) => ({
...node,
isSelected: false
}));
draftState.scene.nodes = draftState.scene.nodes.map((node) => {
return {
...node,
isSelected: false
};
});
if (draftState.mode.mousedownItems !== null) {
if (draftState.mode.mousedown !== null) {
// User's last mousedown action was on a scene item
const mousedownNode = draftState.mode.mousedownItems.nodes[0];
const mousedownNode = draftState.mode.mousedown.items.nodes[0];
if (mousedownNode) {
// The user's last mousedown action was on a node
const nodeIndex = draftState.scene.nodes.findIndex(
(node) => node.id === mousedownNode.id
);
const nodeIndex = draftState.scene.nodes.findIndex((node) => {
return node.id === mousedownNode.id;
});
if (nodeIndex === -1) return;
@@ -73,7 +78,7 @@ export const Cursor: InteractionReducer = {
type: SidebarTypeEnum.SINGLE_NODE,
nodeId: draftState.scene.nodes[nodeIndex].id
};
draftState.mode.mousedownItems = null;
draftState.mode.mousedown = null;
return;
}
@@ -84,7 +89,7 @@ export const Cursor: InteractionReducer = {
position: draftState.mouse.position.tile
};
draftState.itemControls = null;
draftState.mode.mousedownItems = null;
draftState.mode.mousedown = null;
}
}
};

View File

@@ -25,6 +25,6 @@ export const DragItems: InteractionReducer = {
},
mousedown: () => {},
mouseup: (draftState) => {
draftState.mode = { type: 'CURSOR', mousedownItems: null };
draftState.mode = { type: 'CURSOR', mousedown: null };
}
};

View File

@@ -1,5 +1,8 @@
import { Coords } from 'src/utils/Coords';
import { isWithinBounds } from 'src/renderer/utils/gridHelpers';
import {
isWithinBounds,
getItemsByTileV2
} from 'src/renderer/utils/gridHelpers';
import { InteractionReducer } from '../types';
export const Lasso: InteractionReducer = {
@@ -7,35 +10,38 @@ export const Lasso: InteractionReducer = {
if (draftState.mode.type !== 'LASSO') return;
if (draftState.mouse.mousedown === null) return;
// User has moused down (they are in dragging mode)
// User is in mousedown mode
if (
draftState.mouse.delta === null ||
draftState.mouse.delta.tile.isEqual(Coords.zero())
)
return;
// User has moved tile since the last mousedown event
// User has moved tile since they moused down
if (!draftState.mode.isDragging) {
// User is creating the selection (not dragging)
const { mousedown } = draftState.mouse;
const items = draftState.scene.nodes.filter((node) => {
return node.position.isEqual(mousedown.tile);
});
// User is creating a selection
draftState.mode.selection = {
startTile: draftState.mouse.mousedown.tile,
endTile: draftState.mouse.position.tile
endTile: draftState.mouse.position.tile,
items
};
return;
}
if (draftState.mode.isDragging) {
// User is dragging the selection
draftState.mode.selection = {
startTile: draftState.mode.selection.startTile.add(
draftState.mouse.delta.tile
),
endTile: draftState.mode.selection.endTile.add(
draftState.mouse.delta.tile
)
};
// User is dragging an existing selection
draftState.mode.selection.startTile =
draftState.mode.selection.startTile.add(draftState.mouse.delta.tile);
draftState.mode.selection.endTile = draftState.mode.selection.endTile.add(
draftState.mouse.delta.tile
);
}
},
mousedown: (draftState) => {
@@ -50,7 +56,7 @@ export const Lasso: InteractionReducer = {
if (!isWithinSelection) {
draftState.mode = {
type: 'CURSOR',
mousedownItems: null
mousedown: null
};
return;
@@ -65,7 +71,7 @@ export const Lasso: InteractionReducer = {
draftState.mode = {
type: 'CURSOR',
mousedownItems: null
mousedown: null
};
},
mouseup: () => {}

View File

@@ -19,15 +19,33 @@ const reducers: { [k in string]: InteractionReducer } = {
export const useInteractionManager = () => {
const tool = useRef<paper.Tool>();
const mode = useUiStateStore((state) => state.mode);
const scroll = useUiStateStore((state) => state.scroll);
const mouse = useUiStateStore((state) => state.mouse);
const itemControls = useUiStateStore((state) => state.itemControls);
const contextMenu = useUiStateStore((state) => state.contextMenu);
const uiStateActions = useUiStateStore((state) => state.actions);
const scene = useSceneStore(({ nodes }) => ({ nodes }));
const gridSize = useSceneStore((state) => state.gridSize);
const sceneActions = useSceneStore((state) => state.actions);
const mode = useUiStateStore((state) => {
return state.mode;
});
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const mouse = useUiStateStore((state) => {
return state.mouse;
});
const itemControls = useUiStateStore((state) => {
return state.itemControls;
});
const contextMenu = useUiStateStore((state) => {
return state.contextMenu;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const scene = useSceneStore(({ nodes, groups }) => {
return { nodes, groups };
});
const gridSize = useSceneStore((state) => {
return state.gridSize;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const onMouseEvent = useCallback(
(
@@ -57,7 +75,9 @@ export const useInteractionManager = () => {
contextMenu,
itemControls
},
(draft) => reducerAction(draft)
(draft) => {
return reducerAction(draft);
}
);
uiStateActions.setMouse(nextMouse);
@@ -82,12 +102,15 @@ export const useInteractionManager = () => {
useEffect(() => {
tool.current = new Tool();
tool.current.onMouseMove = (ev: paper.ToolEvent) =>
onMouseEvent('mousemove', ev);
tool.current.onMouseDown = (ev: paper.ToolEvent) =>
onMouseEvent('mousedown', ev);
tool.current.onMouseUp = (ev: paper.ToolEvent) =>
onMouseEvent('mouseup', ev);
tool.current.onMouseMove = (ev: paper.ToolEvent) => {
return onMouseEvent('mousemove', ev);
};
tool.current.onMouseDown = (ev: paper.ToolEvent) => {
return onMouseEvent('mousedown', ev);
};
tool.current.onMouseUp = (ev: paper.ToolEvent) => {
return onMouseEvent('mouseup', ev);
};
return () => {
tool.current?.remove();

View File

@@ -1,78 +0,0 @@
import type {
SceneInput,
IconInput,
NodeInput
} from 'src/validation/SceneSchema';
export const icons: IconInput[] = [
{
id: 'block',
name: 'Block',
url: 'https://isoflow.io/static/assets/icons/networking/primitive.svg',
category: 'Networking'
},
{
id: 'pyramid',
name: 'Pyramid',
url: 'https://isoflow.io/static/assets/icons/networking/pyramid.svg',
category: 'Networking'
},
{
id: 'sphere',
name: 'Sphere',
url: 'https://isoflow.io/static/assets/icons/networking/sphere.svg',
category: 'Networking'
},
{
id: 'diamond',
name: 'Diamond',
url: 'https://isoflow.io/static/assets/icons/networking/diamond.svg',
category: 'Networking'
},
{
id: 'cube',
name: 'Cube',
url: 'https://isoflow.io/static/assets/icons/networking/cube.svg'
},
{
id: 'pyramid',
name: 'Pyramid',
url: 'https://isoflow.io/static/assets/icons/networking/pyramid.svg',
category: 'Generic'
},
{
id: 'sphere',
name: 'Sphere',
url: 'https://isoflow.io/static/assets/icons/networking/sphere.svg',
category: 'Generic'
},
{
id: 'diamond',
name: 'Diamond',
url: 'https://isoflow.io/static/assets/icons/networking/diamond.svg',
category: 'Generic'
}
];
export const nodes: NodeInput[] = [
{
id: 'Node1',
label: 'Node 1',
iconId: 'block',
position: {
x: 0,
y: 0
}
}
];
export const mockScene: SceneInput = {
icons,
nodes,
connectors: [],
groups: [],
gridSize: {
width: 51,
height: 51
}
};

View File

@@ -42,7 +42,9 @@ export const Initialiser = ({ children }: Props) => {
setIsReady(false);
if (rafId) cancelAnimationFrame(rafId);
Paper.projects.forEach((project) => project.remove());
Paper.projects.forEach((project) => {
return project.remove();
});
};
}, []);

View File

@@ -11,16 +11,29 @@ import { Node } from './components/Node/Node';
import { getTilePosition } from './utils/gridHelpers';
import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer';
import { Lasso } from './components/Lasso/Lasso';
import { Group } from './components/Group/Group';
const InitialisedRenderer = () => {
const renderer = useRenderer();
const [isReady, setIsReady] = useState(false);
const scene = useSceneStore(({ nodes }) => ({ nodes }));
const gridSize = useSceneStore((state) => state.gridSize);
const mode = useUiStateStore((state) => state.mode);
const zoom = useUiStateStore((state) => state.zoom);
const mouse = useUiStateStore((state) => state.mouse);
const scroll = useUiStateStore((state) => state.scroll);
const scene = useSceneStore(({ nodes, groups }) => {
return { nodes, groups };
});
const gridSize = useSceneStore((state) => {
return state.gridSize;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const mouse = useUiStateStore((state) => {
return state.mouse;
});
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const { activeLayer } = Paper.project;
useInteractionManager();
@@ -94,20 +107,33 @@ const InitialisedRenderer = () => {
endTile={mode.selection.endTile}
/>
)}
{scene.nodes.map((node) => (
<Node
key={node.id}
node={node}
parentContainer={renderer.nodeManager.container as paper.Group}
/>
))}
{scene.groups.map((group) => {
return (
<Group
key={group.id}
group={group}
parentContainer={renderer.groupManager.container as paper.Group}
/>
);
})}
{scene.nodes.map((node) => {
return (
<Node
key={node.id}
node={node}
parentContainer={renderer.nodeManager.container as paper.Group}
/>
);
})}
</>
);
};
export const Renderer = () => (
<Initialiser>
<InitialisedRenderer />
<ContextMenuLayer />
</Initialiser>
);
export const Renderer = () => {
return (
<Initialiser>
<InitialisedRenderer />
<ContextMenuLayer />
</Initialiser>
);
};

View File

@@ -6,8 +6,12 @@ import { EmptyTileContextMenu } from 'src/components/ContextMenu/EmptyTileContex
import { useSceneStore } from 'src/stores/useSceneStore';
export const ContextMenuLayer = () => {
const contextMenu = useUiStateStore((state) => state.contextMenu);
const sceneActions = useSceneStore((state) => state.actions);
const contextMenu = useUiStateStore((state) => {
return state.contextMenu;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
return (
<Box
@@ -25,7 +29,9 @@ export const ContextMenuLayer = () => {
{contextMenu?.type === 'EMPTY_TILE' && (
<EmptyTileContextMenu
key={contextMenu.position.toString()}
onAddNode={() => sceneActions.createNode(contextMenu.position)}
onAddNode={() => {
return sceneActions.createNode(contextMenu.position);
}}
position={contextMenu.position}
/>
)}

View File

@@ -0,0 +1,51 @@
import { useEffect, useMemo } from 'react';
import {
Group as GroupInterface,
useSceneStore,
Node
} from 'src/stores/useSceneStore';
import { DEFAULT_COLOR } from 'src/utils/defaults';
import { getColorVariant } from 'src/utils';
import { useGroup } from './useGroup';
interface GroupProps {
group: GroupInterface;
parentContainer: paper.Group;
}
export const Group = ({ group, parentContainer }: GroupProps) => {
const { init, setTiles, setColor } = useGroup();
const allNodes = useSceneStore((state) => {
return state.nodes;
});
const groupNodes = useMemo(() => {
const nodes = group.nodeIds.map((nodeId) => {
return allNodes.find((node) => {
return node.id === nodeId;
});
});
return nodes.filter((node) => {
return node !== undefined;
}) as Node[];
}, [allNodes, group.nodeIds]);
useEffect(() => {
const container = init();
parentContainer.addChild(container);
}, [init, parentContainer]);
useEffect(() => {
setTiles(
groupNodes.map((node) => {
return node.position;
})
);
}, [groupNodes, setTiles]);
useEffect(() => {
setColor(DEFAULT_COLOR);
}, [setColor]);
return null;
};

View File

@@ -0,0 +1,77 @@
import { useCallback, useRef } from 'react';
import { Group, Shape } from 'paper';
import { Coords } from 'src/utils/Coords';
import { PIXEL_UNIT, TILE_SIZE } from 'src/renderer/utils/constants';
import { getColorVariant } from 'src/utils';
import {
getBoundingBox,
sortByPosition,
getTileBounds
} from '../../utils/gridHelpers';
import { applyProjectionMatrix } from '../../utils/projection';
export const useGroup = () => {
// TODO: Make sure consistent naming for all containers among all scene components
const containerRef = useRef(new Group());
const pathRef = useRef<paper.Shape.Rectangle>();
const setColor = useCallback((color: string) => {
if (!pathRef.current) return;
const fillColor = getColorVariant(color, 'light', { alpha: 0.5 });
pathRef.current.set({ fillColor, strokeColor: color });
}, []);
const setTiles = useCallback((tiles: Coords[]) => {
if (!pathRef.current) return;
const corners = getBoundingBox(tiles, new Coords(1, 1));
if (corners === null) {
containerRef.current.removeChildren();
throw new Error('Group has no nodes');
}
const sorted = sortByPosition(corners);
const size = new Coords(
sorted.highX - sorted.lowX,
sorted.highY - sorted.lowY
);
pathRef.current.set({
position: [0, 0],
radius: PIXEL_UNIT * 17,
size: [
(size.x + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(size.y + 1) * (TILE_SIZE - PIXEL_UNIT * 3)
]
});
containerRef.current.set({
pivot: pathRef.current.bounds.bottomLeft,
position: getTileBounds(corners[3]).left
});
}, []);
// TODO: Do we really need init an init function on each component hook? Does any of them take arguments?
const init = useCallback(() => {
containerRef.current.removeChildren();
pathRef.current = new Shape.Rectangle({
strokeCap: 'round',
strokeWidth: PIXEL_UNIT
});
containerRef.current.addChild(pathRef.current);
applyProjectionMatrix(containerRef.current);
return containerRef.current;
}, []);
return {
init,
setTiles,
setColor
};
};

View File

@@ -1,5 +1,6 @@
import { useRef, useCallback } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import { TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
import {
@@ -17,6 +18,10 @@ export const useLasso = () => {
if (!shapeRef.current) return;
const boundingBox = getBoundingBox([startTile, endTile]);
// TODO: Enforce at least one node being passed to this getBoundingBox() to prevent null returns
if (!boundingBox) return;
const lassoStartTile = boundingBox[3];
const lassoScreenPosition = getTileBounds(lassoStartTile).left;
const sorted = sortByPosition(boundingBox);
@@ -56,6 +61,14 @@ export const useLasso = () => {
pivot: [0, 0]
});
gsap
.fromTo(
shapeRef.current,
{ dashOffset: 0 },
{ dashOffset: PIXEL_UNIT * 10, ease: 'none', duration: 0.25 }
)
.repeat(-1);
containerRef.current.addChild(shapeRef.current);
applyProjectionMatrix(containerRef.current);

View File

@@ -15,11 +15,11 @@ export const useNodeTile = () => {
tileRef.current.set({
fillColor: color,
strokeColor: getColorVariant(color, 'dark', 2)
strokeColor: getColorVariant(color, 'dark', { grade: 2 })
});
highlightRef.current.set({
strokeColor: getColorVariant(color, 'dark', 2)
strokeColor: getColorVariant(color, 'dark', { grade: 2 })
});
}, []);

View File

@@ -0,0 +1,10 @@
import { useRef } from 'react';
import { Group } from 'paper';
export const useGroupManager = () => {
const containerRef = useRef(new Group());
return {
container: containerRef.current
};
};

View File

@@ -5,6 +5,7 @@ import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useGrid } from './components/Grid/useGrid';
import { useNodeManager } from './useNodeManager';
import { useCursor } from './components/Cursor/useCursor';
import { useGroupManager } from './useGroupManager';
export const useRenderer = () => {
const container = useRef(new Group());
@@ -13,8 +14,11 @@ export const useRenderer = () => {
const lassoContainer = useRef(new Group());
const grid = useGrid();
const nodeManager = useNodeManager();
const groupManager = useGroupManager();
const cursor = useCursor();
const uiStateActions = useUiStateStore((state) => state.actions);
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const { setScroll } = uiStateActions;
const { init: initGrid } = grid;
@@ -30,6 +34,7 @@ export const useRenderer = () => {
const cursorContainer = initCursor();
innerContainer.current.addChild(gridContainer);
innerContainer.current.addChild(groupManager.container);
innerContainer.current.addChild(cursorContainer);
innerContainer.current.addChild(lassoContainer.current);
innerContainer.current.addChild(nodeManager.container);
@@ -62,6 +67,7 @@ export const useRenderer = () => {
zoomTo,
scrollTo,
nodeManager,
groupManager,
cursor,
lassoContainer
};

View File

@@ -2,7 +2,7 @@ import Paper from 'paper';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { Coords } from 'src/utils/Coords';
import { clamp } from 'src/utils';
import { SortedSceneItems } from 'src/stores/useSceneStore';
import { SortedSceneItems, Node } from 'src/stores/useSceneStore';
import { Scroll } from 'src/stores/useUiStateStore';
const halfW = PROJECTED_TILE_DIMENSIONS.x * 0.5;
@@ -40,8 +40,9 @@ export const getTileFromMouse = ({
);
};
export const getTilePosition = ({ x, y }: Coords) =>
new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
export const getTilePosition = ({ x, y }: Coords) => {
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
};
export const getTileBounds = (coords: Coords) => {
const position = getTilePosition(coords);
@@ -69,17 +70,29 @@ interface GetItemsByTile {
sortedSceneItems: SortedSceneItems;
}
// TODO: Acheive better performance with more granular functions e.g. getNodesByTile, or even getFirstNodeByTile
export const getItemsByTile = ({
tile,
sortedSceneItems
}: GetItemsByTile): SortedSceneItems => {
const nodes = sortedSceneItems.nodes.filter((node) =>
node.position.isEqual(tile)
);
}: GetItemsByTile): { nodes: Node[] } => {
const nodes = sortedSceneItems.nodes.filter((node) => {
return node.position.isEqual(tile);
});
return { nodes };
};
interface GetItemsByTileV2 {
tile: Coords;
sceneItems: Node[];
}
export const getItemsByTileV2 = ({ tile, sceneItems }: GetItemsByTileV2) => {
return sceneItems.filter((item) => {
return item.position.isEqual(tile);
});
};
interface CanvasCoordsToScreenCoords {
position: Coords;
scrollPosition: Coords;
@@ -137,11 +150,15 @@ export const getTileScreenPosition = ({
return onScreenPosition;
};
export const sortByPosition = (items: Coords[]) => {
const xSorted = [...items];
const ySorted = [...items];
xSorted.sort((a, b) => a.x - b.x);
ySorted.sort((a, b) => a.y - b.y);
export const sortByPosition = (tiles: Coords[]) => {
const xSorted = [...tiles];
const ySorted = [...tiles];
xSorted.sort((a, b) => {
return a.x - b.x;
});
ySorted.sort((a, b) => {
return a.y - b.y;
});
const highest = {
byX: xSorted[xSorted.length - 1],
@@ -166,6 +183,7 @@ export const sortByPosition = (items: Coords[]) => {
};
};
// Returns a complete set of tiles that form a grid area (takes in any number of tiles to use points to encapsulate)
export const getGridSubset = (tiles: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
@@ -186,10 +204,16 @@ export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
};
// Returns the four corners of a grid that encapsulates all tiles
// passed in (at least 1 tile needed)
export const getBoundingBox = (
tiles: Coords[],
offset: Coords = new Coords(0, 0)
) => {
if (tiles.length === 0) {
return null;
}
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
return [

View File

@@ -0,0 +1,36 @@
import { Coords } from 'src/utils/Coords';
import { getGridSubset, isWithinBounds } from '../gridHelpers';
jest.mock('paper', () => {
return {};
});
describe('Tests gridhelpers', () => {
test('getGridSubset() works correctly', () => {
const gridSubset = getGridSubset([new Coords(5, 5), new Coords(7, 7)]);
expect(gridSubset).toEqual([
new Coords(5, 5),
new Coords(5, 6),
new Coords(5, 7),
new Coords(6, 5),
new Coords(6, 6),
new Coords(6, 7),
new Coords(7, 5),
new Coords(7, 6),
new Coords(7, 7)
]);
});
test('isWithinBounds() works correctly', () => {
const BOUNDS = [new Coords(4, 4), new Coords(6, 6)];
const withinBounds = isWithinBounds(new Coords(5, 5), BOUNDS);
const onBorder = isWithinBounds(new Coords(4, 4), BOUNDS);
const outsideBounds = isWithinBounds(new Coords(3, 3), BOUNDS);
expect(withinBounds).toBe(true);
expect(onBorder).toBe(true);
expect(outsideBounds).toBe(false);
});
});

View File

@@ -3,11 +3,13 @@ import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import { produce } from 'immer';
import { NODE_DEFAULTS } from 'src/utils/defaults';
import { IconInput } from '../validation/SceneSchema';
import { IconInput } from '../validation/SceneInput';
import { Coords } from '../utils/Coords';
// TODO: Move all types into a types file for easier access and less mental load over where to look
export enum SceneItemTypeEnum {
NODE = 'NODE'
NODE = 'NODE',
GROUP = 'GROUP'
}
export interface Node {
@@ -21,6 +23,12 @@ export interface Node {
isSelected: boolean;
}
export interface Group {
type: SceneItemTypeEnum.GROUP;
id: string;
nodeIds: string[];
}
export type Icon = IconInput;
export interface SceneItem {
@@ -28,10 +36,14 @@ export interface SceneItem {
type: SceneItemTypeEnum;
}
// TODO: Is this needed, or do we just expost a getNodesFromTile() function?
export interface SortedSceneItems {
// TODO: Decide on whether to make a Map instead of an array for easier lookup
nodes: Node[];
groups: Group[];
}
// TODO: This typing is super confusing to work with
export type Scene = SortedSceneItems & {
icons: IconInput[];
gridSize: Coords;
@@ -48,52 +60,64 @@ export type UseSceneStore = Scene & {
actions: SceneActions;
};
export const useSceneStore = create<UseSceneStore>((set, get) => ({
nodes: [],
icons: [],
gridSize: new Coords(51, 51),
actions: {
set: (scene) => {
set(scene);
},
setItems: (items: SortedSceneItems) => {
set({ nodes: items.nodes });
},
updateNode: (id, updates) => {
const { nodes } = get();
const nodeIndex = nodes.findIndex((node) => node.id === id);
// 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: [],
groups: [],
icons: [],
gridSize: new Coords(51, 51),
actions: {
set: (scene) => {
set(scene);
},
setItems: (items: SortedSceneItems) => {
set({ nodes: items.nodes });
},
updateNode: (id, updates) => {
const { nodes } = get();
const nodeIndex = nodes.findIndex((node) => {
return node.id === id;
});
if (nodeIndex === -1) {
return;
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 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) => state.nodes);
const nodes = useSceneStore((state) => {
return state.nodes;
});
const useGetNodeById = useCallback(
(id: string) => nodes.find((node) => node.id === id),
(id: string) => {
return nodes.find((node) => {
return node.id === id;
});
},
[nodes]
);

View File

@@ -1,8 +1,9 @@
import { create } from 'zustand';
import { clamp, roundToOneDecimalPlace } from 'src/utils';
import { Coords } from 'src/utils/Coords';
import { SortedSceneItems, SceneItem } from 'src/stores/useSceneStore';
import { SortedSceneItems, SceneItem, Node } from 'src/stores/useSceneStore';
// TODO: Move into the defaults file
const ZOOM_INCREMENT = 0.2;
export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;
@@ -37,30 +38,35 @@ export interface Mouse {
} | null;
}
// TODO: Extract modes into own file for simplicity
export interface CursorMode {
type: 'CURSOR';
mousedownItems: SortedSceneItems | null;
mousedown: {
items: { nodes: Node[] };
tile: Coords;
} | null;
}
export interface PanMode {
type: 'PAN';
}
export interface CreateLassoMode {
export interface LassoMode {
type: 'LASSO'; // TODO: Put these into an enum
selection: {
startTile: Coords;
endTile: Coords;
items: Node[];
};
isDragging: boolean;
}
export interface DragItemsMode {
type: 'DRAG_ITEMS';
items: SortedSceneItems;
items: { nodes: Node[] };
}
export type Mode = CursorMode | PanMode | DragItemsMode | CreateLassoMode;
export type Mode = CursorMode | PanMode | DragItemsMode | LassoMode;
export type ContextMenu =
| SceneItem
@@ -98,48 +104,50 @@ export type UseUiStateStore = UiState & {
actions: UiStateActions;
};
export const useUiStateStore = create<UseUiStateStore>((set, get) => ({
mode: {
type: 'CURSOR',
mousedownItems: null
},
mouse: {
position: { screen: new Coords(0, 0), tile: new Coords(0, 0) },
mousedown: null,
delta: null
},
itemControls: null,
contextMenu: null,
scroll: {
position: new Coords(0, 0),
offset: new Coords(0, 0)
},
zoom: 1,
actions: {
setMode: (mode) => {
set({ mode });
export const useUiStateStore = create<UseUiStateStore>((set, get) => {
return {
mode: {
type: 'CURSOR',
mousedown: null
},
incrementZoom: () => {
const { zoom } = get();
const targetZoom = clamp(zoom + ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
set({ zoom: roundToOneDecimalPlace(targetZoom) });
mouse: {
position: { screen: new Coords(0, 0), tile: new Coords(0, 0) },
mousedown: null,
delta: null
},
decrementZoom: () => {
const { zoom } = get();
const targetZoom = clamp(zoom - ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM);
set({ zoom: roundToOneDecimalPlace(targetZoom) });
itemControls: null,
contextMenu: null,
scroll: {
position: new Coords(0, 0),
offset: new Coords(0, 0)
},
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
setSidebar: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
zoom: 1,
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) });
},
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
setSidebar: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
}
}
}
}));
};
});

View File

@@ -1,6 +1,6 @@
import React from "react";
import { GlobalStyles as MUIGlobalStyles } from "@mui/material";
import "react-quill/dist/quill.snow.css";
import React from 'react';
import { GlobalStyles as MUIGlobalStyles } from '@mui/material';
import 'react-quill/dist/quill.snow.css';
export const GlobalStyles = () => {
return <MUIGlobalStyles styles={{}} />;

View File

@@ -1,4 +1,4 @@
import { SceneInput } from 'src/validation/SceneSchema';
import { SceneInput } from 'src/validation/SceneInput';
export const scene: SceneInput = {
gridSize: {

View File

@@ -1,5 +1,8 @@
// TODO: Rename to config.ts
import { customVars } from '../styles/theme';
export const DEFAULT_COLOR = customVars.diagramPalette.blue;
export const GRID_DEFAULTS = {
size: {
x: 51,
@@ -10,5 +13,5 @@ export const GRID_DEFAULTS = {
export const NODE_DEFAULTS = {
label: '',
labelHeight: 100,
color: customVars.diagramPalette.blue
color: DEFAULT_COLOR
};

View File

@@ -1,12 +1,22 @@
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import chroma from 'chroma-js';
import type { NodeInput, SceneInput } from 'src/validation/SceneSchema';
import { Node, SceneItemTypeEnum, Scene } from 'src/stores/useSceneStore';
import type {
NodeInput,
SceneInput,
GroupInput
} from 'src/validation/SceneInput';
import {
Node,
Group,
SceneItemTypeEnum,
Scene
} from 'src/stores/useSceneStore';
import { NODE_DEFAULTS, GRID_DEFAULTS } from 'src/utils/defaults';
export const clamp = (num: number, min: number, max: number) =>
num <= min ? min : num >= max ? max : num; // eslint-disable-line no-nested-ternary
export const clamp = (num: number, min: number, max: number) => {
return num <= min ? min : num >= max ? max : num;
}; // eslint-disable-line no-nested-ternary
export const nonZeroCoords = (coords: Coords) => {
// For some reason, gsap doesn't like to tween x and y both to 0, so we force 0 to be just above 0.
@@ -18,8 +28,9 @@ export const nonZeroCoords = (coords: Coords) => {
return newCoords;
};
export const getRandom = (min: number, max: number) =>
Math.floor(Math.random() * (max - min) + min);
export const getRandom = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min) + min);
};
export const tweenPosition = (
item: paper.Item,
@@ -43,28 +54,44 @@ export const tweenPosition = (
});
};
export const roundToOneDecimalPlace = (num: number) =>
Math.round(num * 10) / 10;
export const roundToOneDecimalPlace = (num: number) => {
return Math.round(num * 10) / 10;
};
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
const node: Node = {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
color: nodeInput.color ?? NODE_DEFAULTS.color,
iconId: nodeInput.iconId,
position: Coords.fromObject(nodeInput.position),
isSelected: false,
type: SceneItemTypeEnum.NODE
isSelected: false
};
};
return node;
export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds
};
};
export const sceneInputtoScene = (sceneInput: SceneInput) => {
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const groups = sceneInput.groups.map((groupInput) => {
return groupInputToGroup(groupInput);
});
const scene = {
...sceneInput,
nodes: sceneInput.nodes.map((nodeInput) => nodeInputToNode(nodeInput)),
nodes,
groups,
icons: sceneInput.icons,
gridSize: sceneInput.gridSize
? new Coords(sceneInput.gridSize.width, sceneInput.gridSize.height)
@@ -75,14 +102,16 @@ export const sceneInputtoScene = (sceneInput: SceneInput) => {
};
export const sceneToSceneInput = (scene: Scene) => {
const nodes: SceneInput['nodes'] = scene.nodes.map((node) => ({
id: node.id,
position: node.position.toObject(),
label: node.label,
labelHeight: node.labelHeight,
color: node.color,
iconId: node.iconId
}));
const nodes: SceneInput['nodes'] = scene.nodes.map((node) => {
return {
id: node.id,
position: node.position.toObject(),
label: node.label,
labelHeight: node.labelHeight,
color: node.color,
iconId: node.iconId
};
});
const sceneInput: SceneInput = {
nodes,
@@ -95,23 +124,28 @@ export const sceneToSceneInput = (scene: Scene) => {
return sceneInput;
};
interface GetColorVariantOpts {
alpha?: number;
grade?: number;
}
export const getColorVariant = (
color: string,
variant: 'light' | 'dark',
grade?: number
{ alpha = 1, grade = 0 }: GetColorVariantOpts
) => {
switch (variant) {
case 'light':
return chroma(color)
.brighten(grade ?? 1)
.saturate(2)
.hex();
.alpha(alpha)
.css();
case 'dark':
return chroma(color)
.darken(grade ?? 1)
.saturate(2)
.hex();
.alpha(alpha)
.css();
default:
return color;
return chroma(color).alpha(alpha).css();
}
};

View File

@@ -1,3 +1,4 @@
// TODO: Split into individual files
import { z } from 'zod';
export const iconInput = z.object({
@@ -29,7 +30,7 @@ export const connectorInput = z.object({
export const groupInput = z.object({
id: z.string(),
label: z.string().nullable(),
nodes: z.array(z.string())
nodeIds: z.array(z.string())
});
export type IconInput = z.infer<typeof iconInput>;
@@ -37,30 +38,41 @@ export type NodeInput = z.infer<typeof nodeInput>;
export type ConnectorInput = z.infer<typeof connectorInput>;
export type GroupInput = z.infer<typeof groupInput>;
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) =>
nodes.find((node) => {
const validIcon = icons.find((icon) => node.iconId === icon.id);
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) => {
return nodes.find((node) => {
const validIcon = icons.find((icon) => {
return node.iconId === icon.id;
});
return !validIcon;
});
};
export const findInvalidConnector = (
connectors: ConnectorInput[],
nodes: NodeInput[]
) =>
connectors.find((con) => {
const fromNode = nodes.find((node) => con.from === node.id);
const toNode = nodes.find((node) => con.to === node.id);
) => {
return connectors.find((con) => {
const fromNode = nodes.find((node) => {
return con.from === node.id;
});
const toNode = nodes.find((node) => {
return con.to === node.id;
});
return Boolean(!fromNode || !toNode);
});
};
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) =>
groups.find((grp) =>
grp.nodes.find((grpNodeSchemaId) => {
const validNode = nodes.find((node) => node.id === grpNodeSchemaId);
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) => {
return groups.find((grp) => {
return grp.nodeIds.find((nodeId) => {
const validNode = nodes.find((node) => {
return node.id === nodeId;
});
return Boolean(!validNode);
})
);
});
});
};
export const sceneInput = z
.object({

View File

@@ -6,7 +6,7 @@ import {
findInvalidNode,
findInvalidConnector,
findInvalidGroup
} from '../SceneSchema';
} from '../SceneInput';
import { scene } from '../../tests/fixtures/scene';
describe('scene validation works correctly', () => {