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:
Mark Mankarious
2023-07-20 16:45:54 +01:00
committed by GitHub
parent fd88787eb1
commit 773473b58e
102 changed files with 6456 additions and 3352 deletions

32
.eslintrc Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2
}

14
.vscode/settings.json vendored Normal file
View 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,
}

View File

@@ -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
View File

File diff suppressed because it is too large Load Diff

View File

@@ -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%",

View File

@@ -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;

View 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>
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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;
}

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);

View 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>
);

View 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>
);

View 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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
});
};

View File

@@ -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>
);
};

View File

@@ -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 });
},
}));

View File

@@ -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,
};
};

View File

@@ -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 />

View 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: () => {}
};

View 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' };
}
};

View 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: () => {}
};

View File

@@ -0,0 +1,7 @@
import { InteractionReducer } from '../types';
export const Select: InteractionReducer = {
mousemove: () => {},
mousedown: () => {},
mouseup: () => {}
};

31
src/interaction/types.ts Normal file
View 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;
};

View 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
View 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
};
};

View File

@@ -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
}
};

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -1,13 +0,0 @@
import { ModeContext, Mouse } from "../types";
export class ModeBase {
ctx;
constructor(ctx: ModeContext) {
this.ctx = ctx;
}
entry(mouse: Mouse) {}
exit() {}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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) {}
}

View File

@@ -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;
};

View 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>
);
};

View File

@@ -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
View 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>
);

View File

@@ -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() {}
}

View File

@@ -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 });
};

View File

@@ -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>
);
};

View 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
};
};

View 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
};
};

View 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;
};

View 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
};
};

View File

@@ -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;

View File

@@ -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,
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 };
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
})
);
}
}

View 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
};
};

View 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
};
};

View 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;

View File

@@ -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;
// }

View File

@@ -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,

View File

@@ -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);
});
});

View 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] });
}
}
}));

View 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 });
}
}
}));

View File

@@ -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'
}
}
}
});

View File

@@ -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'] }]
};

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;
};

View File

@@ -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>;

View 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);
});
});

View File

@@ -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