mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-04-23 08:31:16 -04:00
refactor: integrates the renderer with react
Migrates away from using a standalone renderer and mobx for plumbing. Previously there was a lot of manual syncing needed between the React UI and the Renderer, which added a lot of overhead and complexity. Scene state is now held in a store (facilitated by Zustand). This acts as a single source of truth shared over the renderer and the UI, and both react to changes on the store.
This commit is contained in:
32
.eslintrc
Normal file
32
.eslintrc
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"react-hooks",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"airbnb",
|
||||
"airbnb-typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": 2,
|
||||
"import/prefer-default-export": [0],
|
||||
"react/function-component-definition": [0],
|
||||
"react/jsx-props-no-spreading": [0],
|
||||
"consistent-return": [0],
|
||||
"react/no-unused-prop-types": ["warn"],
|
||||
"react/require-default-props": [0],
|
||||
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState"] }]
|
||||
}
|
||||
}
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2
|
||||
}
|
||||
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.formatOnSave": true,
|
||||
"vs-code-prettier-eslint.prettierLast": false,
|
||||
}
|
||||
@@ -26,7 +26,7 @@ Migration to open-source: ██░░░░░░░░░
|
||||
- [x] Set up automated publishing to NPM registry
|
||||
- [ ] Migrate private JS project to public Typescript project
|
||||
- [x] Pan / Select / Zoom modes
|
||||
- [x] Display icons in sidebar
|
||||
- [x] Display icons in itemControls
|
||||
- [ ] Node controls
|
||||
- [ ] Group controls
|
||||
- [ ] Connector controls
|
||||
|
||||
4436
package-lock.json
generated
4436
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -14,7 +14,9 @@
|
||||
"start": "webpack serve --config ./webpack/dev.config.js",
|
||||
"dev": "nodemon --watch ./src/ -e ts,tsx --exec npm run build",
|
||||
"build": "webpack --config ./webpack/prod.config.js && tsc --declaration --emitDeclarationOnly",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"lint": "eslint ./src/**/*.{ts,tsx}",
|
||||
"lint:fix": "eslint --fix ./src/**/*.{ts,tsx}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
@@ -24,23 +26,34 @@
|
||||
"@types/deep-diff": "^1.0.2",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"@types/node": "^16.18.12",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@types/webpack-env": "^1.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"jsdom": "^21.1.1",
|
||||
"nodemon": "^2.0.22",
|
||||
"prettier": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"style-loader": "^3.3.3",
|
||||
"ts-jest": "^29.0.5",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "^4.9.5",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"typescript": "^5.1.6",
|
||||
"webpack": "^5.76.2",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"webpack-dev-server": "^4.13.1"
|
||||
@@ -55,12 +68,12 @@
|
||||
"cuid": "^3.0.0",
|
||||
"deep-diff": "^1.0.2",
|
||||
"gsap": "^3.11.4",
|
||||
"mobx": "^6.8.0",
|
||||
"mobx-react": "^7.6.0",
|
||||
"immer": "^10.0.2",
|
||||
"paper": "^0.12.17",
|
||||
"react-hook-form": "^7.43.2",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"uuid": "^9.0.0",
|
||||
"zod": "^3.20.6",
|
||||
"zustand": "^4.3.3"
|
||||
},
|
||||
@@ -68,12 +81,6 @@
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
|
||||
108
src/App.tsx
108
src/App.tsx
@@ -1,70 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import { theme } from "./theme";
|
||||
import { SideNav } from "./components/SideNav";
|
||||
import { Sidebar } from "./components/Sidebars";
|
||||
import { ToolMenu } from "./components/ToolMenu";
|
||||
import { ContextMenu } from "./components/ContextMenus";
|
||||
import { RendererContainer } from "./components/RendererContainer";
|
||||
import { SceneI } from "./validation/SceneSchema";
|
||||
import { ModeManagerProvider } from "./contexts/ModeManagerContext";
|
||||
import { useGlobalState } from "./hooks/useGlobalState";
|
||||
import { OnSceneChange } from "./types";
|
||||
import { GlobalStyles } from "./GlobalStyles";
|
||||
import React, { useEffect } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Box } from '@mui/material';
|
||||
import { theme } from 'src/styles/theme';
|
||||
import { ToolMenu } from 'src/components/ToolMenu';
|
||||
import { SceneInput } from 'src/validation/SceneSchema';
|
||||
import { useSceneStore, Scene } from 'src/stores/useSceneStore';
|
||||
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
||||
import { Renderer } from 'src/renderer/Renderer';
|
||||
import { nodeInputToNode } from 'src/utils';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { ItemControlsManager } from './components/ItemControls/ItemControlsManager';
|
||||
|
||||
interface Props {
|
||||
initialScene: SceneI;
|
||||
onSceneChange?: OnSceneChange;
|
||||
initialScene: SceneInput;
|
||||
width?: number | string;
|
||||
height: number | string;
|
||||
}
|
||||
|
||||
const InnerApp = React.memo(
|
||||
({ height, width }: Pick<Props, "height" | "width">) => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
<ModeManagerProvider>
|
||||
<Box
|
||||
sx={{
|
||||
width: width ?? "100%",
|
||||
height,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<RendererContainer />
|
||||
<ContextMenu />
|
||||
<Sidebar />
|
||||
<SideNav />
|
||||
<ToolMenu />
|
||||
</Box>
|
||||
</ModeManagerProvider>
|
||||
</ThemeProvider>
|
||||
({ 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>
|
||||
)
|
||||
);
|
||||
|
||||
const App = ({ initialScene, width, height }: Props) => {
|
||||
const sceneActions = useSceneStore((state) => state.actions);
|
||||
// const setOnSceneChange = useAppState((state) => state.setOnSceneChange);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!onSceneChange) return;
|
||||
|
||||
// setOnSceneChange(onSceneChange);
|
||||
// }, [setOnSceneChange, onSceneChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = initialScene.nodes.map((nodeInput) =>
|
||||
nodeInputToNode(nodeInput)
|
||||
);
|
||||
}
|
||||
);
|
||||
const App = observer(
|
||||
({ initialScene, width, height, onSceneChange }: Props) => {
|
||||
const setInitialScene = useGlobalState((state) => state.setInitialScene);
|
||||
const setOnSceneChange = useGlobalState((state) => state.setOnSceneChange);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSceneChange) return;
|
||||
sceneActions.set({ ...initialScene, nodes, gridSize: new Coords(51, 51) });
|
||||
}, [initialScene, sceneActions]);
|
||||
|
||||
setOnSceneChange(onSceneChange);
|
||||
}, [setOnSceneChange, onSceneChange]);
|
||||
return <InnerApp height={height} width={width} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setInitialScene(initialScene);
|
||||
}, [initialScene, setInitialScene]);
|
||||
|
||||
return <InnerApp height={height} width={width} />;
|
||||
}
|
||||
);
|
||||
|
||||
type Scene = SceneI;
|
||||
export { Scene, OnSceneChange };
|
||||
export { Scene };
|
||||
export default App;
|
||||
|
||||
16
src/components/ContextMenu/EmptyTileContextMenu.tsx
Normal file
16
src/components/ContextMenu/EmptyTileContextMenu.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Add as AddIcon } from '@mui/icons-material';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { ContextMenu } from './components/ContextMenu';
|
||||
import { ContextMenuItem } from './components/ContextMenuItem';
|
||||
|
||||
interface Props {
|
||||
onAddNode: () => void;
|
||||
position: Coords;
|
||||
}
|
||||
|
||||
export const EmptyTileContextMenu = ({ onAddNode, position }: Props) => (
|
||||
<ContextMenu position={position}>
|
||||
<ContextMenuItem onClick={onAddNode} icon={<AddIcon />} label="Add node" />
|
||||
</ContextMenu>
|
||||
);
|
||||
38
src/components/ContextMenu/NodeContextMenu.tsx
Normal file
38
src/components/ContextMenu/NodeContextMenu.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ArrowRightAlt as ConnectIcon,
|
||||
Delete as DeleteIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { ContextMenu } from './components/ContextMenu';
|
||||
import { ContextMenuItem } from './components/ContextMenuItem';
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export const NodeContextMenu = ({ nodeId }: Props) => {
|
||||
const sceneItems = useSceneStore(({ nodes }) => ({ nodes }));
|
||||
const getNodeById = useSceneStore((state) => state.actions.getNodeById);
|
||||
const node = useMemo(
|
||||
() => getNodeById(nodeId),
|
||||
[getNodeById, nodeId, sceneItems]
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<ContextMenu position={node.position}>
|
||||
<ContextMenuItem
|
||||
onClick={() => {}}
|
||||
icon={<ConnectIcon />}
|
||||
label="Connect"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
onClick={() => {}}
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
84
src/components/ContextMenu/components/ContextMenu.tsx
Normal file
84
src/components/ContextMenu/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { List, Box, Card } from '@mui/material';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { getTileScreenPosition } from 'src/renderer/utils/gridHelpers';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
position: Coords;
|
||||
}
|
||||
|
||||
const COLOR = 'grey.900';
|
||||
const ARROW = {
|
||||
size: 11,
|
||||
top: 8
|
||||
};
|
||||
|
||||
export const ContextMenu = ({ position, children }: Props) => {
|
||||
const [firstDisplay, setFirstDisplay] = useState(false);
|
||||
const container = useRef<HTMLDivElement>();
|
||||
const scroll = useUiStateStore((state) => state.scroll);
|
||||
const zoom = useUiStateStore((state) => state.zoom);
|
||||
const gridSize = useSceneStore((state) => state.gridSize);
|
||||
|
||||
const { position: scrollPosition } = scroll;
|
||||
|
||||
useEffect(() => {
|
||||
if (!container.current) return;
|
||||
|
||||
const screenPosition = getTileScreenPosition({
|
||||
position,
|
||||
scrollPosition,
|
||||
zoom
|
||||
});
|
||||
|
||||
gsap.to(container.current, {
|
||||
duration: firstDisplay ? 0.1 : 0,
|
||||
x: screenPosition.x,
|
||||
y: screenPosition.y
|
||||
});
|
||||
|
||||
if (firstDisplay) {
|
||||
// The context menu subtly slides in from the left when it is first displayed.
|
||||
gsap.to(container.current, {
|
||||
duration: 0.2,
|
||||
opacity: 1
|
||||
});
|
||||
}
|
||||
|
||||
setFirstDisplay(true);
|
||||
}, [position, scrollPosition, zoom, firstDisplay, gridSize]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={container}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
marginLeft: '15px',
|
||||
marginTop: '-20px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: -(ARROW.size - 2),
|
||||
top: ARROW.top,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderTop: `${ARROW.size}px solid transparent`,
|
||||
borderBottom: `${ARROW.size}px solid transparent`,
|
||||
borderRight: `${ARROW.size}px solid`,
|
||||
borderRightColor: COLOR
|
||||
}}
|
||||
/>
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<List sx={{ p: 0 }}>{children}</List>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
50
src/components/ContextMenu/components/ContextMenuItem.tsx
Normal file
50
src/components/ContextMenu/components/ContextMenuItem.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ContextMenuItem = ({ onClick, icon, label }: Props) => {
|
||||
const uiStateActions = useUiStateStore((state) => state.actions);
|
||||
|
||||
const onClickProxy = useCallback(() => {
|
||||
onClick();
|
||||
uiStateActions.setContextMenu(null);
|
||||
}, [onClick, uiStateActions]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
p: 0,
|
||||
'&:not(:last-child)': {
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'grey.800'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemButton onClick={onClickProxy} sx={{ py: 0.5, px: 2 }}>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
pr: 1,
|
||||
minWidth: 'auto',
|
||||
color: 'grey.400',
|
||||
svg: {
|
||||
maxWidth: 18,
|
||||
maxHeight: 18
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText secondary={label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react";
|
||||
import { List, Box, Card } from "@mui/material";
|
||||
import { keyframes } from "@emotion/react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
const COLOR = "grey.900";
|
||||
const ARROW = {
|
||||
size: 11,
|
||||
top: 8,
|
||||
};
|
||||
const ANIMATIONS = {
|
||||
in: keyframes`
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(15px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export const ContextMenu = ({ position, children }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: position.y - 20,
|
||||
left: position.x + ARROW.size * 2,
|
||||
animation: `${ANIMATIONS.in} 0.2s ease-in-out`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: -(ARROW.size - 2),
|
||||
top: ARROW.top,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderTop: `${ARROW.size}px solid transparent`,
|
||||
borderBottom: `${ARROW.size}px solid transparent`,
|
||||
borderRight: `${ARROW.size}px solid`,
|
||||
borderRightColor: COLOR,
|
||||
}}
|
||||
/>
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<List sx={{ p: 0 }}>{children}</List>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ContextMenuItem = ({ onClick, icon, label }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
p: 0,
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor: "grey.800",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemButton onClick={onClick} sx={{ py: 0.5, px: 2 }}>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
pr: 1,
|
||||
minWidth: "auto",
|
||||
color: "grey.400",
|
||||
svg: {
|
||||
maxWidth: 18,
|
||||
maxHeight: 18,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText secondary={label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { useGlobalState } from "../../hooks/useGlobalState";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { ContextMenuItem } from "./ContextMenuItem";
|
||||
import { Node } from "../../renderer/elements/Node";
|
||||
import { ArrowRightAlt, Delete } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export const NodeContextMenu = ({ node }: Props) => {
|
||||
const renderer = useGlobalState((state) => state.renderer);
|
||||
const position = renderer.getTileScreenPosition(node.position);
|
||||
|
||||
return (
|
||||
<ContextMenu position={position}>
|
||||
<ContextMenuItem
|
||||
onClick={() => {}}
|
||||
icon={<ArrowRightAlt />}
|
||||
label="Connect"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
onClick={node.destroy}
|
||||
icon={<Delete />}
|
||||
label="Remove"
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
import { useGlobalState } from "../../hooks/useGlobalState";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { ContextMenuItem } from "./ContextMenuItem";
|
||||
import { Coords } from "../../renderer/elements/Coords";
|
||||
import { Add } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
tile: Coords;
|
||||
}
|
||||
|
||||
export const TileContextMenu = ({ tile }: Props) => {
|
||||
const renderer = useGlobalState((state) => state.renderer);
|
||||
const icons = useGlobalState((state) => state.initialScene.icons);
|
||||
const position = renderer.getTileScreenPosition(tile);
|
||||
|
||||
return (
|
||||
<ContextMenu position={position}>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
renderer.sceneElements.nodes.addNode({
|
||||
position: tile,
|
||||
iconId: icons[0].id,
|
||||
})
|
||||
}
|
||||
icon={<Add />}
|
||||
label="Add node"
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
import { useGlobalState } from "../../hooks/useGlobalState";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { TileContextMenu } from "./TileContextMenu";
|
||||
import { Node } from "../../renderer/elements/Node";
|
||||
import { Coords } from "../../renderer/elements/Coords";
|
||||
|
||||
export const ContextMenu = () => {
|
||||
const targetElement = useGlobalState((state) => state.showContextMenuFor);
|
||||
|
||||
if (!targetElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetElement instanceof Node) {
|
||||
return <NodeContextMenu node={targetElement} key={targetElement.id} />;
|
||||
}
|
||||
|
||||
if (targetElement instanceof Coords) {
|
||||
return (
|
||||
<TileContextMenu
|
||||
tile={targetElement}
|
||||
key={JSON.stringify(targetElement)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
71
src/components/IconButton/IconButton.tsx
Normal file
71
src/components/IconButton/IconButton.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button, Box } from '@mui/material';
|
||||
import Tooltip, { TooltipProps } from '@mui/material/Tooltip';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
Icon: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
onClick: () => void;
|
||||
size: number;
|
||||
tooltipPosition?: TooltipProps['placement'];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton = ({
|
||||
name,
|
||||
Icon,
|
||||
onClick,
|
||||
isActive = false,
|
||||
size,
|
||||
disabled = false,
|
||||
tooltipPosition = 'bottom'
|
||||
}: Props) => {
|
||||
const iconColor = useMemo(() => {
|
||||
if (isActive) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return 'grey.800';
|
||||
}
|
||||
|
||||
return 'grey.500';
|
||||
}, [disabled, isActive]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={name}
|
||||
placement={tooltipPosition}
|
||||
enterDelay={1000}
|
||||
enterNextDelay={1000}
|
||||
arrow
|
||||
sx={{ bgcolor: 'primary.main' }}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
height: size,
|
||||
width: size,
|
||||
maxWidth: '100%',
|
||||
minWidth: 'auto',
|
||||
bgcolor: isActive ? 'grey.800' : undefined,
|
||||
p: 0,
|
||||
m: 0
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
svg: {
|
||||
color: iconColor
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Icon}
|
||||
</Box>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
50
src/components/ItemControls/ItemControlsManager.tsx
Normal file
50
src/components/ItemControls/ItemControlsManager.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useTheme } from '@mui/material';
|
||||
import Card from '@mui/material/Card';
|
||||
import Slide from '@mui/material/Slide';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { NodeControls } from './NodeControls/NodeControls';
|
||||
import { ProjectControls } from './ProjectControls/ProjectControls';
|
||||
|
||||
export const ItemControlsManager = () => {
|
||||
const theme = useTheme();
|
||||
const itemControls = useUiStateStore((state) => state.itemControls);
|
||||
const uiStateActions = useUiStateStore((state) => state.actions);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
uiStateActions.setSidebar(null);
|
||||
}, [uiStateActions]);
|
||||
|
||||
const Controls = useMemo(() => {
|
||||
switch (itemControls?.type) {
|
||||
case 'SINGLE_NODE':
|
||||
return <NodeControls onClose={onClose} />;
|
||||
case 'PROJECT_SETTINGS':
|
||||
return <ProjectControls onClose={onClose} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [itemControls, onClose]);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={itemControls !== null}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: '400px',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: theme.customVars.appPadding.x,
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
{Controls}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Button, Typography } from '@mui/material';
|
||||
import { Icon as IconInterface } from 'src/stores/useSceneStore';
|
||||
|
||||
interface Props {
|
||||
icon: IconInterface;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const Icon = ({ icon, onClick }: Props) => (
|
||||
<Button variant="text" onClick={onClick}>
|
||||
<Stack justifyContent="center" alignItems="center" sx={{ height: '100%' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={icon.url}
|
||||
alt={`Icon ${icon.name}`}
|
||||
sx={{ width: '100%', height: 80 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{icon.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Icon as IconInterface } from 'src/stores/useSceneStore';
|
||||
import { Icon } from './Icon';
|
||||
import { Section } from '../../components/Section';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
icons: IconInterface[];
|
||||
onClick: (icon: IconInterface) => void;
|
||||
}
|
||||
|
||||
export const IconCategory = ({ name, icons, onClick }: Props) => (
|
||||
<Section title={name}>
|
||||
<Grid container spacing={2}>
|
||||
{icons.map((icon) => (
|
||||
<Grid item xs={3} key={icon.id}>
|
||||
<Icon icon={icon} onClick={() => onClick(icon)} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
@@ -1,29 +1,28 @@
|
||||
import React from "react";
|
||||
import { useMemo } from "react";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { IconCategory } from "./IconCategory";
|
||||
import { IconI } from "../../../../validation/SceneSchema";
|
||||
import React, { useMemo } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Icon } from 'src/stores/useSceneStore';
|
||||
import { IconCategory } from './IconCategory';
|
||||
|
||||
interface Props {
|
||||
icons: IconI[];
|
||||
onClick: (icon: IconI) => void;
|
||||
icons: Icon[];
|
||||
onClick: (icon: Icon) => void;
|
||||
}
|
||||
|
||||
export const Icons = ({ icons, onClick }: Props) => {
|
||||
const categorisedIcons = useMemo(() => {
|
||||
const _categories: { name?: string; icons: IconI[] }[] = [];
|
||||
const cats: { name?: string; icons: Icon[] }[] = [];
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const category = _categories.find((cat) => cat.name === icon.category);
|
||||
const category = cats.find((cat) => cat.name === icon.category);
|
||||
|
||||
if (!category) {
|
||||
_categories.push({ name: icon.category, icons: [icon] });
|
||||
cats.push({ name: icon.category, icons: [icon] });
|
||||
} else {
|
||||
category.icons.push(icon);
|
||||
}
|
||||
});
|
||||
|
||||
return _categories.sort((a, b) => {
|
||||
return cats.sort((a, b) => {
|
||||
if (a.name === undefined) {
|
||||
return -1;
|
||||
}
|
||||
37
src/components/ItemControls/NodeControls/NodeControls.tsx
Normal file
37
src/components/ItemControls/NodeControls/NodeControls.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs, Tab, Box } from '@mui/material';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
import { Icons } from './IconSelection/IconSelection';
|
||||
import { Header } from '../components/Header';
|
||||
import { NodeSettings } from './NodeSettings/NodeSettings';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NodeControls = ({ onClose }: Props) => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const icons = useSceneStore((state) => state.icons);
|
||||
|
||||
const onTabChanged = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTab(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlsContainer
|
||||
header={
|
||||
<Box>
|
||||
<Header title="Node" onClose={onClose} />{' '}
|
||||
<Tabs value={tab} onChange={onTabChanged}>
|
||||
<Tab label="Settings" />
|
||||
<Tab label="Icons" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{tab === 0 && <NodeSettings />}
|
||||
{tab === 1 && <Icons icons={icons} onClick={() => {}} />}
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MarkdownEditor } from '../../../MarkdownEditor';
|
||||
import { Section } from '../../components/Section';
|
||||
|
||||
export const NodeSettings = () => {
|
||||
const [label, setLabel] = useState('');
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<MarkdownEditor value={label} onChange={setLabel} />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Header } from "../../Sidebar/Header";
|
||||
import { Section } from "../../Sidebar/Section";
|
||||
import { Sidebar } from "../../Sidebar";
|
||||
import React, { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Header } from '../components/Header';
|
||||
import { Section } from '../components/Section';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -16,29 +15,31 @@ interface Values {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const ProjectSettings = ({ onClose }: Props) => {
|
||||
export const ProjectControls = ({ onClose }: Props) => {
|
||||
const { register, handleSubmit } = useForm<Values>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
notes: "",
|
||||
},
|
||||
name: '',
|
||||
notes: ''
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = useCallback((values: Values) => {
|
||||
// console.log(values);
|
||||
console.log(values);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sidebar header={<Header title="Project settings" onClose={onClose} />}>
|
||||
<ControlsContainer
|
||||
header={<Header title="Project settings" onClose={onClose} />}
|
||||
>
|
||||
<Section>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12}>
|
||||
<TextField {...register("name")} label="Name" fullWidth />
|
||||
<TextField {...register('name')} label="Name" fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
{...register("notes")}
|
||||
{...register('notes')}
|
||||
label="Notes"
|
||||
variant="outlined"
|
||||
rows={12}
|
||||
@@ -49,6 +50,6 @@ export const ProjectSettings = ({ onClose }: Props) => {
|
||||
</Grid>
|
||||
</form>
|
||||
</Section>
|
||||
</Sidebar>
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
33
src/components/ItemControls/Transition.tsx
Normal file
33
src/components/ItemControls/Transition.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import Slide from '@mui/material/Slide';
|
||||
import Card from '@mui/material/Card';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactElement;
|
||||
isIn: boolean;
|
||||
}
|
||||
|
||||
export const Transition = ({ children, isIn }: Props) => (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={isIn}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{
|
||||
transitionDelay: isIn ? '150ms' : '0ms'
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
38
src/components/ItemControls/components/ControlsContainer.tsx
Normal file
38
src/components/ItemControls/components/ControlsContainer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ControlsContainer = ({ header, children }: Props) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', boxShadow: 6, zIndex: 1 }}>{header}</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowY: 'scroll',
|
||||
flexGrow: 1,
|
||||
'*::-webkit-scrollbar': {
|
||||
width: '0.4em'
|
||||
},
|
||||
'*::-webkit-scrollbar-track': {
|
||||
'-webkit-box-shadow': 'inset 0 0 6px rgba(0,0,0,0.00)'
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'rgba(0,0,0,.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', pb: 6 }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
31
src/components/ItemControls/components/Header.tsx
Normal file
31
src/components/ItemControls/components/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Button from '@mui/material/Button';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { Section } from './Section';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ title, onClose }: Props) => (
|
||||
<Section py={2}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={10}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Box>
|
||||
<Button variant="text" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
20
src/components/ItemControls/components/Section.tsx
Normal file
20
src/components/ItemControls/components/Section.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
py?: number;
|
||||
px?: number;
|
||||
}
|
||||
|
||||
export const Section = ({ children, py, px, title }: Props) => (
|
||||
<Box py={py ?? 3} px={px ?? 2}>
|
||||
<Stack>
|
||||
{title && <Typography fontWeight={600}>{title}</Typography>}
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import Tooltip, { TooltipProps } from "@mui/material/Tooltip";
|
||||
import { SvgIconComponent } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
Icon: SvgIconComponent;
|
||||
isActive?: boolean;
|
||||
onClick: () => void;
|
||||
size: number;
|
||||
tooltipPosition?: TooltipProps["placement"];
|
||||
}
|
||||
|
||||
export const MenuItem = ({
|
||||
name,
|
||||
Icon,
|
||||
onClick,
|
||||
isActive,
|
||||
size,
|
||||
tooltipPosition = "bottom",
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tooltip
|
||||
title={name}
|
||||
placement={tooltipPosition}
|
||||
enterDelay={1000}
|
||||
enterNextDelay={1000}
|
||||
arrow
|
||||
sx={{ bgcolor: "primary.main" }}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
height: size,
|
||||
width: size,
|
||||
maxWidth: "100%",
|
||||
minWidth: "auto",
|
||||
bgcolor: isActive ? "grey.800" : undefined,
|
||||
p: 0,
|
||||
m: 0,
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ color: isActive ? "grey.200" : "grey.500" }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { useRef, useEffect, useContext, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useGlobalState } from "../hooks/useGlobalState";
|
||||
import { modeManagerContext } from "../contexts/ModeManagerContext";
|
||||
import { Select } from "../modes/Select";
|
||||
import { Renderer } from "../renderer/Renderer";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
import {
|
||||
PROJECTED_TILE_WIDTH,
|
||||
PROJECTED_TILE_HEIGHT,
|
||||
} from "../renderer/constants";
|
||||
|
||||
const UI_OVERLAY_MARGIN = 300;
|
||||
|
||||
export const RendererContainer = observer(() => {
|
||||
const modeManager = useContext(modeManagerContext);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onSceneChange = useGlobalState((state) => state.onSceneChange);
|
||||
const initialScene = useGlobalState((state) => state.initialScene);
|
||||
const onRendererEvent = useGlobalState((state) => state.onRendererEvent);
|
||||
const setRenderer = useGlobalState((state) => state.setRenderer);
|
||||
const renderer = useGlobalState((state) => state.renderer);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (renderer) renderer.destroy();
|
||||
|
||||
const _renderer = new Renderer(containerRef.current);
|
||||
_renderer.setEventHandler(onRendererEvent);
|
||||
modeManager.setRenderer(_renderer);
|
||||
_renderer.loadScene(initialScene);
|
||||
modeManager.setEventEmitter(_renderer.callbacks.emitEvent);
|
||||
modeManager.activateMode(Select);
|
||||
setRenderer(_renderer);
|
||||
|
||||
return () => {
|
||||
_renderer.destroy();
|
||||
};
|
||||
}, [modeManager, onSceneChange, onRendererEvent, initialScene]);
|
||||
|
||||
const uiOverlayPosition = useMemo(() => {
|
||||
return {
|
||||
size: new Coords(
|
||||
renderer.sceneElements.grid.size.x * PROJECTED_TILE_WIDTH +
|
||||
UI_OVERLAY_MARGIN * 2,
|
||||
renderer.sceneElements.grid.size.y * PROJECTED_TILE_HEIGHT +
|
||||
UI_OVERLAY_MARGIN * 2
|
||||
),
|
||||
};
|
||||
}, [
|
||||
{ ...renderer.scroll.position },
|
||||
renderer.zoom,
|
||||
{ ...renderer.sceneElements.grid.size },
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { MenuItem } from "../MenuItem";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { useGlobalState } from "../../hooks/useGlobalState";
|
||||
|
||||
const menuItems = [
|
||||
{ type: "SINGLE_NODE", name: "Icons", Icon: AddIcon },
|
||||
{ type: "PROJECT_SETTINGS", name: "Project settings", Icon: SettingsIcon },
|
||||
];
|
||||
|
||||
export const SideNav = () => {
|
||||
const theme = useTheme();
|
||||
const sidebarState = useGlobalState((state) => state.sidebarState);
|
||||
const setSidebarState = useGlobalState((state) => state.setSidebarState);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: theme.customVars.sideNav,
|
||||
height: "100vh",
|
||||
bgcolor: "grey.900",
|
||||
}}
|
||||
>
|
||||
{/* {menuItems.map((item, index) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
isActive={item.type === sidebarState?.type}
|
||||
onClick={() => setSidebarState(index)}
|
||||
size={theme.customVars.sideNav.width}
|
||||
tooltipPosition="right"
|
||||
{...item}
|
||||
/>
|
||||
))} */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Button from "@mui/material/Button";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ title, onClose }: Props) => {
|
||||
return (
|
||||
<Section py={2}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={10}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Box>
|
||||
<Button variant="text" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Stack from "@mui/material/Stack";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
py?: number;
|
||||
px?: number;
|
||||
}
|
||||
|
||||
export const Section = ({ children, py, px, title }: Props) => {
|
||||
return (
|
||||
<Box py={py ?? 3} px={px ?? 2}>
|
||||
<Stack>
|
||||
{title && <Typography fontWeight={600}>{title}</Typography>}
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Sidebar = ({ header, children }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%", boxShadow: 6, zIndex: 1 }}>{header}</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
overflowY: "scroll",
|
||||
flexGrow: 1,
|
||||
"*::-webkit-scrollbar": {
|
||||
width: "0.4em",
|
||||
},
|
||||
"*::-webkit-scrollbar-track": {
|
||||
"-webkit-box-shadow": "inset 0 0 6px rgba(0,0,0,0.00)",
|
||||
},
|
||||
"*::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "rgba(0,0,0,.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%", pb: 6 }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Button, Typography } from "@mui/material";
|
||||
import { IconI } from "../../../../validation/SceneSchema";
|
||||
|
||||
interface Props {
|
||||
icon: IconI;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const Icon = ({ icon, onClick }: Props) => {
|
||||
return (
|
||||
<Button variant="text" onClick={onClick}>
|
||||
<Stack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={icon.url}
|
||||
alt={`Icon ${icon.name}`}
|
||||
sx={{ width: "100%", height: 80 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{icon.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { IconI } from "../../../../validation/SceneSchema";
|
||||
import { Icon } from "./Icon";
|
||||
import { Section } from "../../../Sidebar/Section";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
icons: IconI[];
|
||||
onClick: (icon: IconI) => void;
|
||||
}
|
||||
|
||||
export const IconCategory = ({ name, icons, onClick }: Props) => {
|
||||
return (
|
||||
<Section title={name}>
|
||||
<Grid container spacing={2}>
|
||||
{icons.map((icon) => (
|
||||
<Grid item xs={3} key={icon.id}>
|
||||
<Icon icon={icon} onClick={() => onClick(icon)} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Node } from "../../../renderer/elements/Node";
|
||||
import { MarkdownEditor } from "../../MarkdownEditor";
|
||||
import { Section } from "../../Sidebar/Section";
|
||||
|
||||
interface Props {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export const NodeSettings = ({ node }: Props) => {
|
||||
const [label, setLabel] = useState("");
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<MarkdownEditor value={label} onChange={setLabel} />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Sidebar } from "../../Sidebar";
|
||||
import { Icons } from "./Icons";
|
||||
import { Header } from "../../Sidebar/Header";
|
||||
import { Node } from "../../../renderer/elements/Node";
|
||||
import { Tabs, Tab, Box } from "@mui/material";
|
||||
import { useGlobalState } from "../../../hooks/useGlobalState";
|
||||
import { IconI } from "../../../validation/SceneSchema";
|
||||
import { NodeSettings } from "./NodeSettings";
|
||||
|
||||
interface Props {
|
||||
node: Node;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NodeSidebar = ({ node, onClose }: Props) => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const icons = useGlobalState((state) => state.initialScene.icons);
|
||||
|
||||
const onTabChanged = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTab(newValue);
|
||||
};
|
||||
|
||||
const onIconChanged = useCallback(
|
||||
(icon: IconI) => {
|
||||
node.update({ iconId: icon.id });
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
header={
|
||||
<Box>
|
||||
<Header title="Node" onClose={onClose} />{" "}
|
||||
<Tabs value={tab} onChange={onTabChanged}>
|
||||
<Tab label="Settings" />
|
||||
<Tab label="Icons" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{tab === 0 && <NodeSettings node={node} />}
|
||||
{tab === 1 && <Icons icons={icons} onClick={onIconChanged} />}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import Card from "@mui/material/Card";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactElement;
|
||||
isIn: boolean;
|
||||
}
|
||||
|
||||
export const Transition = ({ children, isIn }: Props) => {
|
||||
return (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={isIn}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{
|
||||
transitionDelay: isIn ? "150ms" : "0ms",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Card from "@mui/material/Card";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import { NodeSidebar } from "./NodeSidebar";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
import { useGlobalState } from "../../hooks/useGlobalState";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const theme = useTheme();
|
||||
const sidebarState = useGlobalState((state) => state.sidebarState);
|
||||
const closeSidebar = useGlobalState((state) => state.closeSidebar);
|
||||
const closeContextMenu = useGlobalState((state) => state.closeContextMenu);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
closeSidebar();
|
||||
closeContextMenu();
|
||||
}, [closeSidebar, closeContextMenu]);
|
||||
|
||||
const Component = useMemo(() => {
|
||||
switch (sidebarState?.type) {
|
||||
case "SINGLE_NODE":
|
||||
return <NodeSidebar node={sidebarState.node} onClose={onClose} />;
|
||||
case "PROJECT_SETTINGS":
|
||||
return <ProjectSettings onClose={onClose} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [sidebarState]);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={sidebarState !== null}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "400px",
|
||||
height: "100%",
|
||||
top: 0,
|
||||
left: theme.customVars.sideNav.width,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{Component}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +1,62 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useContext } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Card from "@mui/material/Card";
|
||||
import { MenuItem } from "../MenuItem";
|
||||
import PanToolIcon from "@mui/icons-material/PanTool";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
import NearMeIcon from "@mui/icons-material/NearMe";
|
||||
import { useZoom } from "../../hooks/useZoom";
|
||||
import { modeManagerContext } from "../../contexts/ModeManagerContext";
|
||||
import { Select } from "../../modes/Select";
|
||||
import { Pan } from "../../modes/Pan";
|
||||
import React from 'react';
|
||||
import { Card, useTheme } from '@mui/material';
|
||||
import {
|
||||
PanTool as PanToolIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
NearMe as NearMeIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
useUiStateStore,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
} from 'src/stores/useUiStateStore';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
|
||||
export const ToolMenu = observer(() => {
|
||||
const modeManager = useContext(modeManagerContext);
|
||||
export const ToolMenu = () => {
|
||||
const theme = useTheme();
|
||||
const { incrementZoom, decrementZoom } = useZoom();
|
||||
const zoom = useUiStateStore((state) => state.zoom);
|
||||
const mode = useUiStateStore((state) => state.mode);
|
||||
const uiStateStoreActions = useUiStateStore((state) => state.actions);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: theme.spacing(4),
|
||||
right: theme.spacing(4),
|
||||
height: theme.customVars.toolMenu.height,
|
||||
borderRadius: 2,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
<IconButton
|
||||
name="Select"
|
||||
Icon={NearMeIcon}
|
||||
onClick={() => modeManager.activateMode(Select)}
|
||||
Icon={<NearMeIcon />}
|
||||
onClick={() => uiStateStoreActions.setMode({ type: 'CURSOR' })}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
isActive={modeManager.currentMode?.instance instanceof Select}
|
||||
isActive={mode.type === 'CURSOR'}
|
||||
/>
|
||||
<MenuItem
|
||||
<IconButton
|
||||
name="Pan"
|
||||
Icon={PanToolIcon}
|
||||
onClick={() => modeManager.activateMode(Pan)}
|
||||
Icon={<PanToolIcon />}
|
||||
onClick={() => uiStateStoreActions.setMode({ type: 'PAN' })}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
isActive={modeManager.currentMode?.instance instanceof Pan}
|
||||
isActive={mode.type === 'PAN'}
|
||||
/>
|
||||
<MenuItem
|
||||
<IconButton
|
||||
name="Zoom in"
|
||||
Icon={ZoomInIcon}
|
||||
onClick={incrementZoom}
|
||||
Icon={<ZoomInIcon />}
|
||||
onClick={uiStateStoreActions.incrementZoom}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
disabled={zoom === MAX_ZOOM}
|
||||
/>
|
||||
<MenuItem
|
||||
<IconButton
|
||||
name="Zoom out"
|
||||
Icon={ZoomOutIcon}
|
||||
onClick={decrementZoom}
|
||||
Icon={<ZoomOutIcon />}
|
||||
onClick={uiStateStoreActions.decrementZoom}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
disabled={zoom === MIN_ZOOM}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import React, { createContext, useMemo } from "react";
|
||||
import { ModeManager } from "../modes/ModeManager";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const modeManagerContext = createContext(new ModeManager());
|
||||
|
||||
export const ModeManagerProvider = ({ children }: Props) => {
|
||||
return (
|
||||
<modeManagerContext.Provider value={new ModeManager()}>
|
||||
{children}
|
||||
</modeManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { SceneI } from "../validation/SceneSchema";
|
||||
import { Node } from "../renderer/elements/Node";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
import { Renderer } from "../renderer/Renderer";
|
||||
import { OnSceneChange, SceneEventI } from "../types";
|
||||
|
||||
type SidebarState =
|
||||
| {
|
||||
type: "SINGLE_NODE";
|
||||
node: Node;
|
||||
}
|
||||
| {
|
||||
type: "PROJECT_SETTINGS";
|
||||
};
|
||||
|
||||
interface GlobalState {
|
||||
showContextMenuFor: Node | Coords | null;
|
||||
onSceneChange: OnSceneChange;
|
||||
setOnSceneChange: (onSceneChange: OnSceneChange) => void;
|
||||
initialScene: SceneI;
|
||||
setInitialScene: (scene: SceneI) => void;
|
||||
setSelectedElements: (elements: Node[]) => void;
|
||||
setSidebarState: (state: SidebarState | null) => void;
|
||||
renderer: Renderer;
|
||||
selectedElements: Node[];
|
||||
setRenderer: (renderer: Renderer) => void;
|
||||
onRendererEvent: (event: SceneEventI) => void;
|
||||
sidebarState: SidebarState | null;
|
||||
closeSidebar: () => void;
|
||||
closeContextMenu: () => void;
|
||||
}
|
||||
|
||||
export const useGlobalState = create<GlobalState>((set, get) => ({
|
||||
showContextMenuFor: null,
|
||||
selectedElements: [],
|
||||
sidebarState: null,
|
||||
onSceneChange: () => {},
|
||||
setOnSceneChange: (onSceneChange) => set({ onSceneChange }),
|
||||
initialScene: {
|
||||
icons: [],
|
||||
nodes: [],
|
||||
connectors: [],
|
||||
groups: [],
|
||||
},
|
||||
closeContextMenu: () => {
|
||||
set({ showContextMenuFor: null });
|
||||
},
|
||||
setInitialScene: (scene) => {
|
||||
set({ initialScene: scene });
|
||||
},
|
||||
setSelectedElements: (elements: Node[]) => {
|
||||
const { renderer } = get();
|
||||
|
||||
renderer.unfocusAll();
|
||||
elements.forEach((element) => {
|
||||
element.setFocus(true);
|
||||
});
|
||||
set({ selectedElements: elements });
|
||||
},
|
||||
setSidebarState: (val) => {
|
||||
set({ sidebarState: val });
|
||||
},
|
||||
closeSidebar: () => {
|
||||
const { setSidebarState, setSelectedElements } = get();
|
||||
|
||||
setSidebarState(null);
|
||||
setSelectedElements([]);
|
||||
},
|
||||
renderer: new Renderer(document.createElement("div")),
|
||||
onRendererEvent: (event) => {
|
||||
const { setSelectedElements, renderer } = get();
|
||||
|
||||
switch (event.type) {
|
||||
case "TILE_SELECTED":
|
||||
setSelectedElements([]);
|
||||
set({ showContextMenuFor: event.data.tile });
|
||||
break;
|
||||
case "NODES_SELECTED":
|
||||
setSelectedElements(event.data.nodes);
|
||||
|
||||
if (event.data.nodes.length === 1) {
|
||||
const node = event.data.nodes[0];
|
||||
|
||||
set({
|
||||
showContextMenuFor: event.data.nodes[0],
|
||||
sidebarState: { type: "SINGLE_NODE", node: event.data.nodes[0] },
|
||||
});
|
||||
renderer.scrollToTile(node.position);
|
||||
}
|
||||
break;
|
||||
case "NODE_REMOVED":
|
||||
setSelectedElements([]);
|
||||
set({
|
||||
showContextMenuFor: null,
|
||||
sidebarState: null,
|
||||
});
|
||||
break;
|
||||
case "NODE_MOVED":
|
||||
setSelectedElements([]);
|
||||
set({ showContextMenuFor: null });
|
||||
break;
|
||||
case "ZOOM_CHANGED":
|
||||
setSelectedElements([]);
|
||||
set({ showContextMenuFor: null });
|
||||
break;
|
||||
case "MULTISELECT_UPDATED":
|
||||
setSelectedElements(event.data.itemsSelected);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
setRenderer: (renderer) => {
|
||||
set({ renderer });
|
||||
},
|
||||
}));
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { clamp } from "../utils";
|
||||
import { useGlobalState } from "../hooks/useGlobalState";
|
||||
|
||||
const ZOOM_INCREMENT = 0.2;
|
||||
const MIN_ZOOM = 0.2;
|
||||
const MAX_ZOOM = 1;
|
||||
|
||||
export const useZoom = () => {
|
||||
const renderer = useGlobalState((state) => state.renderer);
|
||||
|
||||
const incrementZoom = useCallback(() => {
|
||||
const targetZoom = clamp(
|
||||
renderer.zoom + ZOOM_INCREMENT,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
);
|
||||
renderer.setZoom(targetZoom);
|
||||
|
||||
return { zoom: targetZoom };
|
||||
}, [renderer]);
|
||||
|
||||
const decrementZoom = useCallback(() => {
|
||||
const targetZoom = clamp(
|
||||
renderer.zoom - ZOOM_INCREMENT,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
);
|
||||
renderer.setZoom(targetZoom);
|
||||
|
||||
return { zoom: targetZoom };
|
||||
}, [renderer]);
|
||||
|
||||
return {
|
||||
incrementZoom,
|
||||
decrementZoom,
|
||||
};
|
||||
};
|
||||
@@ -1,35 +1,31 @@
|
||||
import React, { useCallback } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||
import { mockScene } from "./mockData";
|
||||
import { OnSceneChange } from "./types";
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import App from './App';
|
||||
import { mockScene } from './mockData';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
const DataLayer = () => {
|
||||
const onSceneChange = useCallback<OnSceneChange>(() => {}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
margin: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<App
|
||||
initialScene={mockScene}
|
||||
onSceneChange={onSceneChange}
|
||||
height="100vh"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const DataLayer = () => (
|
||||
// const onSceneChange = useCallback<OnSceneChange>(() => {}, []);
|
||||
|
||||
<>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
margin: 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<App
|
||||
initialScene={mockScene}
|
||||
// onSceneChange={onSceneChange}
|
||||
height="100vh"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<DataLayer />
|
||||
|
||||
30
src/interaction/reducers/Cursor.ts
Normal file
30
src/interaction/reducers/Cursor.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { InteractionReducer } from '../types';
|
||||
import { getItemsByTile } from '../../renderer/utils/gridHelpers';
|
||||
|
||||
export const Cursor: InteractionReducer = {
|
||||
mousemove: () => {},
|
||||
mousedown: (draftState) => {
|
||||
const itemsAtTile = getItemsByTile({
|
||||
tile: draftState.mouse.tile,
|
||||
sceneItems: draftState.scene
|
||||
});
|
||||
|
||||
if (itemsAtTile.nodes.length > 0) {
|
||||
draftState.mode = {
|
||||
type: 'DRAG_ITEMS',
|
||||
items: itemsAtTile,
|
||||
hasMovedTile: false
|
||||
};
|
||||
} else {
|
||||
draftState.scene.nodes = draftState.scene.nodes.map((node) => ({
|
||||
...node,
|
||||
isSelected: false
|
||||
}));
|
||||
draftState.contextMenu = {
|
||||
type: 'EMPTY_TILE',
|
||||
position: draftState.mouse.tile
|
||||
};
|
||||
}
|
||||
},
|
||||
mouseup: () => {}
|
||||
};
|
||||
56
src/interaction/reducers/DragItems.ts
Normal file
56
src/interaction/reducers/DragItems.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { getItemsByTile } from 'src/renderer/utils/gridHelpers';
|
||||
import { SidebarTypeEnum } from 'src/stores/useUiStateStore';
|
||||
import { InteractionReducer } from '../types';
|
||||
|
||||
export const DragItems: InteractionReducer = {
|
||||
mousemove: (draftState, { prevMouse }) => {
|
||||
if (draftState.mode.type !== 'DRAG_ITEMS') return;
|
||||
|
||||
if (!prevMouse.tile.isEqual(draftState.mouse.tile)) {
|
||||
draftState.mode.items.nodes.forEach((node) => {
|
||||
const nodeIndex = draftState.scene.nodes.findIndex(
|
||||
(sceneNode) => sceneNode.id === node.id
|
||||
);
|
||||
|
||||
if (nodeIndex === -1) return;
|
||||
|
||||
draftState.scene.nodes[nodeIndex].position = draftState.mouse.tile;
|
||||
draftState.contextMenu = null;
|
||||
});
|
||||
|
||||
draftState.mode.hasMovedTile = true;
|
||||
}
|
||||
},
|
||||
mousedown: () => {},
|
||||
mouseup: (draftState) => {
|
||||
if (draftState.mode.type !== 'DRAG_ITEMS') return;
|
||||
|
||||
if (!draftState.mode.hasMovedTile) {
|
||||
// Set the item to a selected state if the item has been clicked in place,
|
||||
// but not dragged
|
||||
const itemsAtTile = getItemsByTile({
|
||||
tile: draftState.mouse.tile,
|
||||
sceneItems: draftState.scene
|
||||
});
|
||||
|
||||
if (itemsAtTile.nodes.length > 0) {
|
||||
const firstNode = itemsAtTile.nodes[0];
|
||||
|
||||
const nodeIndex = draftState.scene.nodes.findIndex(
|
||||
(sceneNode) => sceneNode.id === firstNode.id
|
||||
);
|
||||
|
||||
if (nodeIndex === -1) return;
|
||||
|
||||
draftState.scene.nodes[nodeIndex].isSelected = true;
|
||||
draftState.contextMenu = draftState.scene.nodes[nodeIndex];
|
||||
draftState.itemControls = {
|
||||
type: SidebarTypeEnum.SINGLE_NODE,
|
||||
nodeId: draftState.scene.nodes[nodeIndex].id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
draftState.mode = { type: 'CURSOR' };
|
||||
}
|
||||
};
|
||||
13
src/interaction/reducers/Pan.ts
Normal file
13
src/interaction/reducers/Pan.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { InteractionReducer } from '../types';
|
||||
|
||||
export const Pan: InteractionReducer = {
|
||||
mousemove: (draftState) => {
|
||||
if (draftState.mouse.mouseDownAt === null) return;
|
||||
|
||||
draftState.scroll.position = draftState.mouse.delta
|
||||
? draftState.scroll.position.add(draftState.mouse.delta)
|
||||
: draftState.scroll.position;
|
||||
},
|
||||
mousedown: () => {},
|
||||
mouseup: () => {}
|
||||
};
|
||||
7
src/interaction/reducers/Select.ts
Normal file
7
src/interaction/reducers/Select.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { InteractionReducer } from '../types';
|
||||
|
||||
export const Select: InteractionReducer = {
|
||||
mousemove: () => {},
|
||||
mousedown: () => {},
|
||||
mouseup: () => {}
|
||||
};
|
||||
31
src/interaction/types.ts
Normal file
31
src/interaction/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Draft } from 'immer';
|
||||
import {
|
||||
Mouse,
|
||||
Mode,
|
||||
Scroll,
|
||||
ContextMenu,
|
||||
ItemControls
|
||||
} from 'src/stores/useUiStateStore';
|
||||
import { SceneItems } from 'src/stores/useSceneStore';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
|
||||
export interface State {
|
||||
mouse: Mouse;
|
||||
mode: Mode;
|
||||
scroll: Scroll;
|
||||
gridSize: Coords;
|
||||
scene: SceneItems;
|
||||
contextMenu: ContextMenu;
|
||||
itemControls: ItemControls;
|
||||
}
|
||||
|
||||
export type InteractionReducerAction = (
|
||||
state: Draft<State>,
|
||||
payload: { prevMouse: Mouse }
|
||||
) => void;
|
||||
|
||||
export type InteractionReducer = {
|
||||
mousemove: InteractionReducerAction;
|
||||
mousedown: InteractionReducerAction;
|
||||
mouseup: InteractionReducerAction;
|
||||
};
|
||||
107
src/interaction/useInteractionManager.ts
Normal file
107
src/interaction/useInteractionManager.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { produce } from 'immer';
|
||||
import { Tool } from 'paper';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { toolEventToMouseEvent } from './utils';
|
||||
import { Select } from './reducers/Select';
|
||||
import { DragItems } from './reducers/DragItems';
|
||||
import { Pan } from './reducers/Pan';
|
||||
import { Cursor } from './reducers/Cursor';
|
||||
import type { InteractionReducer, InteractionReducerAction } from './types';
|
||||
|
||||
const reducers: {
|
||||
[key in 'SELECT' | 'PAN' | 'DRAG_ITEMS' | 'CURSOR']: InteractionReducer;
|
||||
} = {
|
||||
CURSOR: Cursor,
|
||||
SELECT: Select,
|
||||
DRAG_ITEMS: DragItems,
|
||||
PAN: Pan
|
||||
};
|
||||
|
||||
export const useInteractionManager = () => {
|
||||
const tool = useRef<paper.Tool>();
|
||||
const mode = useUiStateStore((state) => state.mode);
|
||||
const mouse = useUiStateStore((state) => state.mouse);
|
||||
const scroll = useUiStateStore((state) => state.scroll);
|
||||
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 onMouseEvent = useCallback(
|
||||
(toolEvent: paper.ToolEvent) => {
|
||||
const reducer = reducers[mode.type];
|
||||
let reducerAction: InteractionReducerAction;
|
||||
|
||||
switch (toolEvent.type) {
|
||||
case 'mousedown':
|
||||
reducerAction = reducer.mousedown;
|
||||
break;
|
||||
case 'mousemove':
|
||||
reducerAction = reducer.mousemove;
|
||||
break;
|
||||
case 'mouseup':
|
||||
reducerAction = reducer.mouseup;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const prevMouse = { ...mouse };
|
||||
// Update mouse position
|
||||
const newMouse = toolEventToMouseEvent({
|
||||
toolEvent,
|
||||
mouse,
|
||||
gridSize,
|
||||
scroll
|
||||
});
|
||||
|
||||
const newState = produce(
|
||||
{
|
||||
scene,
|
||||
mouse: newMouse,
|
||||
mode,
|
||||
scroll,
|
||||
gridSize,
|
||||
contextMenu,
|
||||
itemControls
|
||||
},
|
||||
(draft) => reducerAction(draft, { prevMouse })
|
||||
);
|
||||
|
||||
uiStateActions.setMouse(newMouse);
|
||||
uiStateActions.setScroll(newState.scroll);
|
||||
uiStateActions.setMode(newState.mode);
|
||||
uiStateActions.setContextMenu(newState.contextMenu);
|
||||
uiStateActions.setSidebar(newState.itemControls);
|
||||
sceneActions.setItems(newState.scene);
|
||||
},
|
||||
[
|
||||
mode,
|
||||
mouse,
|
||||
scroll,
|
||||
gridSize,
|
||||
itemControls,
|
||||
uiStateActions,
|
||||
sceneActions,
|
||||
scene,
|
||||
contextMenu
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
tool.current = new Tool();
|
||||
tool.current.onMouseMove = onMouseEvent;
|
||||
tool.current.onMouseDown = onMouseEvent;
|
||||
tool.current.onMouseUp = onMouseEvent;
|
||||
// tool.current.onKeyDown = onMouseEvent;
|
||||
// tool.current.onKeyUp = onMouseEvent;
|
||||
|
||||
return () => {
|
||||
tool.current?.remove();
|
||||
};
|
||||
}, [onMouseEvent]);
|
||||
};
|
||||
50
src/interaction/utils.ts
Normal file
50
src/interaction/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { Mouse, Scroll } from 'src/stores/useUiStateStore';
|
||||
import { getTileFromMouse } from 'src/renderer/utils/gridHelpers';
|
||||
|
||||
interface ToolEventToMouseEvent {
|
||||
toolEvent: paper.ToolEvent;
|
||||
mouse: Mouse;
|
||||
gridSize: Coords;
|
||||
scroll: Scroll;
|
||||
}
|
||||
|
||||
export const toolEventToMouseEvent = ({
|
||||
toolEvent,
|
||||
mouse,
|
||||
gridSize,
|
||||
scroll
|
||||
}: ToolEventToMouseEvent) => {
|
||||
const position = Coords.fromObject(toolEvent.point);
|
||||
|
||||
let mouseDownAt: Mouse['mouseDownAt'];
|
||||
|
||||
switch (toolEvent.type) {
|
||||
case 'mousedown':
|
||||
mouseDownAt = position;
|
||||
break;
|
||||
case 'mouseup':
|
||||
mouseDownAt = null;
|
||||
break;
|
||||
default:
|
||||
mouseDownAt = mouse.mouseDownAt;
|
||||
break;
|
||||
}
|
||||
|
||||
let delta: Coords | null = position.subtract(mouse.position);
|
||||
|
||||
if (delta.x === 0 && delta.y === 0) delta = null;
|
||||
|
||||
const tile = getTileFromMouse({
|
||||
mousePosition: position,
|
||||
gridSize,
|
||||
scroll
|
||||
});
|
||||
|
||||
return {
|
||||
tile,
|
||||
position,
|
||||
mouseDownAt,
|
||||
delta
|
||||
};
|
||||
};
|
||||
@@ -1,70 +1,78 @@
|
||||
import type { SceneI, IconI, NodeI } from "./validation/SceneSchema";
|
||||
import type {
|
||||
SceneInput,
|
||||
IconInput,
|
||||
NodeInput
|
||||
} from 'src/validation/SceneSchema';
|
||||
|
||||
export const icons: IconI[] = [
|
||||
export const icons: IconInput[] = [
|
||||
{
|
||||
id: "block",
|
||||
name: "Block",
|
||||
url: "https://isoflow.io/static/assets/icons/networking/primitive.svg",
|
||||
category: "Networking",
|
||||
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: '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: '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: '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: '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: '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: '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",
|
||||
},
|
||||
id: 'diamond',
|
||||
name: 'Diamond',
|
||||
url: 'https://isoflow.io/static/assets/icons/networking/diamond.svg',
|
||||
category: 'Generic'
|
||||
}
|
||||
];
|
||||
|
||||
export const nodes: NodeI[] = [
|
||||
export const nodes: NodeInput[] = [
|
||||
{
|
||||
id: "Node1",
|
||||
label: "Node 1",
|
||||
iconId: "block",
|
||||
id: 'Node1',
|
||||
label: 'Node 1',
|
||||
iconId: 'block',
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const mockScene: SceneI = {
|
||||
export const mockScene: SceneInput = {
|
||||
icons,
|
||||
nodes,
|
||||
connectors: [],
|
||||
groups: [],
|
||||
gridSize: {
|
||||
x: 51,
|
||||
y: 51
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import { Select } from "./Select";
|
||||
import { getGridSubset, isWithinBounds } from "../renderer/utils/gridHelpers";
|
||||
import { Mouse } from "../types";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
import { Node } from "../renderer/elements/Node";
|
||||
import { ManipulateLasso } from "./ManipulateLasso";
|
||||
|
||||
export class CreateLasso extends ModeBase {
|
||||
startTile: Coords | null = null;
|
||||
nodesSelected: Node[] = [];
|
||||
selectionGrid: Coords[] = [];
|
||||
|
||||
entry(mouse: Mouse) {
|
||||
if (!this.startTile) {
|
||||
this.startTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
}
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.displayAt(this.startTile, {
|
||||
skipAnimation: true,
|
||||
});
|
||||
this.ctx.renderer.sceneElements.cursor.setVisible(true);
|
||||
}
|
||||
|
||||
setStartTile(tile: Coords) {
|
||||
this.startTile = tile;
|
||||
}
|
||||
|
||||
exit() {}
|
||||
|
||||
MOUSE_MOVE(mouse: Mouse) {
|
||||
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
if (mouse.delta) {
|
||||
const prevTile = this.ctx.renderer.getTileFromMouse(
|
||||
mouse.position.subtract(mouse.delta)
|
||||
);
|
||||
|
||||
if (currentTile.isEqual(prevTile)) return;
|
||||
}
|
||||
|
||||
if (!this.startTile) return;
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.createSelection(
|
||||
this.startTile,
|
||||
currentTile
|
||||
);
|
||||
|
||||
this.selectionGrid = getGridSubset([this.startTile, currentTile]);
|
||||
this.nodesSelected = this.selectionGrid.reduce<Node[]>((acc, tile) => {
|
||||
const tileItems = this.ctx.renderer.getItemsByTile(tile);
|
||||
const filtered = tileItems.filter((i) => i?.type === "NODE") as Node[];
|
||||
return [...acc, ...filtered];
|
||||
}, []);
|
||||
|
||||
this.ctx.emitEvent({
|
||||
type: "MULTISELECT_UPDATED",
|
||||
data: {
|
||||
itemsSelected: this.nodesSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
MOUSE_DOWN(mouse: Mouse) {
|
||||
if (this.nodesSelected.length > 0) {
|
||||
this.ctx.activateMode(ManipulateLasso, (mode) => {
|
||||
mode.setSelectedItems(this.nodesSelected);
|
||||
mode.MOUSE_DOWN(mouse);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_UP(mouse: Mouse) {
|
||||
this.startTile = null;
|
||||
|
||||
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
if (
|
||||
this.nodesSelected.length === 0 ||
|
||||
!isWithinBounds(currentTile, this.selectionGrid)
|
||||
) {
|
||||
this.ctx.activateMode(Select);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import { Select } from "./Select";
|
||||
import { getBoundingBox, isWithinBounds } from "../renderer/utils/gridHelpers";
|
||||
import { Mouse } from "../types";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
import { Node } from "../renderer/elements/Node";
|
||||
import { CreateLasso } from "./CreateLasso";
|
||||
|
||||
export class ManipulateLasso extends ModeBase {
|
||||
selectedItems: Node[] = [];
|
||||
isMouseDownWithinLassoBounds = false;
|
||||
dragOffset = new Coords(0, 0);
|
||||
isDragging = false;
|
||||
|
||||
entry(mouse: Mouse) {}
|
||||
|
||||
setSelectedItems(items: Node[]) {
|
||||
this.selectedItems = items;
|
||||
}
|
||||
|
||||
exit() {}
|
||||
|
||||
MOUSE_MOVE(mouse: Mouse) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
if (mouse.delta) {
|
||||
const prevTile = this.ctx.renderer.getTileFromMouse(
|
||||
mouse.position.subtract(mouse.delta)
|
||||
);
|
||||
|
||||
if (currentTile.isEqual(prevTile)) return;
|
||||
}
|
||||
|
||||
const { renderer } = this.ctx;
|
||||
const { cursor, grid } = renderer.sceneElements;
|
||||
|
||||
if (this.isMouseDownWithinLassoBounds) {
|
||||
const validTile = grid.getAreaWithinGrid(
|
||||
currentTile,
|
||||
cursor.size,
|
||||
this.dragOffset
|
||||
);
|
||||
|
||||
const oldCursorPosition = cursor.position.clone();
|
||||
const newCursorPosition = validTile.subtract(this.dragOffset);
|
||||
|
||||
cursor.displayAt(newCursorPosition, {
|
||||
skipAnimation: true,
|
||||
});
|
||||
|
||||
const translateBy = new Coords(
|
||||
-(oldCursorPosition.x - newCursorPosition.x),
|
||||
-(oldCursorPosition.y - newCursorPosition.y)
|
||||
);
|
||||
|
||||
renderer.sceneElements.nodes.translateNodes(
|
||||
this.selectedItems.filter((i) => i.type === "NODE"),
|
||||
translateBy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_DOWN(mouse: Mouse) {
|
||||
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
const { renderer } = this.ctx;
|
||||
const { cursor } = renderer.sceneElements;
|
||||
const boundingBox = getBoundingBox([
|
||||
renderer.sceneElements.cursor.position,
|
||||
new Coords(
|
||||
cursor.position.x + cursor.size.x,
|
||||
cursor.position.y - cursor.size.y
|
||||
),
|
||||
]);
|
||||
|
||||
this.isMouseDownWithinLassoBounds = isWithinBounds(
|
||||
currentTile,
|
||||
boundingBox
|
||||
);
|
||||
|
||||
if (this.isMouseDownWithinLassoBounds) {
|
||||
this.isDragging = true;
|
||||
this.dragOffset.set(
|
||||
currentTile.x - cursor.position.x,
|
||||
currentTile.y - cursor.position.y
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx.activateMode(Select, (mode) => mode.MOUSE_DOWN(mouse));
|
||||
}
|
||||
|
||||
MOUSE_UP(mouse: Mouse) {
|
||||
this.isDragging = false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ModeContext, Mouse } from "../types";
|
||||
|
||||
export class ModeBase {
|
||||
ctx;
|
||||
|
||||
constructor(ctx: ModeContext) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
entry(mouse: Mouse) {}
|
||||
|
||||
exit() {}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { Tool } from "paper";
|
||||
import { Renderer } from "../renderer/Renderer";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import type { Mouse, OnSceneChange } from "../types";
|
||||
|
||||
const MOUSE_EVENTS = new Map([
|
||||
["mousemove", "MOUSE_MOVE"],
|
||||
["mousedown", "MOUSE_DOWN"],
|
||||
["mouseup", "MOUSE_UP"],
|
||||
["mouseenter", "MOUSE_ENTER"],
|
||||
["mouseleave", "MOUSE_LEAVE"],
|
||||
]);
|
||||
|
||||
export class ModeManager {
|
||||
// mobx requires all properties to be initialised explicitly (i.e. prop = undefined)
|
||||
renderer?: Renderer = undefined;
|
||||
currentMode?: {
|
||||
instance: ModeBase;
|
||||
class: typeof ModeBase;
|
||||
} = undefined;
|
||||
lastMode?: typeof ModeBase = undefined;
|
||||
mouse: Mouse = {
|
||||
position: new Coords(0, 0),
|
||||
delta: null,
|
||||
};
|
||||
emitEvent?: OnSceneChange;
|
||||
tool?: paper.Tool;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.onMouseEvent = this.onMouseEvent.bind(this);
|
||||
this.send = this.send.bind(this);
|
||||
}
|
||||
|
||||
setRenderer(renderer: Renderer) {
|
||||
this.renderer = renderer;
|
||||
|
||||
this.tool = new Tool();
|
||||
this.tool.onMouseMove = this.onMouseEvent;
|
||||
this.tool.onMouseDown = this.onMouseEvent;
|
||||
this.tool.onMouseUp = this.onMouseEvent;
|
||||
this.tool.onKeyDown = this.onMouseEvent;
|
||||
this.tool.onKeyUp = this.onMouseEvent;
|
||||
}
|
||||
|
||||
setEventEmitter(fn: OnSceneChange) {
|
||||
this.emitEvent = fn;
|
||||
}
|
||||
|
||||
activateMode<T extends typeof ModeBase>(
|
||||
Mode: T,
|
||||
init?: (instance: InstanceType<T>) => void
|
||||
) {
|
||||
if (!this.renderer) return;
|
||||
|
||||
if (this.currentMode) {
|
||||
this.currentMode.instance.exit();
|
||||
this.lastMode = this.currentMode.class;
|
||||
}
|
||||
|
||||
this.currentMode = {
|
||||
instance: new Mode({
|
||||
renderer: this.renderer,
|
||||
activateMode: this.activateMode.bind(this),
|
||||
emitEvent: this.emitEvent ?? (() => {}),
|
||||
}),
|
||||
class: Mode,
|
||||
};
|
||||
|
||||
init?.(this.currentMode.instance as InstanceType<T>);
|
||||
this.currentMode.instance.entry(this.mouse);
|
||||
}
|
||||
|
||||
onMouseEvent(event: paper.ToolEvent) {
|
||||
const type = MOUSE_EVENTS.get(event.type);
|
||||
|
||||
if (!type) return;
|
||||
|
||||
const mouse = {
|
||||
position: new Coords(event.point.x, event.point.y),
|
||||
delta: event.delta ? new Coords(event.delta.x, event.delta.y) : null,
|
||||
};
|
||||
|
||||
this.mouse = mouse;
|
||||
this.send(type, this.mouse);
|
||||
}
|
||||
|
||||
send(eventName: string, params?: any) {
|
||||
// TODO: Improve typings below
|
||||
// @ts-ignore
|
||||
this.currentMode?.instance[eventName]?.(params);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import { Mouse } from "../types";
|
||||
import { Renderer } from "../renderer/Renderer";
|
||||
|
||||
const changeCursor = (cursorType: string, renderer: Renderer) => {
|
||||
renderer.domElements.container.style.cursor = cursorType;
|
||||
};
|
||||
|
||||
export class Pan extends ModeBase {
|
||||
isPanning = false;
|
||||
|
||||
entry() {
|
||||
changeCursor("grab", this.ctx.renderer);
|
||||
}
|
||||
|
||||
exit() {
|
||||
changeCursor("default", this.ctx.renderer);
|
||||
}
|
||||
|
||||
MOUSE_DOWN() {
|
||||
if (!this.isPanning) {
|
||||
this.isPanning = true;
|
||||
changeCursor("grabbing", this.ctx.renderer);
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_UP() {
|
||||
if (this.isPanning) {
|
||||
this.isPanning = false;
|
||||
changeCursor("grab", this.ctx.renderer);
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_MOVE(mouse: Mouse) {
|
||||
if (this.isPanning && mouse.delta !== null) {
|
||||
this.ctx.renderer.scrollToDelta(mouse.delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import { Mouse } from "../types";
|
||||
import { getTargetFromSelection } from "./utils";
|
||||
import { SelectNode } from "./SelectNode";
|
||||
import { CreateLasso } from "./CreateLasso";
|
||||
import { CURSOR_TYPES } from "../renderer/elements/Cursor";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
|
||||
export class Select extends ModeBase {
|
||||
dragStartTile: Coords | null = null;
|
||||
|
||||
entry(mouse: Mouse) {
|
||||
this.ctx.renderer.unfocusAll();
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.setCursorType(CURSOR_TYPES.TILE);
|
||||
|
||||
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.displayAt(tile, {
|
||||
skipAnimation: true,
|
||||
});
|
||||
this.ctx.renderer.sceneElements.cursor.setVisible(true);
|
||||
}
|
||||
|
||||
exit() {
|
||||
this.ctx.renderer.sceneElements.cursor.setVisible(false);
|
||||
}
|
||||
|
||||
MOUSE_UP(mouse: Mouse) {
|
||||
const { renderer } = this.ctx;
|
||||
const tile = renderer.getTileFromMouse(mouse.position);
|
||||
const items = renderer.getItemsByTile(tile);
|
||||
const target = getTargetFromSelection(items);
|
||||
|
||||
if (!target?.type) {
|
||||
this.ctx.emitEvent({
|
||||
type: "TILE_SELECTED",
|
||||
data: { tile },
|
||||
});
|
||||
}
|
||||
|
||||
this.dragStartTile = null;
|
||||
}
|
||||
|
||||
MOUSE_DOWN(mouse: Mouse) {
|
||||
this.dragStartTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
const { renderer } = this.ctx;
|
||||
const tile = renderer.getTileFromMouse(mouse.position);
|
||||
const items = renderer.getItemsByTile(tile);
|
||||
const target = getTargetFromSelection(items);
|
||||
|
||||
if (target?.type === "NODE") {
|
||||
this.ctx.activateMode(SelectNode, (instance) => (instance.node = target));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_MOVE(mouse: Mouse) {
|
||||
const currentTile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
if (mouse.delta) {
|
||||
const prevTile = this.ctx.renderer.getTileFromMouse(
|
||||
mouse.position.subtract(mouse.delta)
|
||||
);
|
||||
|
||||
if (currentTile.isEqual(prevTile)) return;
|
||||
}
|
||||
|
||||
if (this.dragStartTile && !currentTile.isEqual(this.dragStartTile)) {
|
||||
this.ctx.activateMode(CreateLasso, (mode) => {
|
||||
this.dragStartTile && mode.setStartTile(this.dragStartTile);
|
||||
mode.MOUSE_MOVE(mouse);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.displayAt(currentTile);
|
||||
this.ctx.renderer.unfocusAll();
|
||||
|
||||
const items = this.ctx.renderer.getItemsByTile(currentTile);
|
||||
const target = getTargetFromSelection(items);
|
||||
|
||||
if (target?.type === "NODE") {
|
||||
target.setFocus(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ModeBase } from "./ModeBase";
|
||||
import { Select } from "../modes/Select";
|
||||
import { Mouse } from "../types";
|
||||
import { Node } from "../renderer/elements/Node";
|
||||
|
||||
export class SelectNode extends ModeBase {
|
||||
node?: Node;
|
||||
hasMoved = false;
|
||||
|
||||
entry(mouse: Mouse) {
|
||||
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
this.ctx.renderer.sceneElements.cursor.displayAt(tile);
|
||||
this.ctx.renderer.sceneElements.cursor.setVisible(true);
|
||||
}
|
||||
|
||||
exit() {
|
||||
this.ctx.renderer.sceneElements.cursor.setVisible(false);
|
||||
}
|
||||
|
||||
MOUSE_MOVE(mouse: Mouse) {
|
||||
if (!this.node) return;
|
||||
|
||||
const tile = this.ctx.renderer.getTileFromMouse(mouse.position);
|
||||
|
||||
if (this.node.position.x !== tile.x || this.node.position.y !== tile.y) {
|
||||
this.node.moveTo(tile);
|
||||
this.ctx.renderer.sceneElements.cursor.displayAt(tile);
|
||||
this.hasMoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
MOUSE_UP() {
|
||||
if (!this.node) return;
|
||||
|
||||
if (!this.hasMoved) {
|
||||
this.ctx.renderer.sceneElements.nodes.setSelectedNodes([this.node.id]);
|
||||
} else {
|
||||
}
|
||||
|
||||
this.ctx.activateMode(Select);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ModeManager } from "../ModeManager";
|
||||
import { Renderer } from "../../renderer/Renderer";
|
||||
import { TestMode } from "./fixtures/TestMode";
|
||||
|
||||
jest.mock("paper", () => ({
|
||||
Tool: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
jest.mock("../../renderer/Renderer", () => ({
|
||||
Renderer: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("Mode manager functions correctly", () => {
|
||||
it("Activating a mode works correctly", () => {
|
||||
const entrySpy = jest.spyOn(TestMode.prototype, "entry");
|
||||
const exitSpy = jest.spyOn(TestMode.prototype, "exit");
|
||||
const eventSpy = jest.spyOn(TestMode.prototype, "TEST_EVENT");
|
||||
const mouseEventSpy = jest.spyOn(TestMode.prototype, "MOUSE_MOVE");
|
||||
const renderer = new Renderer({} as unknown as HTMLDivElement);
|
||||
const modeManager = new ModeManager();
|
||||
modeManager.setRenderer(renderer);
|
||||
modeManager.activateMode(TestMode);
|
||||
|
||||
modeManager.send("TEST_EVENT");
|
||||
modeManager.send("TEST_EVENT");
|
||||
modeManager.send("MOUSE_MOVE", { x: 10, y: 10 });
|
||||
|
||||
expect(entrySpy).toHaveBeenCalled();
|
||||
expect(eventSpy).toHaveBeenCalledTimes(2);
|
||||
expect(mouseEventSpy).toHaveBeenCalledWith({
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
|
||||
modeManager.activateMode(TestMode);
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { ModeManager } from "../ModeManager";
|
||||
import { Renderer } from "../../renderer/Renderer";
|
||||
import { Select } from "../Select";
|
||||
import { CreateLasso } from "../CreateLasso";
|
||||
import { SelectNode } from "../SelectNode";
|
||||
import { Coords } from "../../renderer/elements/Coords";
|
||||
import { Node } from "../../renderer/elements/Node";
|
||||
import * as utils from "../utils";
|
||||
|
||||
jest.mock("paper", () => ({
|
||||
Tool: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
jest.mock("../utils", () => ({
|
||||
getTargetFromSelection: jest.fn(),
|
||||
}));
|
||||
jest.mock("../../renderer/elements/Cursor", () => ({
|
||||
CURSOR_TYPES: {
|
||||
TILE: "TILE",
|
||||
},
|
||||
}));
|
||||
jest.mock("../../renderer/Renderer", () => ({
|
||||
Renderer: jest.fn().mockImplementation(() => ({
|
||||
getTileFromMouse: (coords: Coords) => coords,
|
||||
getItemsByTile: jest.fn(() => []),
|
||||
unfocusAll: jest.fn(),
|
||||
sceneElements: {
|
||||
cursor: {
|
||||
displayAt: jest.fn(),
|
||||
setVisible: jest.fn(),
|
||||
setCursorType: jest.fn(),
|
||||
createSelection: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
jest.mock("../../renderer/elements/Node", () => ({
|
||||
Node: jest.fn().mockImplementation(() => ({
|
||||
type: "NODE",
|
||||
isFocussed: false,
|
||||
setFocus: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const MockNode = Node as jest.Mock<Node>;
|
||||
|
||||
const createRenderer = () => {
|
||||
const renderer = new Renderer({} as unknown as HTMLDivElement);
|
||||
const modeManager = new ModeManager();
|
||||
|
||||
modeManager.setRenderer(renderer);
|
||||
modeManager.activateMode(Select);
|
||||
|
||||
return { renderer, modeManager };
|
||||
};
|
||||
|
||||
describe("Select mode functions correctly", () => {
|
||||
it("Cursor repositions when tile is hovered", () => {
|
||||
const { renderer, modeManager } = createRenderer();
|
||||
const displayAtSpy = jest.spyOn(renderer.sceneElements.cursor, "displayAt");
|
||||
modeManager.send("MOUSE_MOVE", {
|
||||
position: new Coords(2, 2),
|
||||
delta: null,
|
||||
});
|
||||
expect(displayAtSpy).toHaveBeenCalled();
|
||||
expect(displayAtSpy.mock.calls[1][0]).toStrictEqual(new Coords(2, 2));
|
||||
});
|
||||
|
||||
it("Node gains focus when mouse hovers over it", () => {
|
||||
const { modeManager } = createRenderer();
|
||||
const mockNode = new MockNode();
|
||||
jest.spyOn(utils, "getTargetFromSelection").mockReturnValueOnce(mockNode);
|
||||
modeManager.send("MOUSE_MOVE", {
|
||||
position: new Coords(1, 1),
|
||||
delta: null,
|
||||
});
|
||||
expect(mockNode.setFocus).toHaveBeenCalled();
|
||||
});
|
||||
it("All focussed elements reset when user hovers over a different tile", () => {
|
||||
const { renderer, modeManager } = createRenderer();
|
||||
jest.spyOn(utils, "getTargetFromSelection").mockReturnValueOnce(null);
|
||||
const unfocusAllSpy = jest.spyOn(renderer, "unfocusAll");
|
||||
modeManager.send("MOUSE_MOVE", {
|
||||
position: new Coords(1, 1),
|
||||
delta: null,
|
||||
});
|
||||
expect(unfocusAllSpy).toHaveBeenCalled();
|
||||
});
|
||||
it("Activates multiselect mode when mouse is dragged (dragging must start from an empty tile)", () => {
|
||||
const activateModeSpy = jest.spyOn(ModeManager.prototype, "activateMode");
|
||||
const { modeManager } = createRenderer();
|
||||
jest.spyOn(utils, "getTargetFromSelection").mockReturnValue(null);
|
||||
modeManager.send("MOUSE_DOWN", {
|
||||
position: new Coords(0, 0),
|
||||
delta: null,
|
||||
});
|
||||
modeManager.send("MOUSE_MOVE", {
|
||||
position: new Coords(2, 2),
|
||||
delta: new Coords(2, 2),
|
||||
});
|
||||
expect(activateModeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(activateModeSpy.mock.calls[1][0]).toStrictEqual(CreateLasso);
|
||||
});
|
||||
it("Activates Node selection mode when user clicks on a node", () => {
|
||||
const activateModeSpy = jest.spyOn(ModeManager.prototype, "activateMode");
|
||||
const { modeManager } = createRenderer();
|
||||
const mockNode = new MockNode();
|
||||
jest.spyOn(utils, "getTargetFromSelection").mockReturnValue(mockNode);
|
||||
modeManager.send("MOUSE_DOWN", {
|
||||
position: new Coords(0, 0),
|
||||
delta: null,
|
||||
});
|
||||
expect(activateModeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(activateModeSpy.mock.calls[1][0]).toStrictEqual(SelectNode);
|
||||
});
|
||||
});
|
||||
12
src/modes/tests/fixtures/TestMode.ts
vendored
12
src/modes/tests/fixtures/TestMode.ts
vendored
@@ -1,12 +0,0 @@
|
||||
import { Coords } from "../../../renderer/elements/Coords";
|
||||
import { ModeBase } from "../../ModeBase";
|
||||
|
||||
export class TestMode extends ModeBase {
|
||||
entry() {}
|
||||
|
||||
exit() {}
|
||||
|
||||
TEST_EVENT() {}
|
||||
|
||||
MOUSE_MOVE(e: Coords) {}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Node } from "../renderer/elements/Node";
|
||||
import { Mouse } from "../types";
|
||||
import { Renderer } from "../renderer/Renderer";
|
||||
import { Coords } from "../renderer/elements/Coords";
|
||||
|
||||
export const getTargetFromSelection = (items: (Node | undefined)[]) => {
|
||||
const node = items.find((item) => item instanceof Node);
|
||||
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isMouseOverNewTile = (
|
||||
mouse: Mouse,
|
||||
getTileFromMouse: Renderer["getTileFromMouse"]
|
||||
) => {
|
||||
if (mouse.delta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevTile = getTileFromMouse(
|
||||
new Coords(
|
||||
mouse.position.x - mouse.delta.x,
|
||||
mouse.position.y - mouse.delta.y
|
||||
)
|
||||
);
|
||||
|
||||
const currentTile = getTileFromMouse(mouse.position);
|
||||
|
||||
if (prevTile.x !== currentTile.x || prevTile.y !== currentTile.y) {
|
||||
return currentTile;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
72
src/renderer/Initialiser.tsx
Normal file
72
src/renderer/Initialiser.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Paper from 'paper';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
if (Paper.view) {
|
||||
if (global.requestAnimationFrame) {
|
||||
const raf = global.requestAnimationFrame(render);
|
||||
|
||||
return raf;
|
||||
}
|
||||
|
||||
Paper.view.update();
|
||||
}
|
||||
};
|
||||
|
||||
export const Initialiser = ({ children }: Props) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const containerRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsReady(false);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
|
||||
Paper.settings = {
|
||||
insertItems: false,
|
||||
applyMatrix: false
|
||||
};
|
||||
|
||||
Paper.setup(containerRef.current);
|
||||
|
||||
const rafId = render();
|
||||
|
||||
setIsReady(true);
|
||||
|
||||
return () => {
|
||||
setIsReady(false);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
|
||||
Paper.projects.forEach((project) => project.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
{isReady && children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,308 +0,0 @@
|
||||
import { makeAutoObservable, observable } from "mobx";
|
||||
import Paper, { Group } from "paper";
|
||||
import gsap from "gsap";
|
||||
import { Grid } from "./elements/Grid";
|
||||
import { Cursor } from "./elements/Cursor";
|
||||
import { PROJECTED_TILE_WIDTH, PROJECTED_TILE_HEIGHT } from "./constants";
|
||||
import { clamp } from "../utils";
|
||||
import { Nodes } from "./elements/Nodes";
|
||||
import { SceneI, IconI } from "../validation/SceneSchema";
|
||||
import { Coords } from "./elements/Coords";
|
||||
import { OnSceneChange, SceneEventI } from "../types";
|
||||
|
||||
interface Config {
|
||||
icons: IconI[];
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
activeLayer: paper.Layer;
|
||||
zoom = 1;
|
||||
|
||||
config: Config = {
|
||||
icons: [],
|
||||
};
|
||||
callbacks: {
|
||||
emitEvent: OnSceneChange;
|
||||
};
|
||||
groups: {
|
||||
container: paper.Group;
|
||||
elements: paper.Group;
|
||||
};
|
||||
sceneElements: {
|
||||
grid: Grid;
|
||||
cursor: Cursor;
|
||||
nodes: Nodes;
|
||||
};
|
||||
domElements: {
|
||||
container: HTMLDivElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
};
|
||||
scroll = {
|
||||
position: new Coords(0, 0),
|
||||
offset: new Coords(0, 0),
|
||||
};
|
||||
rafRef?: number;
|
||||
|
||||
constructor(containerEl: HTMLDivElement) {
|
||||
makeAutoObservable(this, {
|
||||
scroll: observable,
|
||||
});
|
||||
|
||||
Paper.settings = {
|
||||
insertItems: false,
|
||||
applyMatrix: false,
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
emitEvent: () => {},
|
||||
};
|
||||
|
||||
this.domElements = {
|
||||
container: containerEl,
|
||||
canvas: this.initDOM(containerEl).canvas,
|
||||
};
|
||||
|
||||
Paper.setup(this.domElements.canvas);
|
||||
|
||||
this.sceneElements = {
|
||||
grid: new Grid(new Coords(51, 51), this),
|
||||
cursor: new Cursor(this),
|
||||
nodes: new Nodes(this),
|
||||
};
|
||||
|
||||
this.groups = {
|
||||
container: new Group(),
|
||||
elements: new Group(),
|
||||
};
|
||||
|
||||
this.groups.elements.addChild(this.sceneElements.grid.container);
|
||||
this.groups.elements.addChild(this.sceneElements.cursor.container);
|
||||
this.groups.elements.addChild(this.sceneElements.nodes.container);
|
||||
|
||||
this.groups.container.addChild(this.groups.elements);
|
||||
this.groups.container.set({ position: [0, 0] });
|
||||
|
||||
this.activeLayer = Paper.project.activeLayer;
|
||||
this.activeLayer.addChild(this.groups.container);
|
||||
|
||||
this.scrollTo(new Coords(0, 0));
|
||||
|
||||
this.render();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
setEventHandler(eventHandler: OnSceneChange) {
|
||||
this.callbacks.emitEvent = eventHandler;
|
||||
}
|
||||
|
||||
loadScene(scene: SceneI) {
|
||||
this.config.icons = scene.icons;
|
||||
|
||||
scene.nodes.forEach((node) => {
|
||||
this.sceneElements.nodes.addNode(node);
|
||||
});
|
||||
|
||||
this.setZoom(1);
|
||||
}
|
||||
|
||||
getIconById(id: string) {
|
||||
const icon = this.config.icons.find((icon) => icon.id === id);
|
||||
|
||||
if (!icon) {
|
||||
throw new Error(`Icon not found: ${id}`);
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
initDOM(containerEl: HTMLDivElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.position = "absolute";
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.left = "0";
|
||||
canvas.style.top = "0";
|
||||
canvas.setAttribute("resize", "true");
|
||||
containerEl.appendChild(canvas);
|
||||
|
||||
return { canvas };
|
||||
}
|
||||
|
||||
getTileFromMouse(mouse: Coords) {
|
||||
const halfW = PROJECTED_TILE_WIDTH / 2;
|
||||
const halfH = PROJECTED_TILE_HEIGHT / 2;
|
||||
|
||||
const canvasPosition = new Coords(
|
||||
mouse.x - this.groups.elements.position.x,
|
||||
mouse.y - this.groups.elements.position.y + halfH
|
||||
);
|
||||
|
||||
const row = Math.floor(
|
||||
(canvasPosition.x / halfW + canvasPosition.y / halfH) / 2
|
||||
);
|
||||
const col = Math.floor(
|
||||
(canvasPosition.y / halfH - canvasPosition.x / halfW) / 2
|
||||
);
|
||||
|
||||
const halfRowNum = Math.floor(this.sceneElements.grid.size.x * 0.5);
|
||||
const halfColNum = Math.floor(this.sceneElements.grid.size.y * 0.5);
|
||||
|
||||
return new Coords(
|
||||
clamp(row, -halfRowNum, halfRowNum),
|
||||
clamp(col, -halfColNum, halfColNum)
|
||||
);
|
||||
}
|
||||
|
||||
getTilePosition({ x, y }: Coords) {
|
||||
const halfW = PROJECTED_TILE_WIDTH * 0.5;
|
||||
const halfH = PROJECTED_TILE_HEIGHT * 0.5;
|
||||
|
||||
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
|
||||
}
|
||||
|
||||
getTileBounds(coords: Coords) {
|
||||
const position = this.getTilePosition(coords);
|
||||
|
||||
return {
|
||||
left: {
|
||||
x: position.x - PROJECTED_TILE_WIDTH * 0.5,
|
||||
y: position.y,
|
||||
},
|
||||
right: {
|
||||
x: position.x + PROJECTED_TILE_WIDTH * 0.5,
|
||||
y: position.y,
|
||||
},
|
||||
top: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT * 0.5 },
|
||||
bottom: { x: position.x, y: position.y + PROJECTED_TILE_HEIGHT * 0.5 },
|
||||
center: { x: position.x, y: position.y },
|
||||
};
|
||||
}
|
||||
|
||||
getTileScreenPosition(position: Coords) {
|
||||
const { width: viewW, height: viewH } = Paper.view.bounds;
|
||||
const { offsetLeft: offsetX, offsetTop: offsetY } = this.domElements.canvas;
|
||||
const tilePosition = this.getTileBounds(position).center;
|
||||
const globalItemsGroupPosition = this.groups.elements.globalToLocal([0, 0]);
|
||||
const screenPosition = new Coords(
|
||||
(tilePosition.x +
|
||||
this.scroll.position.x +
|
||||
globalItemsGroupPosition.x +
|
||||
this.groups.elements.position.x +
|
||||
viewW * 0.5) *
|
||||
this.zoom +
|
||||
offsetX,
|
||||
|
||||
(tilePosition.y +
|
||||
this.scroll.position.y +
|
||||
globalItemsGroupPosition.y +
|
||||
this.groups.elements.position.y +
|
||||
viewH * 0.5) *
|
||||
this.zoom +
|
||||
offsetY
|
||||
);
|
||||
|
||||
return screenPosition;
|
||||
}
|
||||
|
||||
setGrid(width: number, height: number) {}
|
||||
|
||||
setZoom(zoom: number) {
|
||||
this.zoom = zoom;
|
||||
|
||||
gsap.killTweensOf(Paper.view);
|
||||
gsap.to(Paper.view, {
|
||||
duration: 0.3,
|
||||
zoom: this.zoom,
|
||||
onComplete: () => {
|
||||
this.scrollTo(this.scroll.position);
|
||||
},
|
||||
});
|
||||
|
||||
this.emitEvent({
|
||||
type: "ZOOM_CHANGED",
|
||||
data: { level: zoom },
|
||||
});
|
||||
}
|
||||
|
||||
scrollTo(coords: Coords, opts?: { skipAnimation?: boolean }) {
|
||||
this.scroll.position.set(coords.x, coords.y);
|
||||
|
||||
const { center: viewCenter } = Paper.view.bounds;
|
||||
|
||||
const newPosition = new Coords(
|
||||
coords.x + viewCenter.x,
|
||||
coords.y + viewCenter.y
|
||||
);
|
||||
|
||||
gsap.to(this.groups.elements.position, {
|
||||
duration: opts?.skipAnimation ? 0 : 0.25,
|
||||
...newPosition,
|
||||
});
|
||||
}
|
||||
|
||||
scrollToDelta(delta: Coords) {
|
||||
const position = this.scroll.position.add(delta);
|
||||
|
||||
this.scrollTo(position);
|
||||
}
|
||||
|
||||
scrollToTile(coords: Coords, opts?: { skipAnimation?: boolean }) {
|
||||
const tile = this.getTileBounds(coords).center;
|
||||
|
||||
this.scrollTo(
|
||||
new Coords(
|
||||
-(tile.x - this.scroll.offset.x),
|
||||
-(tile.y - this.scroll.offset.y)
|
||||
),
|
||||
opts
|
||||
);
|
||||
}
|
||||
|
||||
unfocusAll() {
|
||||
this.sceneElements.nodes.unfocusAll();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.sceneElements.nodes.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.domElements.canvas.remove();
|
||||
|
||||
if (this.rafRef !== undefined) global.cancelAnimationFrame(this.rafRef);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (Paper.view) {
|
||||
if (global.requestAnimationFrame) {
|
||||
this.rafRef = global.requestAnimationFrame(this.render.bind(this));
|
||||
}
|
||||
|
||||
Paper.view.update();
|
||||
}
|
||||
}
|
||||
|
||||
exportScene(): SceneI {
|
||||
const exported = {
|
||||
icons: this.config.icons,
|
||||
nodes: this.sceneElements.nodes.export(),
|
||||
groups: [],
|
||||
connectors: [],
|
||||
};
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
emitEvent(event: SceneEventI) {
|
||||
this.callbacks.emitEvent(event);
|
||||
}
|
||||
|
||||
getItemsByTile(coords: Coords) {
|
||||
const node = this.sceneElements.nodes.getNodeByTile(coords);
|
||||
|
||||
return [node].filter((i) => Boolean(i));
|
||||
}
|
||||
}
|
||||
105
src/renderer/Renderer.tsx
Normal file
105
src/renderer/Renderer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Paper from 'paper';
|
||||
import gsap from 'gsap';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { useInteractionManager } from 'src/interaction/useInteractionManager';
|
||||
import { Initialiser } from './Initialiser';
|
||||
import { useRenderer } from './useRenderer';
|
||||
import { Node } from './components/node/Node';
|
||||
import { getTileFromMouse, getTilePosition } from './utils/gridHelpers';
|
||||
import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer';
|
||||
|
||||
const InitialisedRenderer = () => {
|
||||
const renderer = useRenderer();
|
||||
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 { activeLayer } = Paper.project;
|
||||
useInteractionManager();
|
||||
|
||||
const {
|
||||
init: initRenderer,
|
||||
zoomTo,
|
||||
container: rendererContainer,
|
||||
scrollTo
|
||||
} = renderer;
|
||||
const { position: scrollPosition } = scroll;
|
||||
|
||||
useEffect(() => {
|
||||
initRenderer();
|
||||
|
||||
return () => {
|
||||
if (activeLayer) gsap.killTweensOf(activeLayer.view);
|
||||
};
|
||||
}, [initRenderer, activeLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
zoomTo(zoom);
|
||||
}, [zoom, zoomTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const { center: viewCenter } = activeLayer.view.bounds;
|
||||
|
||||
const newPosition = new Coords(
|
||||
scrollPosition.x + viewCenter.x,
|
||||
scrollPosition.y + viewCenter.y
|
||||
);
|
||||
|
||||
rendererContainer.current.position.set(newPosition.x, newPosition.y);
|
||||
}, [scrollPosition, rendererContainer, activeLayer.view.bounds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode.type !== 'CURSOR') return;
|
||||
|
||||
const tile = getTileFromMouse({
|
||||
gridSize,
|
||||
mousePosition: mouse.position,
|
||||
scroll
|
||||
});
|
||||
|
||||
const tilePosition = getTilePosition(tile);
|
||||
renderer.cursor.moveTo(tilePosition);
|
||||
}, [
|
||||
mode,
|
||||
mouse.position,
|
||||
renderer.cursor.moveTo,
|
||||
gridSize,
|
||||
scrollPosition,
|
||||
renderer.cursor,
|
||||
scroll
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTo(scrollPosition);
|
||||
}, [scrollPosition, scrollTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const isCursorVisible = mode.type === 'CURSOR';
|
||||
|
||||
renderer.cursor.setVisible(isCursorVisible);
|
||||
}, [mode.type, mouse.position, renderer.cursor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{scene.nodes.map((node) => (
|
||||
<Node
|
||||
key={node.id}
|
||||
{...node}
|
||||
parentContainer={renderer.nodeManager.container as paper.Group}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Renderer = () => (
|
||||
<Initialiser>
|
||||
<InitialisedRenderer />
|
||||
<ContextMenuLayer />
|
||||
</Initialiser>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Group } from "paper";
|
||||
import { Context } from "../types";
|
||||
|
||||
export class SceneElement {
|
||||
container = new Group();
|
||||
ctx: Context;
|
||||
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.container.removeChildren();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clear();
|
||||
this.container.remove();
|
||||
}
|
||||
|
||||
export() {}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import cuid from "cuid";
|
||||
import { SceneEventI } from "../types";
|
||||
|
||||
type OnSceneEventComplete = (event: SceneEvent) => void;
|
||||
|
||||
interface SceneEventArgs {
|
||||
onComplete?: OnSceneEventComplete;
|
||||
parentEvent?: SceneEvent;
|
||||
}
|
||||
|
||||
export class SceneEvent {
|
||||
id = cuid();
|
||||
timeStarted = Date.now();
|
||||
timeCompleted?: number;
|
||||
event: SceneEventI;
|
||||
cascadedEvents: SceneEventI[] = [];
|
||||
onComplete?: (event: SceneEvent) => void;
|
||||
parentEvent?: SceneEvent;
|
||||
|
||||
constructor(event: SceneEventI, opts: SceneEventArgs) {
|
||||
this.event = event;
|
||||
this.onComplete = opts.onComplete;
|
||||
this.parentEvent = opts.parentEvent;
|
||||
}
|
||||
|
||||
attachEvent(event: SceneEventI) {
|
||||
this.cascadedEvents.push(event);
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (this.parentEvent) this.parentEvent.attachEvent(this.event);
|
||||
|
||||
if (!this.parentEvent) this.onComplete?.(this);
|
||||
|
||||
this.timeCompleted = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
export const createSceneEvent =
|
||||
(onComplete: OnSceneEventComplete) =>
|
||||
(event: SceneEventI, opts?: SceneEventArgs) => {
|
||||
return new SceneEvent(event, { ...opts, onComplete });
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { NodeContextMenu } from 'src/components/ContextMenu/NodeContextMenu';
|
||||
import { EmptyTileContextMenu } from 'src/components/ContextMenu/EmptyTileContextMenu';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
|
||||
export const ContextMenuLayer = () => {
|
||||
const contextMenu = useUiStateStore((state) => state.contextMenu);
|
||||
const sceneActions = useSceneStore((state) => state.actions);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
}}
|
||||
>
|
||||
{contextMenu?.type === 'NODE' && (
|
||||
<NodeContextMenu key={contextMenu.id} nodeId={contextMenu.id} />
|
||||
)}
|
||||
{contextMenu?.type === 'EMPTY_TILE' && (
|
||||
<EmptyTileContextMenu
|
||||
key={contextMenu.position.toString()}
|
||||
onAddNode={() => sceneActions.createNode(contextMenu.position)}
|
||||
position={contextMenu.position}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
61
src/renderer/components/cursor/useCursor.ts
Normal file
61
src/renderer/components/cursor/useCursor.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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 '../../utils/constants';
|
||||
import { applyProjectionMatrix } from '../../utils/projection';
|
||||
|
||||
export const useCursor = () => {
|
||||
const container = useRef(new Group());
|
||||
|
||||
const init = useCallback(() => {
|
||||
container.current.removeChildren();
|
||||
container.current.set({ pivot: [0, 0] });
|
||||
|
||||
const rectangle = new Shape.Rectangle({
|
||||
strokeCap: 'round',
|
||||
fillColor: 'blue',
|
||||
size: [TILE_SIZE, TILE_SIZE],
|
||||
opacity: 0.5,
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
pivot: [0, 0],
|
||||
position: [0, 0],
|
||||
dashArray: null
|
||||
});
|
||||
|
||||
container.current.addChild(rectangle);
|
||||
applyProjectionMatrix(container.current);
|
||||
|
||||
return container.current;
|
||||
}, []);
|
||||
|
||||
const moveTo = useCallback(
|
||||
(position: Coords, opts?: { animationDuration?: number }) => {
|
||||
const tweenProxy = new Coords(
|
||||
container.current.position.x,
|
||||
container.current.position.y
|
||||
);
|
||||
|
||||
gsap.to(tweenProxy, {
|
||||
duration: opts?.animationDuration || 0.1,
|
||||
...position,
|
||||
onUpdate: () => {
|
||||
container.current.position.set(tweenProxy);
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setVisible = useCallback((state: boolean) => {
|
||||
container.current.visible = state;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
init,
|
||||
moveTo,
|
||||
setVisible
|
||||
};
|
||||
};
|
||||
68
src/renderer/components/grid/useGrid.ts
Normal file
68
src/renderer/components/grid/useGrid.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Path, Point, Group } from 'paper';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { applyProjectionMatrix } from '../../utils/projection';
|
||||
import { TILE_SIZE, PIXEL_UNIT, SCALING_CONST } from '../../utils/constants';
|
||||
|
||||
const LINE_STYLE = {
|
||||
color: 'rgba(0, 0, 0, 0.15)',
|
||||
width: PIXEL_UNIT * 1
|
||||
};
|
||||
|
||||
const drawGrid = (width: number, height: number) => {
|
||||
const container = new Group();
|
||||
|
||||
for (let x = 0; x <= width; x += 1) {
|
||||
const lineLength = height * TILE_SIZE;
|
||||
const start = x * TILE_SIZE - lineLength * 0.5;
|
||||
const line = new Path({
|
||||
segments: [
|
||||
[start, -lineLength * 0.5],
|
||||
[start, lineLength * 0.5]
|
||||
],
|
||||
strokeWidth: LINE_STYLE.width,
|
||||
strokeColor: LINE_STYLE.color
|
||||
});
|
||||
|
||||
container.addChild(line);
|
||||
}
|
||||
|
||||
for (let y = 0; y <= height; y += 1) {
|
||||
const lineLength = width * TILE_SIZE;
|
||||
const start = y * TILE_SIZE - lineLength * 0.5;
|
||||
const line = new Path({
|
||||
segments: [
|
||||
[-lineLength * 0.5, start],
|
||||
[lineLength * 0.5, start]
|
||||
],
|
||||
strokeWidth: LINE_STYLE.width,
|
||||
strokeColor: LINE_STYLE.color
|
||||
});
|
||||
|
||||
container.addChild(line);
|
||||
}
|
||||
|
||||
container.scaling = new Point(SCALING_CONST, SCALING_CONST);
|
||||
container.applyMatrix = true;
|
||||
applyProjectionMatrix(container);
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
export const useGrid = () => {
|
||||
const container = useRef(new Group());
|
||||
const gridSize = useSceneStore((state) => state.gridSize);
|
||||
|
||||
const init = useCallback(() => {
|
||||
container.current.removeChildren();
|
||||
const grid = drawGrid(gridSize.x, gridSize.y);
|
||||
container.current.addChild(grid);
|
||||
|
||||
return container.current;
|
||||
}, [gridSize]);
|
||||
|
||||
return {
|
||||
init,
|
||||
container: container.current
|
||||
};
|
||||
};
|
||||
55
src/renderer/components/node/Node.tsx
Normal file
55
src/renderer/components/node/Node.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Group } from 'paper';
|
||||
import gsap from 'gsap';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { useNodeIcon } from './useNodeIcon';
|
||||
import { getTilePosition } from '../../utils/gridHelpers';
|
||||
|
||||
export interface NodeProps {
|
||||
position: Coords;
|
||||
iconId: string;
|
||||
parentContainer: paper.Group;
|
||||
}
|
||||
|
||||
export const Node = ({ position, iconId, parentContainer }: NodeProps) => {
|
||||
const [isFirstDisplay, setIsFirstDisplay] = useState(true);
|
||||
const container = useRef(new Group());
|
||||
const nodeIcon = useNodeIcon();
|
||||
|
||||
const {
|
||||
init: initNodeIcon,
|
||||
update: updateNodeIcon,
|
||||
isLoaded: isIconLoaded
|
||||
} = nodeIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const nodeIconContainer = initNodeIcon();
|
||||
|
||||
container.current.removeChildren();
|
||||
container.current.addChild(nodeIconContainer);
|
||||
parentContainer.addChild(container.current);
|
||||
}, [initNodeIcon, parentContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
updateNodeIcon(iconId);
|
||||
}, [iconId, updateNodeIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIconLoaded) return;
|
||||
|
||||
const tweenValues = Coords.fromObject(container.current.position);
|
||||
const endState = getTilePosition(position);
|
||||
|
||||
gsap.to(tweenValues, {
|
||||
duration: isFirstDisplay ? 0 : 0.1,
|
||||
...endState,
|
||||
onUpdate: () => {
|
||||
container.current.position.set(tweenValues);
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirstDisplay) setIsFirstDisplay(false);
|
||||
}, [position, isFirstDisplay, isIconLoaded]);
|
||||
|
||||
return null;
|
||||
};
|
||||
61
src/renderer/components/node/useNodeIcon.ts
Normal file
61
src/renderer/components/node/useNodeIcon.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
import { Group, Raster } from 'paper';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { PROJECTED_TILE_DIMENSIONS } from '../../utils/constants';
|
||||
|
||||
const NODE_IMG_PADDING = 0;
|
||||
|
||||
export const useNodeIcon = () => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const container = useRef(new Group());
|
||||
const icons = useSceneStore((state) => state.icons);
|
||||
|
||||
const update = useCallback(
|
||||
async (iconId: string) => {
|
||||
setIsLoaded(false);
|
||||
|
||||
const icon = icons.find((_icon) => _icon.id === iconId);
|
||||
|
||||
if (!icon) return;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const iconRaster = new Raster();
|
||||
|
||||
iconRaster.onLoad = () => {
|
||||
if (!container.current) return;
|
||||
|
||||
iconRaster.scale(
|
||||
(PROJECTED_TILE_DIMENSIONS.x - NODE_IMG_PADDING) /
|
||||
iconRaster.bounds.width
|
||||
);
|
||||
|
||||
const raster = iconRaster.rasterize();
|
||||
|
||||
container.current.removeChildren();
|
||||
container.current.addChild(raster);
|
||||
container.current.pivot = iconRaster.bounds.bottomCenter;
|
||||
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
iconRaster.source = icon.url;
|
||||
setIsLoaded(true);
|
||||
});
|
||||
},
|
||||
[icons]
|
||||
);
|
||||
|
||||
const init = useCallback(() => {
|
||||
container.current.removeChildren();
|
||||
container.current = new Group();
|
||||
|
||||
return container.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
container: container.current,
|
||||
update,
|
||||
init,
|
||||
isLoaded
|
||||
};
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export const TILE_SIZE = 72;
|
||||
export const PROJECTED_TILE_WIDTH = TILE_SIZE + TILE_SIZE / 3;
|
||||
export const PROJECTED_TILE_HEIGHT = PROJECTED_TILE_WIDTH / Math.sqrt(3);
|
||||
export const PIXEL_UNIT = TILE_SIZE * 0.02;
|
||||
export const SCALING_CONST = 0.9425;
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Shape, Point, Group } from "paper";
|
||||
import { gsap } from "gsap";
|
||||
import { applyProjectionMatrix } from "../utils/projection";
|
||||
import { TILE_SIZE, PIXEL_UNIT } from "../constants";
|
||||
import {
|
||||
sortByPosition,
|
||||
getBoundingBox,
|
||||
getTileBounds,
|
||||
} from "../utils/gridHelpers";
|
||||
import type { Context } from "../../types";
|
||||
import { Coords } from "./Coords";
|
||||
import { SceneElement } from "../SceneElement";
|
||||
import { tweenPosition } from "../../utils";
|
||||
|
||||
export enum CURSOR_TYPES {
|
||||
OUTLINE = "OUTLINE",
|
||||
CIRCLE = "CIRCLE",
|
||||
TILE = "TILE",
|
||||
LASSO = "LASSO",
|
||||
DOT = "DOT",
|
||||
}
|
||||
|
||||
export class Cursor extends SceneElement {
|
||||
container = new Group();
|
||||
|
||||
renderElements = {
|
||||
rectangle: new Shape.Rectangle([0, 0]),
|
||||
};
|
||||
|
||||
animations: {
|
||||
highlight: gsap.core.Tween;
|
||||
};
|
||||
|
||||
position = new Coords(0, 0);
|
||||
|
||||
size: Coords = new Coords(1, 1);
|
||||
|
||||
currentType?: CURSOR_TYPES;
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx);
|
||||
|
||||
this.displayAt = this.displayAt.bind(this);
|
||||
|
||||
this.renderElements.rectangle = new Shape.Rectangle({});
|
||||
|
||||
this.animations = {
|
||||
highlight: gsap
|
||||
.fromTo(
|
||||
this.renderElements.rectangle,
|
||||
{ duration: 0.25, dashOffset: 0 },
|
||||
{ dashOffset: PIXEL_UNIT * 12, ease: "none" }
|
||||
)
|
||||
.repeat(-1)
|
||||
.pause(),
|
||||
};
|
||||
|
||||
this.container.addChild(this.renderElements.rectangle);
|
||||
applyProjectionMatrix(this.container);
|
||||
|
||||
this.setCursorType(CURSOR_TYPES.TILE);
|
||||
this.displayAt(new Coords(0, 0));
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
setCursorType(type: CURSOR_TYPES) {
|
||||
if (type === this.currentType) return;
|
||||
|
||||
this.currentType = type;
|
||||
this.container.set({ pivot: [0, 0] });
|
||||
this.size = new Coords(1, 1);
|
||||
|
||||
switch (type) {
|
||||
case CURSOR_TYPES.OUTLINE:
|
||||
this.renderElements.rectangle.set({
|
||||
strokeCap: "round",
|
||||
fillColor: null,
|
||||
size: [TILE_SIZE * 1.8, TILE_SIZE * 1.8],
|
||||
opacity: 1,
|
||||
radius: PIXEL_UNIT * 25,
|
||||
strokeWidth: PIXEL_UNIT * 3,
|
||||
strokeColor: "blue",
|
||||
pivot: [0, 0],
|
||||
dashArray: [PIXEL_UNIT * 6, PIXEL_UNIT * 6],
|
||||
});
|
||||
this.animations.highlight.play();
|
||||
break;
|
||||
case CURSOR_TYPES.LASSO:
|
||||
this.renderElements.rectangle.set({
|
||||
strokeCap: "round",
|
||||
fillColor: "lightBlue",
|
||||
size: [TILE_SIZE, TILE_SIZE],
|
||||
opacity: 0.5,
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeWidth: PIXEL_UNIT * 3,
|
||||
strokeColor: "blue",
|
||||
dashArray: [5, 10],
|
||||
pivot: [0, 0],
|
||||
});
|
||||
this.animations.highlight.play();
|
||||
break;
|
||||
case CURSOR_TYPES.DOT:
|
||||
this.renderElements.rectangle.set({
|
||||
strokeCap: null,
|
||||
fillColor: "blue",
|
||||
size: [TILE_SIZE * 0.2, TILE_SIZE * 0.2],
|
||||
opacity: 1,
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeWidth: null,
|
||||
strokeColor: null,
|
||||
dashArray: null,
|
||||
pivot: [0, 0],
|
||||
});
|
||||
break;
|
||||
case CURSOR_TYPES.TILE:
|
||||
default:
|
||||
this.renderElements.rectangle.set({
|
||||
strokeCap: "round",
|
||||
fillColor: "blue",
|
||||
size: [TILE_SIZE, TILE_SIZE],
|
||||
opacity: 0.5,
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeWidth: 0,
|
||||
strokeColor: "transparent",
|
||||
pivot: [0, 0],
|
||||
dashArray: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setVisible(state: boolean) {
|
||||
this.container.visible = state;
|
||||
}
|
||||
|
||||
createSelection(from: Coords, to: Coords) {
|
||||
const boundingBox = getBoundingBox([from, to]);
|
||||
this.createSelectionFromBounds(boundingBox);
|
||||
}
|
||||
|
||||
createSelectionFromBounds(boundingBox: Coords[]) {
|
||||
this.setCursorType(CURSOR_TYPES.LASSO);
|
||||
|
||||
const sorted = sortByPosition(boundingBox);
|
||||
const position = new Coords(sorted.lowX, sorted.highY);
|
||||
|
||||
this.position.set(position.x, position.y);
|
||||
|
||||
this.size = new Coords(
|
||||
sorted.highX - sorted.lowX,
|
||||
sorted.highY - sorted.lowY
|
||||
);
|
||||
|
||||
this.renderElements.rectangle.set({
|
||||
size: [
|
||||
(this.size.x + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
|
||||
(this.size.y + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
|
||||
],
|
||||
});
|
||||
|
||||
this.container.set({
|
||||
pivot: this.renderElements.rectangle.bounds.bottomLeft,
|
||||
});
|
||||
|
||||
const targetTile = boundingBox[3];
|
||||
|
||||
this.container.position = new Point(getTileBounds(targetTile).left);
|
||||
}
|
||||
|
||||
predictBoundsAt(tile: Coords) {
|
||||
const bounds = [
|
||||
{ x: tile.x, y: tile.y },
|
||||
{ x: tile.x, y: tile.y - this.size.y },
|
||||
{ x: tile.x + this.size.x, y: tile.y - this.size.y },
|
||||
{ x: tile.x + this.size.x, y: tile.y },
|
||||
];
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
displayAt(position: Coords, opts?: { skipAnimation: boolean }) {
|
||||
if (this.position.isEqual(position)) return;
|
||||
|
||||
this.position.set(position.x, position.y);
|
||||
|
||||
const tileBoundsPosition =
|
||||
this.currentType === CURSOR_TYPES.LASSO ? "left" : "center";
|
||||
|
||||
const tile = getTileBounds(position)[tileBoundsPosition];
|
||||
|
||||
tweenPosition(this.container, {
|
||||
...tile,
|
||||
duration: opts?.skipAnimation ? 0 : 0.05,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Group, Path, Point } from "paper";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { applyProjectionMatrix } from "../utils/projection";
|
||||
import type { Context } from "../../types";
|
||||
import { TILE_SIZE, PIXEL_UNIT, SCALING_CONST } from "../constants";
|
||||
import { Coords } from "./Coords";
|
||||
import { sortByPosition, getBoundingBox } from "../utils/gridHelpers";
|
||||
|
||||
export class Grid {
|
||||
ctx: Context;
|
||||
container = new Group();
|
||||
renderElements = {
|
||||
grid: new Group({ applyMatrix: true }),
|
||||
};
|
||||
|
||||
size: Coords;
|
||||
|
||||
constructor(size: Coords, ctx: Context) {
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.size = size;
|
||||
this.ctx = ctx;
|
||||
this.container.addChild(this.renderElements.grid);
|
||||
|
||||
for (let x = 0; x <= this.size.x; x++) {
|
||||
const lineLength = this.size.y * TILE_SIZE;
|
||||
const start = x * TILE_SIZE - lineLength * 0.5;
|
||||
const line = new Path({
|
||||
segments: [
|
||||
[start, -lineLength * 0.5],
|
||||
[start, lineLength * 0.5],
|
||||
],
|
||||
strokeWidth: PIXEL_UNIT * 1,
|
||||
strokeColor: "rgba(0, 0, 0, 0.15)",
|
||||
});
|
||||
|
||||
this.renderElements.grid.addChild(line);
|
||||
}
|
||||
|
||||
for (let y = 0; y <= this.size.y; y++) {
|
||||
const lineLength = this.size.x * TILE_SIZE;
|
||||
const start = y * TILE_SIZE - lineLength * 0.5;
|
||||
const line = new Path({
|
||||
segments: [
|
||||
[-lineLength * 0.5, start],
|
||||
[lineLength * 0.5, start],
|
||||
],
|
||||
strokeWidth: PIXEL_UNIT * 1,
|
||||
strokeColor: "rgba(0, 0, 0, 0.15)",
|
||||
});
|
||||
|
||||
this.renderElements.grid.addChild(line);
|
||||
}
|
||||
|
||||
this.renderElements.grid.scaling = new Point(SCALING_CONST, SCALING_CONST);
|
||||
applyProjectionMatrix(this.renderElements.grid);
|
||||
}
|
||||
|
||||
getGridBounds() {
|
||||
const halfW = Math.floor(this.size.x * 0.5);
|
||||
const halfH = Math.floor(this.size.y * 0.5);
|
||||
|
||||
return getBoundingBox([
|
||||
new Coords(-halfW, -halfH),
|
||||
new Coords(-halfW, halfH),
|
||||
new Coords(halfW, halfH),
|
||||
new Coords(halfW, -halfH),
|
||||
]);
|
||||
}
|
||||
|
||||
getAreaWithinGrid(
|
||||
tile: Coords,
|
||||
size: Coords,
|
||||
offset: Coords = new Coords(0, 0)
|
||||
) {
|
||||
const position = tile.subtract(offset);
|
||||
|
||||
const areaBounds = sortByPosition([
|
||||
position,
|
||||
position.subtractY(size.y),
|
||||
new Coords(position.x + size.x, position.y - size.y),
|
||||
position.addX(size.x),
|
||||
]);
|
||||
const gridBounds = sortByPosition(this.getGridBounds());
|
||||
|
||||
const delta = new Coords(0, 0);
|
||||
|
||||
if (areaBounds.highX > gridBounds.highX) {
|
||||
delta.setX(-(areaBounds.highX - gridBounds.highX));
|
||||
}
|
||||
|
||||
if (areaBounds.lowX < gridBounds.lowX) {
|
||||
delta.setX(gridBounds.lowX - areaBounds.lowX);
|
||||
}
|
||||
|
||||
if (areaBounds.highY > gridBounds.highY) {
|
||||
delta.setY(-(areaBounds.highY - gridBounds.highY));
|
||||
}
|
||||
|
||||
if (areaBounds.lowY < gridBounds.lowY) {
|
||||
delta.setY(gridBounds.lowY - areaBounds.lowY);
|
||||
}
|
||||
|
||||
return new Coords(tile.x + delta.x, tile.y + delta.y);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { Group } from "paper";
|
||||
import { Context } from "../../types";
|
||||
import { Coords } from "./Coords";
|
||||
import { theme } from "../../theme";
|
||||
import { NodeTile } from "./NodeTile";
|
||||
import { NodeIcon } from "./NodeIcon";
|
||||
|
||||
export interface NodeOptions {
|
||||
id: string;
|
||||
position: Coords;
|
||||
iconId: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
onMove: (
|
||||
coords: Coords,
|
||||
node: Node,
|
||||
opts?: { skipAnimation: boolean }
|
||||
) => void;
|
||||
onDestroy: (node: Node) => void;
|
||||
}
|
||||
|
||||
export class Node {
|
||||
ctx: Context;
|
||||
container = new Group();
|
||||
type = "NODE";
|
||||
|
||||
id;
|
||||
callbacks: Callbacks;
|
||||
position;
|
||||
color: string = theme.customVars.diagramPalette.purple;
|
||||
isSelected = false;
|
||||
isFocussed = false;
|
||||
|
||||
icon: NodeIcon;
|
||||
tile: NodeTile;
|
||||
|
||||
constructor(ctx: Context, options: NodeOptions, callbacks: Callbacks) {
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.ctx = ctx;
|
||||
this.id = options.id;
|
||||
this.position = options.position;
|
||||
this.callbacks = callbacks;
|
||||
|
||||
this.icon = new NodeIcon(options.iconId, ctx);
|
||||
this.tile = new NodeTile();
|
||||
|
||||
this.container.addChild(this.tile.container);
|
||||
this.container.addChild(this.icon.container);
|
||||
this.moveTo(this.position);
|
||||
|
||||
this.destroy = this.destroy.bind(this);
|
||||
}
|
||||
|
||||
// although focus and selection appear to be the same thing, selection happens when a user
|
||||
// activates a node, and focus happens when a user hovers over a node.
|
||||
setSelected(state: boolean) {
|
||||
this.isSelected = state;
|
||||
this.setFocus(state);
|
||||
}
|
||||
|
||||
setFocus(state: boolean) {
|
||||
this.isFocussed = state;
|
||||
|
||||
if (!state && this.isSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tile.setFocus(state);
|
||||
}
|
||||
|
||||
moveTo(coords: Coords, opts?: { skipAnimation: boolean }) {
|
||||
this.callbacks.onMove(coords, this, opts);
|
||||
}
|
||||
|
||||
update(options: Partial<NodeOptions>) {
|
||||
if (options.iconId) {
|
||||
this.icon.update(options.iconId);
|
||||
}
|
||||
}
|
||||
|
||||
export() {
|
||||
return {
|
||||
id: this.id,
|
||||
position: this.position,
|
||||
...this.icon.export(),
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.container.removeChildren();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.container.remove();
|
||||
this.callbacks.onDestroy(this);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Group, Raster } from "paper";
|
||||
import { PROJECTED_TILE_WIDTH, PIXEL_UNIT } from "../constants";
|
||||
import { Context } from "../../types";
|
||||
|
||||
const NODE_IMG_PADDING = 0 * PIXEL_UNIT;
|
||||
|
||||
export class NodeIcon {
|
||||
container = new Group();
|
||||
ctx: Context;
|
||||
|
||||
iconId: string;
|
||||
|
||||
renderElements = {
|
||||
iconRaster: new Raster(),
|
||||
};
|
||||
|
||||
constructor(iconId: string, ctx: Context) {
|
||||
this.ctx = ctx;
|
||||
this.iconId = iconId;
|
||||
this.container.addChild(this.renderElements.iconRaster);
|
||||
|
||||
this.update(iconId);
|
||||
}
|
||||
|
||||
async update(iconId: string) {
|
||||
const { iconRaster } = this.renderElements;
|
||||
|
||||
this.iconId = iconId;
|
||||
|
||||
const icon = this.ctx.getIconById(iconId);
|
||||
|
||||
if (!icon) {
|
||||
return new Error("Icon not found");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
iconRaster.onLoad = () => {
|
||||
iconRaster.scale(
|
||||
(PROJECTED_TILE_WIDTH - NODE_IMG_PADDING) / iconRaster.bounds.width
|
||||
);
|
||||
|
||||
const raster = iconRaster.rasterize();
|
||||
this.container.removeChildren();
|
||||
|
||||
this.renderElements.iconRaster = raster;
|
||||
this.container.addChild(raster);
|
||||
|
||||
this.container.pivot = iconRaster.bounds.bottomCenter;
|
||||
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
iconRaster.source = icon.url;
|
||||
});
|
||||
}
|
||||
|
||||
export() {
|
||||
return { iconId: this.iconId };
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Group, Shape, Color } from "paper";
|
||||
import { PIXEL_UNIT, TILE_SIZE } from "../constants";
|
||||
import chroma from "chroma-js";
|
||||
import gsap from "gsap";
|
||||
import { applyProjectionMatrix } from "../utils/projection";
|
||||
import { theme } from "../../theme";
|
||||
|
||||
const TILE_PADDING = 10 * PIXEL_UNIT;
|
||||
const TILE_STYLE = {
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeCap: "round",
|
||||
strokeWidth: PIXEL_UNIT,
|
||||
size: [TILE_SIZE + TILE_PADDING * 2, TILE_SIZE + TILE_PADDING * 2],
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
export class NodeTile {
|
||||
container = new Group();
|
||||
|
||||
color: string;
|
||||
|
||||
renderElements = {
|
||||
tile: new Shape.Rectangle({}),
|
||||
focussedOutline: new Shape.Rectangle({}),
|
||||
};
|
||||
|
||||
animations = {
|
||||
highlight: gsap
|
||||
.fromTo(
|
||||
this.renderElements.focussedOutline,
|
||||
{ dashOffset: 0 },
|
||||
{ dashOffset: PIXEL_UNIT * 12, ease: "none", duration: 0.25 }
|
||||
)
|
||||
.repeat(-1)
|
||||
.pause(),
|
||||
};
|
||||
|
||||
constructor(color: string = theme.customVars.diagramPalette.purple) {
|
||||
this.color = color;
|
||||
|
||||
const { tile, focussedOutline } = this.renderElements;
|
||||
|
||||
this.renderElements.tile.set(TILE_STYLE);
|
||||
|
||||
this.renderElements.focussedOutline.set({
|
||||
...TILE_STYLE,
|
||||
radius: PIXEL_UNIT * 12,
|
||||
strokeWidth: PIXEL_UNIT * 3,
|
||||
pivot: [0, 0],
|
||||
dashArray: [PIXEL_UNIT * 6, PIXEL_UNIT * 6],
|
||||
scaling: 1.2,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
this.container.addChild(tile);
|
||||
this.container.addChild(focussedOutline);
|
||||
applyProjectionMatrix(this.container);
|
||||
|
||||
this.setColor(this.color);
|
||||
}
|
||||
|
||||
setFocus(state: boolean) {
|
||||
this.renderElements.focussedOutline.visible = state;
|
||||
this.animations.highlight.play();
|
||||
}
|
||||
|
||||
setColor(color: string) {
|
||||
this.color = color;
|
||||
this.renderElements.tile.fillColor = new Color(color);
|
||||
this.renderElements.tile.strokeColor = new Color(
|
||||
chroma(color).darken(1.5).hex()
|
||||
);
|
||||
this.renderElements.focussedOutline.strokeColor = new Color(color);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Group } from "paper";
|
||||
import { makeAutoObservable, toJS } from "mobx";
|
||||
import { Context } from "../../types";
|
||||
import { Node, NodeOptions } from "./Node";
|
||||
import { Coords } from "./Coords";
|
||||
import cuid from "cuid";
|
||||
import { tweenPosition } from "../../utils";
|
||||
|
||||
export class Nodes {
|
||||
ctx: Context;
|
||||
container = new Group();
|
||||
nodes: Node[] = [];
|
||||
selected: Node[] = [];
|
||||
|
||||
constructor(ctx: Context) {
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
addNode(
|
||||
options: Omit<NodeOptions, "position" | "id"> & {
|
||||
id?: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
) {
|
||||
const position = new Coords(options.position.x, options.position.y);
|
||||
|
||||
const node = new Node(
|
||||
this.ctx,
|
||||
{
|
||||
...options,
|
||||
position,
|
||||
id: options.id ?? cuid(),
|
||||
},
|
||||
{
|
||||
onMove: this.onMove.bind(this),
|
||||
onDestroy: this.onDestroy.bind(this),
|
||||
}
|
||||
);
|
||||
|
||||
this.nodes.push(node);
|
||||
this.container.addChild(node.container);
|
||||
|
||||
this.ctx.emitEvent({
|
||||
type: "NODE_CREATED",
|
||||
data: { node: node.id },
|
||||
});
|
||||
}
|
||||
|
||||
onMove(coords: Coords, node: Node, opts?: { skipAnimation: boolean }) {
|
||||
const from = node.position;
|
||||
const to = coords;
|
||||
|
||||
const tile = this.ctx.getTileBounds(coords);
|
||||
node.position = coords;
|
||||
|
||||
tweenPosition(node.container, {
|
||||
...tile.center,
|
||||
duration: opts?.skipAnimation ? 0 : 0.05,
|
||||
});
|
||||
|
||||
this.ctx.emitEvent({
|
||||
type: "NODE_MOVED",
|
||||
data: { node: node.id, from, to },
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(node: Node) {
|
||||
const id = node.id;
|
||||
const nodeIndex = this.nodes.indexOf(node);
|
||||
|
||||
if (nodeIndex === -1) return;
|
||||
|
||||
this.nodes.splice(nodeIndex, 1);
|
||||
|
||||
this.ctx.emitEvent({
|
||||
type: "NODE_REMOVED",
|
||||
data: { node: id },
|
||||
});
|
||||
}
|
||||
|
||||
getNodeById(id: string) {
|
||||
return this.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
getNodeByTile(coords: Coords) {
|
||||
return this.nodes.find(
|
||||
(node) => node.position.x === coords.x && node.position.y === coords.y
|
||||
);
|
||||
}
|
||||
|
||||
unfocusAll() {
|
||||
this.nodes.forEach((node) => {
|
||||
if (node.isFocussed) node.setFocus(false);
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.nodes.forEach((node) => node.destroy());
|
||||
this.nodes = [];
|
||||
}
|
||||
|
||||
setSelectedNodes(ids: string[]) {
|
||||
const nodes = ids
|
||||
.map((id) => {
|
||||
const node = this.getNodeById(id);
|
||||
node?.setSelected(true);
|
||||
|
||||
return node;
|
||||
})
|
||||
.filter((node) => node !== undefined) as Node[];
|
||||
|
||||
this.ctx.emitEvent({
|
||||
type: "NODES_SELECTED",
|
||||
data: { nodes },
|
||||
});
|
||||
}
|
||||
|
||||
translateNodes(nodes: Node[], translation: Coords) {
|
||||
// const updatedConnectors = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
// const connectors = this.connectors.getConnectorsByNode(node.id);
|
||||
|
||||
// connectors.forEach((con) => {
|
||||
// if (updatedConnectors.includes(con.id)) return;
|
||||
|
||||
// const connectedNode = con.from.id === node.id ? con.to : con.from;
|
||||
|
||||
// if (!nodes.find(({ id }) => id === connectedNode.id)) {
|
||||
// con.removeAllAnchors();
|
||||
// } else {
|
||||
// con.translateAnchors(translate);
|
||||
// }
|
||||
|
||||
// updatedConnectors.push(con.id);
|
||||
// });
|
||||
|
||||
node.moveTo(node.position.add(translation), { skipAnimation: true });
|
||||
});
|
||||
|
||||
// this.connectors.updateAllPaths();
|
||||
}
|
||||
|
||||
export() {
|
||||
const exported = this.nodes.map((node) => node.export());
|
||||
return exported;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Shape, Group } from "paper";
|
||||
|
||||
export class Positioner {
|
||||
container = new Group();
|
||||
|
||||
constructor(color: string = "red") {
|
||||
this.container.addChild(
|
||||
new Shape.Circle({
|
||||
center: [0, 0],
|
||||
radius: 10,
|
||||
fillColor: color,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
10
src/renderer/useNodeManager.ts
Normal file
10
src/renderer/useNodeManager.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useRef } from 'react';
|
||||
import { Group } from 'paper';
|
||||
|
||||
export const useNodeManager = () => {
|
||||
const container = useRef(new Group());
|
||||
|
||||
return {
|
||||
container: container.current
|
||||
};
|
||||
};
|
||||
61
src/renderer/useRenderer.ts
Normal file
61
src/renderer/useRenderer.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import Paper, { Group } from 'paper';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { useGrid } from './components/grid/useGrid';
|
||||
import { useNodeManager } from './useNodeManager';
|
||||
import { useCursor } from './components/cursor/useCursor';
|
||||
|
||||
export const useRenderer = () => {
|
||||
const container = useRef(new Group());
|
||||
const innerContainer = useRef(new Group());
|
||||
const grid = useGrid();
|
||||
const nodeManager = useNodeManager();
|
||||
const cursor = useCursor();
|
||||
const uiStateActions = useUiStateStore((state) => state.actions);
|
||||
|
||||
const { setScroll } = uiStateActions;
|
||||
const { init: initGrid } = grid;
|
||||
const { init: initCursor } = cursor;
|
||||
|
||||
const zoomTo = useCallback((zoom: number) => {
|
||||
Paper.project.activeLayer.view.zoom = zoom;
|
||||
}, []);
|
||||
|
||||
const init = useCallback(() => {
|
||||
const gridContainer = initGrid();
|
||||
const cursorContainer = initCursor();
|
||||
|
||||
innerContainer.current.addChild(gridContainer);
|
||||
innerContainer.current.addChild(cursorContainer);
|
||||
innerContainer.current.addChild(nodeManager.container);
|
||||
container.current.addChild(innerContainer.current);
|
||||
container.current.set({ position: [0, 0] });
|
||||
Paper.project.activeLayer.addChild(container.current);
|
||||
setScroll({
|
||||
position: new Coords(0, 0),
|
||||
offset: new Coords(0, 0)
|
||||
});
|
||||
}, [initGrid, initCursor, setScroll, nodeManager.container]);
|
||||
|
||||
const scrollTo = useCallback((to: Coords) => {
|
||||
const { center: viewCenter } = Paper.project.view.bounds;
|
||||
|
||||
const newPosition = {
|
||||
x: to.x + viewCenter.x,
|
||||
y: to.y + viewCenter.y
|
||||
};
|
||||
|
||||
container.current.position.set(newPosition);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
init,
|
||||
container,
|
||||
grid,
|
||||
zoomTo,
|
||||
scrollTo,
|
||||
nodeManager,
|
||||
cursor
|
||||
};
|
||||
};
|
||||
9
src/renderer/utils/constants.ts
Normal file
9
src/renderer/utils/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
|
||||
export const TILE_SIZE = 72;
|
||||
export const PROJECTED_TILE_DIMENSIONS = new Coords(
|
||||
TILE_SIZE + TILE_SIZE / 3,
|
||||
(TILE_SIZE + TILE_SIZE / 3) / Math.sqrt(3)
|
||||
);
|
||||
export const PIXEL_UNIT = TILE_SIZE * 0.02;
|
||||
export const SCALING_CONST = 0.9425;
|
||||
@@ -1,136 +1,114 @@
|
||||
import { PROJECTED_TILE_HEIGHT, PROJECTED_TILE_WIDTH } from "../constants";
|
||||
import { Coords } from "../elements/Coords";
|
||||
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 { SceneItems } from 'src/stores/useSceneStore';
|
||||
import { Scroll } from 'src/stores/useUiStateStore';
|
||||
|
||||
// Iterates over every item in a 2 dimensional array
|
||||
// const tileIterator = (w, h, cb) => {
|
||||
// const tiles = [];
|
||||
const halfW = PROJECTED_TILE_DIMENSIONS.x * 0.5;
|
||||
const halfH = PROJECTED_TILE_DIMENSIONS.y * 0.5;
|
||||
|
||||
// new Array(w).fill(null).map((row, x) =>
|
||||
// new Array(h).fill(null).forEach((col, y) => {
|
||||
// return tiles.push(cb(x - Math.floor(w * 0.5), y - Math.floor(h * 0.5)));
|
||||
// })
|
||||
// );
|
||||
interface GetTileFromMouse {
|
||||
mousePosition: Coords;
|
||||
scroll: Scroll;
|
||||
gridSize: Coords;
|
||||
}
|
||||
|
||||
// return tiles;
|
||||
// };
|
||||
export const getTileFromMouse = ({
|
||||
mousePosition,
|
||||
scroll,
|
||||
gridSize
|
||||
}: GetTileFromMouse) => {
|
||||
const canvasPosition = new Coords(
|
||||
mousePosition.x - (scroll.position.x + Paper.view.bounds.center.x),
|
||||
mousePosition.y - (scroll.position.y + Paper.view.bounds.center.y) + halfH
|
||||
);
|
||||
|
||||
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);
|
||||
const row = Math.floor(
|
||||
(canvasPosition.x / halfW + canvasPosition.y / halfH) / 2
|
||||
);
|
||||
const col = Math.floor(
|
||||
(canvasPosition.y / halfH - canvasPosition.x / halfW) / 2
|
||||
);
|
||||
|
||||
const highest = {
|
||||
byX: xSorted[xSorted.length - 1],
|
||||
byY: ySorted[ySorted.length - 1],
|
||||
};
|
||||
const lowest = { byX: xSorted[0], byY: ySorted[0] };
|
||||
const halfRowNum = Math.floor(gridSize.x * 0.5);
|
||||
const halfColNum = Math.floor(gridSize.y * 0.5);
|
||||
|
||||
const lowX = lowest.byX.x;
|
||||
const highX = highest.byX.x;
|
||||
const lowY = lowest.byY.y;
|
||||
const highY = highest.byY.y;
|
||||
|
||||
return {
|
||||
byX: xSorted,
|
||||
byY: ySorted,
|
||||
highest,
|
||||
lowest,
|
||||
lowX,
|
||||
lowY,
|
||||
highX,
|
||||
highY,
|
||||
};
|
||||
return new Coords(
|
||||
clamp(row, -halfRowNum, halfRowNum),
|
||||
clamp(col, -halfColNum, halfColNum)
|
||||
);
|
||||
};
|
||||
|
||||
export const getBoundingBox = (
|
||||
tiles: Coords[],
|
||||
offset: Coords = new Coords(0, 0)
|
||||
) => {
|
||||
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
|
||||
|
||||
return [
|
||||
new Coords(lowX - offset.x, lowY - offset.y),
|
||||
new Coords(highX + offset.x, lowY - offset.y),
|
||||
new Coords(highX + offset.x, highY + offset.y),
|
||||
new Coords(lowX - offset.x, highY + offset.y),
|
||||
];
|
||||
};
|
||||
|
||||
export const getTilePosition = ({ x, y }: Coords) => {
|
||||
const halfW = PROJECTED_TILE_WIDTH * 0.5;
|
||||
const halfH = PROJECTED_TILE_HEIGHT * 0.5;
|
||||
|
||||
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
|
||||
};
|
||||
export const getTilePosition = ({ x, y }: Coords) =>
|
||||
new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
|
||||
|
||||
export const getTileBounds = (coords: Coords) => {
|
||||
const position = getTilePosition(coords);
|
||||
|
||||
return {
|
||||
left: new Coords(position.x - PROJECTED_TILE_WIDTH * 0.5, position.y),
|
||||
right: new Coords(position.x + PROJECTED_TILE_WIDTH * 0.5, position.y),
|
||||
top: new Coords(position.x, position.y - PROJECTED_TILE_HEIGHT * 0.5),
|
||||
bottom: new Coords(position.x, position.y + PROJECTED_TILE_HEIGHT * 0.5),
|
||||
center: new Coords(position.x, position.y),
|
||||
left: new Coords(
|
||||
position.x - PROJECTED_TILE_DIMENSIONS.x * 0.5,
|
||||
position.y
|
||||
),
|
||||
right: new Coords(
|
||||
position.x + PROJECTED_TILE_DIMENSIONS.x * 0.5,
|
||||
position.y
|
||||
),
|
||||
top: new Coords(position.x, position.y - PROJECTED_TILE_DIMENSIONS.y * 0.5),
|
||||
bottom: new Coords(
|
||||
position.x,
|
||||
position.y + PROJECTED_TILE_DIMENSIONS.y * 0.5
|
||||
),
|
||||
center: new Coords(position.x, position.y)
|
||||
};
|
||||
};
|
||||
|
||||
export const getGridSubset = (tiles: Coords[]) => {
|
||||
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
|
||||
interface GetItemsByTile {
|
||||
tile: Coords;
|
||||
sceneItems: SceneItems;
|
||||
}
|
||||
|
||||
const subset = [];
|
||||
export const getItemsByTile = ({ tile, sceneItems }: GetItemsByTile) => {
|
||||
const nodes = sceneItems.nodes.filter((node) => node.position.isEqual(tile));
|
||||
|
||||
for (let x = lowX; x < highX + 1; x += 1) {
|
||||
for (let y = lowY; y < highY + 1; y += 1) {
|
||||
subset.push(new Coords(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
return subset;
|
||||
return { nodes };
|
||||
};
|
||||
|
||||
export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
|
||||
const { lowX, lowY, highX, highY } = sortByPosition(bounds);
|
||||
interface GetTileScreenPosition {
|
||||
position: Coords;
|
||||
scrollPosition: Coords;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
|
||||
export const getTileScreenPosition = ({
|
||||
position,
|
||||
scrollPosition,
|
||||
zoom
|
||||
}: GetTileScreenPosition) => {
|
||||
const { width: viewW, height: viewH } = Paper.view.bounds;
|
||||
const { offsetLeft: offsetX, offsetTop: offsetY } =
|
||||
Paper.project.view.element;
|
||||
const tilePosition = getTileBounds(position).center;
|
||||
const container = Paper.project.activeLayer.children[0];
|
||||
const globalItemsGroupPosition = container.globalToLocal([0, 0]);
|
||||
const screenPosition = new Coords(
|
||||
(tilePosition.x +
|
||||
scrollPosition.x +
|
||||
globalItemsGroupPosition.x +
|
||||
container.position.x +
|
||||
viewW * 0.5) *
|
||||
zoom +
|
||||
offsetX,
|
||||
|
||||
(tilePosition.y +
|
||||
scrollPosition.y +
|
||||
globalItemsGroupPosition.y +
|
||||
container.position.y +
|
||||
viewH * 0.5) *
|
||||
zoom +
|
||||
offsetY
|
||||
);
|
||||
|
||||
return screenPosition;
|
||||
};
|
||||
|
||||
// function getTranslation(start, end) {
|
||||
// return { x: start.x - end.x, y: start.y - end.y };
|
||||
// }
|
||||
|
||||
// const diffStates = {
|
||||
// ADDED: "A",
|
||||
// REMOVED: "R",
|
||||
// SAME: "S",
|
||||
// };
|
||||
|
||||
// function diffItems(oldArr, newArr) {
|
||||
// const items = [...oldArr, ...newArr].reduce((prev, i) => {
|
||||
// const match = prev.find((p) => p.id === i.id);
|
||||
|
||||
// if (match) {
|
||||
// return [...prev];
|
||||
// }
|
||||
|
||||
// return [...prev, i];
|
||||
// }, []);
|
||||
|
||||
// const changes = items.reduce((prev, item) => {
|
||||
// const isNew = Boolean(newArr.find((i) => i.id === item.id));
|
||||
// const isOld = Boolean(oldArr.find((i) => i.id === item.id));
|
||||
|
||||
// if (isNew && !isOld) {
|
||||
// return [...prev, { diffState: diffStates.ADDED, item }];
|
||||
// }
|
||||
|
||||
// if (isOld && !isNew) {
|
||||
// return [...prev, { diffState: diffStates.REMOVED, item }];
|
||||
// }
|
||||
|
||||
// return [...prev, { diffState: diffStates.SAME, item }];
|
||||
// }, []);
|
||||
|
||||
// return changes;
|
||||
// }
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Matrix, Point } from "paper";
|
||||
import { Matrix, Point } from 'paper';
|
||||
|
||||
export const getProjectionMatrix = (x: number, y: number) => {
|
||||
return new Matrix([
|
||||
export const getProjectionMatrix = (x: number, y: number) =>
|
||||
new Matrix([
|
||||
Math.sqrt(2) / 2,
|
||||
Math.sqrt(6) / 6,
|
||||
-(Math.sqrt(2) / 2),
|
||||
Math.sqrt(6) / 6,
|
||||
x - (Math.sqrt(2) / 2) * (x - y),
|
||||
y - (Math.sqrt(6) / 6) * (x + y - 2),
|
||||
y - (Math.sqrt(6) / 6) * (x + y - 2)
|
||||
]);
|
||||
};
|
||||
|
||||
export const applyProjectionMatrix = (
|
||||
item: paper.Item,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { getGridSubset, isWithinBounds } from "../gridHelpers";
|
||||
import { Coords } from "../../elements/Coords";
|
||||
|
||||
describe("Tests gridhelper functions", () => {
|
||||
test("Gets grid subset 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("Calculates within bounds correctly", () => {
|
||||
const BOUNDS: Coords[] = [
|
||||
new Coords(4, 4),
|
||||
new Coords(6, 4),
|
||||
new Coords(6, 6),
|
||||
new Coords(4, 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);
|
||||
});
|
||||
});
|
||||
90
src/stores/useSceneStore.ts
Normal file
90
src/stores/useSceneStore.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { produce } from 'immer';
|
||||
import { IconInput } from '../validation/SceneSchema';
|
||||
import { Coords } from '../utils/Coords';
|
||||
|
||||
export enum SceneItemTypeEnum {
|
||||
NODE = 'NODE'
|
||||
}
|
||||
|
||||
export interface SceneItem {
|
||||
id: string;
|
||||
type: SceneItemTypeEnum;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
type: SceneItemTypeEnum.NODE;
|
||||
id: string;
|
||||
iconId: string;
|
||||
label?: string;
|
||||
position: Coords;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export type Icon = IconInput;
|
||||
|
||||
export interface SceneItems {
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export type Scene = SceneItems & {
|
||||
icons: IconInput[];
|
||||
gridSize: Coords;
|
||||
};
|
||||
|
||||
export interface SceneActions {
|
||||
set: (scene: Scene) => void;
|
||||
setItems: (elements: SceneItems) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>) => void;
|
||||
getNodeById: (id: string) => Node | undefined;
|
||||
createNode: (position: Coords) => void;
|
||||
}
|
||||
|
||||
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: SceneItems) => {
|
||||
set({ nodes: items.nodes });
|
||||
},
|
||||
getNodeById: (id: string) => {
|
||||
const { nodes } = get();
|
||||
return nodes.find((node) => node.id === id);
|
||||
},
|
||||
updateNode: (id, updates) => {
|
||||
const { nodes } = get();
|
||||
const nodeIndex = nodes.findIndex((node) => node.id === id);
|
||||
|
||||
if (nodeIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newNodes = produce(nodes, (draftState) => {
|
||||
draftState[nodeIndex] = { ...draftState[nodeIndex], ...updates };
|
||||
});
|
||||
|
||||
set({ nodes: newNodes });
|
||||
},
|
||||
createNode: (position) => {
|
||||
const { nodes, icons } = get();
|
||||
const newNode: Node = {
|
||||
id: uuid(),
|
||||
type: SceneItemTypeEnum.NODE,
|
||||
iconId: icons[0].id,
|
||||
position,
|
||||
isSelected: false
|
||||
};
|
||||
|
||||
set({ nodes: [...nodes, newNode] });
|
||||
}
|
||||
}
|
||||
}));
|
||||
128
src/stores/useUiStateStore.ts
Normal file
128
src/stores/useUiStateStore.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { create } from 'zustand';
|
||||
import { clamp, roundToOneDecimalPlace } from 'src/utils';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import { Node, SceneItem } from 'src/stores/useSceneStore';
|
||||
|
||||
const ZOOM_INCREMENT = 0.2;
|
||||
export const MIN_ZOOM = 0.2;
|
||||
export const MAX_ZOOM = 1;
|
||||
|
||||
export enum SidebarTypeEnum {
|
||||
SINGLE_NODE = 'SINGLE_NODE',
|
||||
PROJECT_SETTINGS = 'PROJECT_SETTINGS'
|
||||
}
|
||||
|
||||
export type ItemControls =
|
||||
| {
|
||||
type: SidebarTypeEnum.SINGLE_NODE;
|
||||
nodeId: string;
|
||||
}
|
||||
| {
|
||||
type: SidebarTypeEnum.PROJECT_SETTINGS;
|
||||
}
|
||||
| null;
|
||||
|
||||
export type Mode =
|
||||
| {
|
||||
type: 'CURSOR';
|
||||
}
|
||||
| {
|
||||
type: 'SELECT';
|
||||
}
|
||||
| {
|
||||
type: 'PAN';
|
||||
}
|
||||
| {
|
||||
type: 'DRAG_ITEMS';
|
||||
items: {
|
||||
nodes: Pick<Node, 'id' | 'type'>[];
|
||||
};
|
||||
hasMovedTile: boolean;
|
||||
};
|
||||
|
||||
export type ContextMenu =
|
||||
| SceneItem
|
||||
| {
|
||||
type: 'EMPTY_TILE';
|
||||
position: Coords;
|
||||
}
|
||||
| null;
|
||||
|
||||
export interface Mouse {
|
||||
position: Coords;
|
||||
tile: Coords;
|
||||
mouseDownAt: Coords | null;
|
||||
delta: Coords | null;
|
||||
}
|
||||
|
||||
export interface Scroll {
|
||||
position: Coords;
|
||||
offset: Coords;
|
||||
}
|
||||
|
||||
export interface UiState {
|
||||
mode: Mode;
|
||||
itemControls: ItemControls;
|
||||
contextMenu: ContextMenu;
|
||||
zoom: number;
|
||||
scroll: Scroll;
|
||||
mouse: Mouse;
|
||||
}
|
||||
|
||||
export interface UiStateActions {
|
||||
setMode: (mode: Mode) => void;
|
||||
incrementZoom: () => void;
|
||||
decrementZoom: () => void;
|
||||
setScroll: (scroll: Scroll) => void;
|
||||
setMouse: (mouse: Mouse) => void;
|
||||
setSidebar: (itemControls: ItemControls) => void;
|
||||
setContextMenu: (contextMenu: ContextMenu) => void;
|
||||
}
|
||||
|
||||
export type UseUiStateStore = UiState & {
|
||||
actions: UiStateActions;
|
||||
};
|
||||
|
||||
export const useUiStateStore = create<UseUiStateStore>((set, get) => ({
|
||||
mode: { type: 'CURSOR' },
|
||||
itemControls: null,
|
||||
contextMenu: null,
|
||||
scroll: {
|
||||
position: new Coords(0, 0),
|
||||
offset: new Coords(0, 0)
|
||||
},
|
||||
mouse: {
|
||||
position: new Coords(0, 0),
|
||||
tile: new Coords(0, 0),
|
||||
mouseDownAt: null,
|
||||
delta: null
|
||||
},
|
||||
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 } });
|
||||
},
|
||||
setMouse: ({ position, delta, mouseDownAt, tile }) => {
|
||||
set({ mouse: { position, delta, mouseDownAt, tile } });
|
||||
},
|
||||
setSidebar: (itemControls) => {
|
||||
set({ itemControls });
|
||||
},
|
||||
setContextMenu: (contextMenu) => {
|
||||
set({ contextMenu });
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createTheme } from "@mui/material";
|
||||
import { createTheme } from '@mui/material';
|
||||
|
||||
interface CustomThemeVars {
|
||||
sideNav: {
|
||||
width: number;
|
||||
appPadding: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
toolMenu: {
|
||||
height: number;
|
||||
@@ -12,7 +13,7 @@ interface CustomThemeVars {
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@mui/material/styles" {
|
||||
declare module '@mui/material/styles' {
|
||||
interface Theme {
|
||||
customVars: CustomThemeVars;
|
||||
}
|
||||
@@ -23,56 +24,57 @@ declare module "@mui/material/styles" {
|
||||
}
|
||||
|
||||
const customVars: CustomThemeVars = {
|
||||
sideNav: {
|
||||
width: 60,
|
||||
appPadding: {
|
||||
x: 60,
|
||||
y: 60
|
||||
},
|
||||
toolMenu: {
|
||||
height: 55,
|
||||
height: 55
|
||||
},
|
||||
diagramPalette: {
|
||||
purple: "#cabffa",
|
||||
},
|
||||
purple: '#cabffa'
|
||||
}
|
||||
};
|
||||
|
||||
export const theme = createTheme({
|
||||
customVars,
|
||||
typography: {
|
||||
h5: {
|
||||
fontSize: "1.3rem",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: 1.2
|
||||
}
|
||||
},
|
||||
palette: {
|
||||
mode: "dark",
|
||||
mode: 'dark',
|
||||
secondary: {
|
||||
main: "#df004c",
|
||||
},
|
||||
main: '#df004c'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiToolbar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
},
|
||||
backgroundColor: 'white'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableElevation: true,
|
||||
variant: "contained",
|
||||
variant: 'contained',
|
||||
disableRipple: true,
|
||||
disableTouchRipple: true,
|
||||
disableTouchRipple: true
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: "none",
|
||||
},
|
||||
},
|
||||
textTransform: 'none'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: {
|
||||
variant: "standard",
|
||||
},
|
||||
},
|
||||
},
|
||||
variant: 'standard'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
60
src/tests/fixtures/scene.ts
vendored
60
src/tests/fixtures/scene.ts
vendored
@@ -1,50 +1,54 @@
|
||||
import { SceneI } from "../../validation/SceneSchema";
|
||||
import { SceneInput } from 'src/validation/SceneSchema';
|
||||
|
||||
export const scene: SceneI = {
|
||||
export const scene: SceneInput = {
|
||||
gridSize: {
|
||||
x: 10,
|
||||
y: 10
|
||||
},
|
||||
icons: [
|
||||
{
|
||||
id: "icon1",
|
||||
name: "Icon1",
|
||||
url: "http://example1.com",
|
||||
id: 'icon1',
|
||||
name: 'Icon1',
|
||||
url: 'http://example1.com'
|
||||
},
|
||||
{
|
||||
id: "icon2",
|
||||
name: "Icon2",
|
||||
url: "http://example2.com",
|
||||
},
|
||||
id: 'icon2',
|
||||
name: 'Icon2',
|
||||
url: 'http://example2.com'
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
id: "node1",
|
||||
label: "Node1",
|
||||
iconId: "icon1",
|
||||
id: 'node1',
|
||||
label: 'Node1',
|
||||
iconId: 'icon1',
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
y: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "node2",
|
||||
label: "Node2",
|
||||
iconId: "icon2",
|
||||
id: 'node2',
|
||||
label: 'Node2',
|
||||
iconId: 'icon2',
|
||||
position: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
},
|
||||
y: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "node3",
|
||||
label: "Node3",
|
||||
iconId: "icon1",
|
||||
id: 'node3',
|
||||
label: 'Node3',
|
||||
iconId: 'icon1',
|
||||
position: {
|
||||
x: 2,
|
||||
y: 2,
|
||||
},
|
||||
},
|
||||
y: 2
|
||||
}
|
||||
}
|
||||
],
|
||||
connectors: [
|
||||
{ id: "connector1", label: "Connector1", from: "node1", to: "node2" },
|
||||
{ id: "connector2", label: "Connector2", from: "node2", to: "node3" },
|
||||
{ id: 'connector1', label: 'Connector1', from: 'node1', to: 'node2' },
|
||||
{ id: 'connector2', label: 'Connector2', from: 'node2', to: 'node3' }
|
||||
],
|
||||
groups: [{ id: "group1", label: "Group1", nodes: ["node1", "node2"] }],
|
||||
groups: [{ id: 'group1', label: 'Group1', nodes: ['node1', 'node2'] }]
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
import {
|
||||
SceneSchema,
|
||||
NodeI,
|
||||
ConnectorI,
|
||||
GroupI,
|
||||
findInvalidNode,
|
||||
findInvalidConnector,
|
||||
findInvalidGroup,
|
||||
} from "../../validation/SceneSchema";
|
||||
import { scene } from "../fixtures/scene";
|
||||
|
||||
describe("scene validation works correctly", () => {
|
||||
test("scene fixture is valid", () => {
|
||||
const result = SceneSchema.safeParse(scene);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("finds invalid nodes in scene", () => {
|
||||
const { icons } = scene;
|
||||
const invalidNode = {
|
||||
id: "invalidNode",
|
||||
iconId: "doesntExist",
|
||||
position: { x: -1, y: -1 },
|
||||
};
|
||||
const nodes: NodeI[] = [...scene.nodes, invalidNode];
|
||||
|
||||
const result = findInvalidNode(nodes, icons);
|
||||
|
||||
expect(result).toEqual(invalidNode);
|
||||
});
|
||||
|
||||
test("finds invalid connectors in scene", () => {
|
||||
const { nodes } = scene;
|
||||
const invalidConnector = {
|
||||
id: "invalidConnector",
|
||||
from: "node1",
|
||||
to: "invalidNode",
|
||||
label: null,
|
||||
};
|
||||
const connectors: ConnectorI[] = [...scene.connectors, invalidConnector];
|
||||
|
||||
const result = findInvalidConnector(connectors, nodes);
|
||||
|
||||
expect(result).toEqual(invalidConnector);
|
||||
});
|
||||
|
||||
test("finds invalid groups in scene", () => {
|
||||
const { nodes } = scene;
|
||||
const invalidGroup = {
|
||||
id: "invalidGroup",
|
||||
label: null,
|
||||
nodes: ["invalidNode", "node1"],
|
||||
};
|
||||
const groups: GroupI[] = [...scene.groups, invalidGroup];
|
||||
|
||||
const result = findInvalidGroup(groups, nodes);
|
||||
|
||||
expect(result).toEqual(invalidGroup);
|
||||
});
|
||||
});
|
||||
80
src/types.ts
80
src/types.ts
@@ -1,80 +0,0 @@
|
||||
import { Renderer } from "./renderer/Renderer";
|
||||
import type { ModeManager } from "./modes/ModeManager";
|
||||
import { Coords } from "./renderer/elements/Coords";
|
||||
import { Node } from "./renderer/elements/Node";
|
||||
|
||||
export interface Mode {
|
||||
initial: string;
|
||||
ctx: ModeContext;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface Mouse {
|
||||
position: Coords;
|
||||
delta: Coords | null;
|
||||
}
|
||||
|
||||
export interface ModeContext {
|
||||
renderer: Renderer;
|
||||
activateMode: ModeManager["activateMode"];
|
||||
emitEvent: OnSceneChange;
|
||||
}
|
||||
|
||||
export type GeneralEventI =
|
||||
| {
|
||||
type: "SCENE_LOAD";
|
||||
data: {};
|
||||
}
|
||||
| {
|
||||
type: "TILE_SELECTED";
|
||||
data: {
|
||||
tile: Coords;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "MULTISELECT_UPDATED";
|
||||
data: {
|
||||
itemsSelected: Node[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "ZOOM_CHANGED";
|
||||
data: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type NodeEventI =
|
||||
// Node Events
|
||||
| {
|
||||
type: "NODE_CREATED";
|
||||
data: {
|
||||
node: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "NODE_REMOVED";
|
||||
data: {
|
||||
node: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "NODES_SELECTED";
|
||||
data: {
|
||||
nodes: Node[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "NODE_MOVED";
|
||||
data: {
|
||||
node: string;
|
||||
from: Coords;
|
||||
to: Coords;
|
||||
};
|
||||
};
|
||||
|
||||
export type SceneEventI = NodeEventI | GeneralEventI;
|
||||
|
||||
export type Context = Renderer;
|
||||
|
||||
export type OnSceneChange = (event: SceneEventI) => void;
|
||||
@@ -1,12 +1,9 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
export class Coords {
|
||||
x: number = 0;
|
||||
|
||||
y: number = 0;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
@@ -55,4 +52,12 @@ export class Coords {
|
||||
clone() {
|
||||
return new Coords(this.x, this.y);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `x: ${this.x}, y: ${this.y}`;
|
||||
}
|
||||
|
||||
static fromObject({ x, y }: { x: number; y: number }) {
|
||||
return new Coords(x, y);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import gsap from "gsap";
|
||||
import gsap from 'gsap';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import type { NodeInput } from 'src/validation/SceneSchema';
|
||||
import { Node, SceneItemTypeEnum } from 'src/stores/useSceneStore';
|
||||
|
||||
export const clamp = (num: number, min: number, max: number) => {
|
||||
return num <= min ? min : num >= max ? max : num;
|
||||
export const clamp = (num: number, min: number, max: number) =>
|
||||
num <= min ? min : num >= max ? max : num;
|
||||
|
||||
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.
|
||||
const newCoords = new Coords(
|
||||
coords.x === 0 ? 0.000001 : coords.x,
|
||||
coords.y === 0 ? 0.000001 : coords.y
|
||||
);
|
||||
|
||||
return newCoords;
|
||||
};
|
||||
|
||||
export const getRandom = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min) + min);
|
||||
};
|
||||
export const getRandom = (min: number, max: number) =>
|
||||
Math.floor(Math.random() * (max - min) + min);
|
||||
|
||||
export const tweenPosition = (
|
||||
item: paper.Item,
|
||||
@@ -16,16 +27,32 @@ export const tweenPosition = (
|
||||
// so we have to use a proxy object
|
||||
const currPosition = {
|
||||
x: item.position.x,
|
||||
y: item.position.y,
|
||||
y: item.position.y
|
||||
};
|
||||
|
||||
gsap.to(currPosition, {
|
||||
duration,
|
||||
overwrite: "auto",
|
||||
overwrite: 'auto',
|
||||
x,
|
||||
y,
|
||||
onUpdate: () => {
|
||||
item.set({ position: currPosition });
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const roundToOneDecimalPlace = (num: number) =>
|
||||
Math.round(num * 10) / 10;
|
||||
|
||||
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
|
||||
const node: Node = {
|
||||
id: nodeInput.id,
|
||||
label: nodeInput.label,
|
||||
iconId: nodeInput.iconId,
|
||||
position: Coords.fromObject(nodeInput.position),
|
||||
isSelected: false,
|
||||
type: SceneItemTypeEnum.NODE
|
||||
};
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
@@ -1,74 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import { z } from 'zod';
|
||||
|
||||
export const IconSchema = z.object({
|
||||
export const iconInput = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
category: z.string().optional(),
|
||||
category: z.string().optional()
|
||||
});
|
||||
|
||||
export const NodeSchema = z.object({
|
||||
export const nodeInput = z.object({
|
||||
id: z.string(),
|
||||
label: z.string().optional(),
|
||||
iconId: z.string(),
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
y: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
export const ConnectorSchema = z.object({
|
||||
export const connectorInput = z.object({
|
||||
id: z.string(),
|
||||
label: z.string().nullable(),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
to: z.string()
|
||||
});
|
||||
|
||||
export const GroupSchema = z.object({
|
||||
export const groupInput = z.object({
|
||||
id: z.string(),
|
||||
label: z.string().nullable(),
|
||||
nodes: z.array(z.string()),
|
||||
nodes: z.array(z.string())
|
||||
});
|
||||
|
||||
export type IconI = z.infer<typeof IconSchema>;
|
||||
export type NodeI = z.infer<typeof NodeSchema>;
|
||||
export type ConnectorI = z.infer<typeof ConnectorSchema>;
|
||||
export type GroupI = z.infer<typeof GroupSchema>;
|
||||
export type IconInput = z.infer<typeof iconInput>;
|
||||
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: NodeI[], icons: IconI[]) => {
|
||||
return nodes.find((node) => {
|
||||
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) =>
|
||||
nodes.find((node) => {
|
||||
const validIcon = icons.find((icon) => node.iconId === icon.id);
|
||||
return !Boolean(validIcon);
|
||||
return !validIcon;
|
||||
});
|
||||
};
|
||||
|
||||
export const findInvalidConnector = (
|
||||
connectors: ConnectorI[],
|
||||
nodes: NodeI[]
|
||||
) => {
|
||||
return connectors.find((con) => {
|
||||
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 Boolean(!fromNode || !toNode);
|
||||
});
|
||||
};
|
||||
|
||||
export const findInvalidGroup = (groups: GroupI[], nodes: NodeI[]) => {
|
||||
return groups.find((grp) => {
|
||||
return grp.nodes.find((grpNodeId) => {
|
||||
const validNode = nodes.find((node) => node.id === grpNodeId);
|
||||
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) =>
|
||||
groups.find((grp) =>
|
||||
grp.nodes.find((grpNodeSchemaId) => {
|
||||
const validNode = nodes.find((node) => node.id === grpNodeSchemaId);
|
||||
return Boolean(!validNode);
|
||||
});
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const SceneSchema = z
|
||||
export const sceneInput = z
|
||||
.object({
|
||||
icons: z.array(IconSchema),
|
||||
nodes: z.array(NodeSchema),
|
||||
connectors: z.array(ConnectorSchema),
|
||||
groups: z.array(GroupSchema),
|
||||
icons: z.array(iconInput),
|
||||
nodes: z.array(nodeInput),
|
||||
connectors: z.array(connectorInput),
|
||||
groups: z.array(groupInput),
|
||||
gridSize: z.object({
|
||||
x: z.number(),
|
||||
y: z.number()
|
||||
})
|
||||
})
|
||||
.superRefine((scene, ctx) => {
|
||||
const invalidNode = findInvalidNode(scene.nodes, scene.icons);
|
||||
@@ -76,8 +77,8 @@ export const SceneSchema = z
|
||||
if (invalidNode) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["nodes", invalidNode.id],
|
||||
message: "Invalid node found in scene",
|
||||
path: ['nodes', invalidNode.id],
|
||||
message: 'Invalid node found in scene'
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -91,8 +92,8 @@ export const SceneSchema = z
|
||||
if (invalidConnector) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["connectors", invalidConnector.id],
|
||||
message: "Invalid connector found in scene",
|
||||
path: ['connectors', invalidConnector.id],
|
||||
message: 'Invalid connector found in scene'
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -103,10 +104,10 @@ export const SceneSchema = z
|
||||
if (invalidGroup) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["groups", invalidGroup.id],
|
||||
message: "Invalid group found in scene",
|
||||
path: ['groups', invalidGroup.id],
|
||||
message: 'Invalid group found in scene'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type SceneI = z.infer<typeof SceneSchema>;
|
||||
export type SceneInput = z.infer<typeof sceneInput>;
|
||||
|
||||
68
src/validation/tests/Scene.test.ts
Normal file
68
src/validation/tests/Scene.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
sceneInput,
|
||||
NodeInput,
|
||||
ConnectorInput,
|
||||
GroupInput,
|
||||
findInvalidNode,
|
||||
findInvalidConnector,
|
||||
findInvalidGroup
|
||||
} from '../SceneSchema';
|
||||
import { scene } from '../../tests/fixtures/scene';
|
||||
|
||||
describe('scene validation works correctly', () => {
|
||||
test('scene fixture is valid', () => {
|
||||
const result = sceneInput.safeParse(scene);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('finds invalid nodes in scene', () => {
|
||||
const { icons } = scene;
|
||||
const invalidNode = {
|
||||
id: 'invalidNode',
|
||||
iconId: 'doesntExist',
|
||||
position: { x: -1, y: -1 }
|
||||
};
|
||||
const nodes: NodeInput[] = [...scene.nodes, invalidNode];
|
||||
|
||||
const result = findInvalidNode(nodes, icons);
|
||||
|
||||
expect(result).toEqual(invalidNode);
|
||||
});
|
||||
|
||||
test('finds invalid connectors in scene', () => {
|
||||
const { nodes } = scene;
|
||||
const invalidConnector = {
|
||||
id: 'invalidConnector',
|
||||
from: 'node1',
|
||||
to: 'invalidNode',
|
||||
label: null
|
||||
};
|
||||
const connectors: ConnectorInput[] = [
|
||||
...scene.connectors,
|
||||
invalidConnector
|
||||
];
|
||||
|
||||
const result = findInvalidConnector(connectors, nodes);
|
||||
|
||||
expect(result).toEqual(invalidConnector);
|
||||
});
|
||||
|
||||
test('finds invalid groups in scene', () => {
|
||||
const { nodes } = scene;
|
||||
const invalidGroup = {
|
||||
id: 'invalidGroup',
|
||||
label: null,
|
||||
nodes: ['invalidNode', 'node1']
|
||||
};
|
||||
const groups: GroupInput[] = [...scene.groups, invalidGroup];
|
||||
|
||||
const result = findInvalidGroup(groups, nodes);
|
||||
|
||||
expect(result).toEqual(invalidGroup);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"src/*": [
|
||||
"./src/*"
|
||||
],
|
||||
},
|
||||
"outDir": "./dist",
|
||||
"noImplicitAny": true,
|
||||
"module": "es6",
|
||||
@@ -8,7 +13,11 @@
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"baseUrl": "./",
|
||||
},
|
||||
"files": ["./src/App.tsx"]
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user