mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 15:09:03 -05:00
feat: implements group rendering (UI not implemented yet)
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
modulePaths: ['node_modules', '<rootDir>']
|
||||
};
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
102
src/index.tsx
102
src/index.tsx
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,6 @@ export const DragItems: InteractionReducer = {
|
||||
},
|
||||
mousedown: () => {},
|
||||
mouseup: (draftState) => {
|
||||
draftState.mode = { type: 'CURSOR', mousedownItems: null };
|
||||
draftState.mode = { type: 'CURSOR', mousedown: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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: () => {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
51
src/renderer/components/Group/Group.ts
Normal file
51
src/renderer/components/Group/Group.ts
Normal 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;
|
||||
};
|
||||
77
src/renderer/components/Group/useGroup.ts
Normal file
77
src/renderer/components/Group/useGroup.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
10
src/renderer/useGroupManager.ts
Normal file
10
src/renderer/useGroupManager.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 [
|
||||
|
||||
36
src/renderer/utils/tests/gridHelpers.test.ts
Normal file
36
src/renderer/utils/tests/gridHelpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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={{}} />;
|
||||
|
||||
2
src/tests/fixtures/scene.ts
vendored
2
src/tests/fixtures/scene.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { SceneInput } from 'src/validation/SceneSchema';
|
||||
import { SceneInput } from 'src/validation/SceneInput';
|
||||
|
||||
export const scene: SceneInput = {
|
||||
gridSize: {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
findInvalidNode,
|
||||
findInvalidConnector,
|
||||
findInvalidGroup
|
||||
} from '../SceneSchema';
|
||||
} from '../SceneInput';
|
||||
import { scene } from '../../tests/fixtures/scene';
|
||||
|
||||
describe('scene validation works correctly', () => {
|
||||
|
||||
Reference in New Issue
Block a user