refactor: migrate away from paperjs [PHASE 1]

This commit is contained in:
Mark Mankarious
2023-08-01 13:33:55 +01:00
committed by GitHub
parent 486ac09e61
commit 8e6995c615
50 changed files with 1036 additions and 949 deletions

View File

@@ -9,7 +9,7 @@ import {
NodeInput,
ConnectorInput,
GroupInput
} from 'src/validation/SceneInput';
} from 'src/types';
import { useSceneStore, Scene } from 'src/stores/useSceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/renderer/Renderer';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Add as AddIcon } from '@mui/icons-material';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { ContextMenu } from './components/ContextMenu';
import { ContextMenuItem } from './components/ContextMenuItem';
@@ -9,8 +9,14 @@ interface Props {
position: Coords;
}
export const EmptyTileContextMenu = ({ onAddNode, position }: Props) => (
<ContextMenu position={position}>
<ContextMenuItem onClick={onAddNode} icon={<AddIcon />} label="Add node" />
</ContextMenu>
);
export const EmptyTileContextMenu = ({ onAddNode, position }: Props) => {
return (
<ContextMenu position={position}>
<ContextMenuItem
onClick={onAddNode}
icon={<AddIcon />}
label="Add node"
/>
</ContextMenu>
);
};

View File

@@ -1,7 +1,7 @@
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 { Coords } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { getTileScreenPosition } from 'src/renderer/utils/gridHelpers';
import { useSceneStore } from 'src/stores/useSceneStore';
@@ -18,67 +18,68 @@ const ARROW = {
};
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);
return null;
// 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;
// const { position: scrollPosition } = scroll;
useEffect(() => {
if (!container.current) return;
// useEffect(() => {
// if (!container.current) return;
const screenPosition = getTileScreenPosition({
position,
scrollPosition,
zoom
});
// const screenPosition = getTileScreenPosition({
// position,
// scrollPosition,
// zoom
// });
gsap.to(container.current, {
duration: firstDisplay ? 0.1 : 0,
x: screenPosition.x,
y: screenPosition.y
});
// 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
});
}
// 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]);
// 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>
);
// 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

@@ -15,9 +15,15 @@ import { IconButton } from '../IconButton/IconButton';
export const ToolMenu = () => {
const theme = useTheme();
const zoom = useUiStateStore((state) => state.zoom);
const mode = useUiStateStore((state) => state.mode);
const uiStateStoreActions = useUiStateStore((state) => state.actions);
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
const uiStateStoreActions = useUiStateStore((state) => {
return state.actions;
});
return (
<Card
@@ -32,16 +38,25 @@ export const ToolMenu = () => {
<IconButton
name="Select"
Icon={<NearMeIcon />}
onClick={() =>
uiStateStoreActions.setMode({ type: 'CURSOR', mousedown: null })
}
onClick={() => {
return uiStateStoreActions.setMode({
type: 'CURSOR',
showCursor: true,
mousedown: null
});
}}
size={theme.customVars.toolMenu.height}
isActive={mode.type === 'CURSOR'}
/>
<IconButton
name="Pan"
Icon={<PanToolIcon />}
onClick={() => uiStateStoreActions.setMode({ type: 'PAN' })}
onClick={() => {
return uiStateStoreActions.setMode({
type: 'PAN',
showCursor: false
});
}}
size={theme.customVars.toolMenu.height}
isActive={mode.type === 'PAN'}
/>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Isoflow, { DefaultLabelContainer } from 'src/Isoflow';
import Isoflow from 'src/Isoflow';
import { Box, useTheme } from '@mui/material';
// eslint-disable-next-line import/no-extraneous-dependencies
import {

View File

@@ -5,7 +5,7 @@ import { CustomNode } from './CustomNode/CustomNode';
const examples = [
{ name: 'Basic Editor', component: BasicEditor },
{ name: 'Custom Node Label', component: CustomNode }
{ name: 'Live Diagrams', component: CustomNode }
];
export const Examples = () => {

View File

@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
export const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const onResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return windowSize;
};

View File

@@ -1,5 +1,5 @@
import { SidebarTypeEnum } from 'src/stores/useUiStateStore';
import { Coords } from 'src/utils/Coords';
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
import { getItemsByTile } from '../../renderer/utils/gridHelpers';
@@ -9,7 +9,7 @@ export const Cursor: InteractionReducer = {
if (
draftState.mouse.delta === null ||
draftState.mouse.delta?.tile.isEqual(Coords.zero())
CoordsUtils.isEqual(draftState.mouse.delta?.tile, CoordsUtils.zero())
)
return;
// User has moved tile since the last event
@@ -20,6 +20,7 @@ export const Cursor: InteractionReducer = {
// User's last mousedown action was on a node
draftState.mode = {
type: 'DRAG_ITEMS',
showCursor: true,
items: draftState.mode.mousedown.items
};
@@ -28,6 +29,7 @@ export const Cursor: InteractionReducer = {
draftState.mode = {
type: 'LASSO',
showCursor: false,
selection: {
startTile: draftState.mode.mousedown.tile,
endTile: draftState.mouse.position.tile,

View File

@@ -1,4 +1,4 @@
import { Coords } from 'src/utils/Coords';
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
export const DragItems: InteractionReducer = {
@@ -7,13 +7,13 @@ export const DragItems: InteractionReducer = {
if (
draftState.mouse.delta !== null &&
!draftState.mouse.delta.tile.isEqual(Coords.fromObject({ x: 0, y: 0 }))
!CoordsUtils.isEqual(draftState.mouse.delta.tile, CoordsUtils.zero())
) {
// User has moved tile since the last mouse event
draftState.mode.items.nodes.forEach((node) => {
const nodeIndex = draftState.scene.nodes.findIndex(
(sceneNode) => sceneNode.id === node.id
);
const nodeIndex = draftState.scene.nodes.findIndex((sceneNode) => {
return sceneNode.id === node.id;
});
if (nodeIndex === -1) return;
@@ -25,6 +25,6 @@ export const DragItems: InteractionReducer = {
},
mousedown: () => {},
mouseup: (draftState) => {
draftState.mode = { type: 'CURSOR', mousedown: null };
draftState.mode = { type: 'CURSOR', showCursor: true, mousedown: null };
}
};

View File

@@ -1,8 +1,5 @@
import { Coords } from 'src/utils/Coords';
import {
isWithinBounds,
getItemsByTileV2
} from 'src/renderer/utils/gridHelpers';
import { CoordsUtils } from 'src/utils';
import { isWithinBounds } from 'src/renderer/utils/gridHelpers';
import { InteractionReducer } from '../types';
export const Lasso: InteractionReducer = {
@@ -14,7 +11,7 @@ export const Lasso: InteractionReducer = {
if (
draftState.mouse.delta === null ||
draftState.mouse.delta.tile.isEqual(Coords.zero())
CoordsUtils.isEqual(draftState.mouse.delta.tile, CoordsUtils.zero())
)
return;
// User has moved tile since they moused down
@@ -22,7 +19,7 @@ export const Lasso: InteractionReducer = {
if (!draftState.mode.isDragging) {
const { mousedown } = draftState.mouse;
const items = draftState.scene.nodes.filter((node) => {
return node.position.isEqual(mousedown.tile);
return CoordsUtils.isEqual(node.position, mousedown.tile);
});
// User is creating a selection
@@ -37,9 +34,12 @@ export const Lasso: InteractionReducer = {
if (draftState.mode.isDragging) {
// User is dragging an existing selection
draftState.mode.selection.startTile =
draftState.mode.selection.startTile.add(draftState.mouse.delta.tile);
draftState.mode.selection.endTile = draftState.mode.selection.endTile.add(
draftState.mode.selection.startTile = CoordsUtils.add(
draftState.mode.selection.startTile,
draftState.mouse.delta.tile
);
draftState.mode.selection.endTile = CoordsUtils.add(
draftState.mode.selection.endTile,
draftState.mouse.delta.tile
);
}
@@ -56,6 +56,7 @@ export const Lasso: InteractionReducer = {
if (!isWithinSelection) {
draftState.mode = {
type: 'CURSOR',
showCursor: true,
mousedown: null
};
@@ -71,6 +72,7 @@ export const Lasso: InteractionReducer = {
draftState.mode = {
type: 'CURSOR',
showCursor: true,
mousedown: null
};
},

View File

@@ -1,3 +1,4 @@
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
export const Pan: InteractionReducer = {
@@ -6,7 +7,10 @@ export const Pan: InteractionReducer = {
if (draftState.mouse.mousedown !== null) {
draftState.scroll.position = draftState.mouse.delta?.screen
? draftState.scroll.position.add(draftState.mouse.delta.screen)
? CoordsUtils.add(
draftState.scroll.position,
draftState.mouse.delta.screen
)
: draftState.scroll.position;
}
},

View File

@@ -7,13 +7,11 @@ import {
Mouse
} from 'src/stores/useUiStateStore';
import { SortedSceneItems } from 'src/stores/useSceneStore';
import { Coords } from 'src/utils/Coords';
export interface State {
mode: Mode;
mouse: Mouse;
scroll: Scroll;
gridSize: Coords;
scene: SortedSceneItems;
contextMenu: ContextMenu;
itemControls: ItemControls;

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect } 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 { useUiStateStore, Mouse } from 'src/stores/useUiStateStore';
import { CoordsUtils, screenToIso } from 'src/utils';
import { DragItems } from './reducers/DragItems';
import { Pan } from './reducers/Pan';
import { Cursor } from './reducers/Cursor';
@@ -18,7 +17,6 @@ const reducers: { [k in string]: InteractionReducer } = {
};
export const useInteractionManager = () => {
const tool = useRef<paper.Tool>();
const mode = useUiStateStore((state) => {
return state.mode;
});
@@ -40,30 +38,52 @@ export const useInteractionManager = () => {
const scene = useSceneStore(({ nodes, connectors, groups }) => {
return { nodes, connectors, groups };
});
const gridSize = useSceneStore((state) => {
return state.gridSize;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const onMouseEvent = useCallback(
(
eventType: 'mousedown' | 'mousemove' | 'mouseup',
toolEvent: paper.ToolEvent
) => {
(e: MouseEvent) => {
const reducer = reducers[mode.type];
if (!reducer) return;
if (
!reducer ||
!(
e.type === 'mousemove' ||
e.type === 'mouseup' ||
e.type === 'mousedown'
)
)
return;
const reducerAction = reducer[eventType];
const reducerAction = reducer[e.type];
const nextMouse = toolEventToMouseEvent({
toolEvent,
gridSize,
scroll,
prevMouse: mouse
});
const newPosition: Mouse['position'] = {
screen: { x: e.clientX, y: e.clientY },
tile: screenToIso({ x: e.clientX, y: e.clientY })
};
const newDelta: Mouse['delta'] = {
screen: CoordsUtils.subtract(newPosition.screen, mouse.position.screen),
tile: CoordsUtils.subtract(newPosition.tile, mouse.position.tile)
};
const getMousedown = (): Mouse['mousedown'] => {
switch (e.type) {
case 'mousedown':
return newPosition;
case 'mousemove':
return mouse.mousedown;
default:
return null;
}
};
const nextMouse: Mouse = {
position: newPosition,
delta: newDelta,
mousedown: getMousedown()
};
const newState = produce(
{
@@ -71,7 +91,6 @@ export const useInteractionManager = () => {
mouse: nextMouse,
mode,
scroll,
gridSize,
contextMenu,
itemControls
},
@@ -90,8 +109,6 @@ export const useInteractionManager = () => {
[
mode,
scroll,
mouse,
gridSize,
itemControls,
uiStateActions,
sceneActions,
@@ -101,19 +118,14 @@ export const useInteractionManager = () => {
);
useEffect(() => {
tool.current = new Tool();
tool.current.onMouseMove = (ev: paper.ToolEvent) => {
return onMouseEvent('mousemove', ev);
};
tool.current.onMouseDown = (ev: paper.ToolEvent) => {
return onMouseEvent('mousedown', ev);
};
tool.current.onMouseUp = (ev: paper.ToolEvent) => {
return onMouseEvent('mouseup', ev);
};
window.addEventListener('mousemove', onMouseEvent);
window.addEventListener('mousedown', onMouseEvent);
window.addEventListener('mouseup', onMouseEvent);
return () => {
tool.current?.remove();
window.removeEventListener('mousemove', onMouseEvent);
window.removeEventListener('mousedown', onMouseEvent);
window.removeEventListener('mouseup', onMouseEvent);
};
}, [onMouseEvent]);
};

View File

@@ -1,105 +0,0 @@
import { Coords } from 'src/utils/Coords';
import { Mouse, Scroll } from 'src/stores/useUiStateStore';
import { getTileFromMouse } from 'src/renderer/utils/gridHelpers';
interface GetMousePositionFromToolEvent {
toolEvent: paper.ToolEvent;
gridSize: Coords;
scroll: Scroll;
}
const getMousePositionFromToolEvent = ({
toolEvent,
gridSize,
scroll
}: GetMousePositionFromToolEvent): Mouse['position'] => {
const screenPosition = Coords.fromObject(toolEvent.point);
const tile = getTileFromMouse({
mousePosition: screenPosition,
gridSize,
scroll
});
return {
screen: screenPosition,
tile
};
};
interface GetDeltaFromToolEvent {
currentPosition: Mouse['position'];
prevPosition: Mouse['position'];
}
const getDeltaFromToolEvent = ({
currentPosition,
prevPosition
}: GetDeltaFromToolEvent) => {
const delta = currentPosition.screen.subtract(prevPosition.screen);
if (delta.isEqual(Coords.zero())) {
return null;
}
return {
screen: delta,
tile: currentPosition.tile.subtract(prevPosition.tile)
};
};
interface GetMousedownFromToolEvent {
toolEvent: paper.ToolEvent;
currentTile: Coords;
prevMouse: Mouse;
}
const getMousedownFromToolEvent = ({
toolEvent,
currentTile,
prevMouse
}: GetMousedownFromToolEvent) => {
if (toolEvent.type === 'mousedown') {
return {
screen: Coords.fromObject(toolEvent.point),
tile: currentTile
};
}
if (toolEvent.type === 'mousemove') {
return prevMouse.mousedown;
}
return null;
};
type ToolEventToMouseEvent = GetMousePositionFromToolEvent & {
prevMouse: Mouse;
};
export const toolEventToMouseEvent = ({
toolEvent,
gridSize,
scroll,
prevMouse
}: ToolEventToMouseEvent): Mouse => {
const position = getMousePositionFromToolEvent({
toolEvent,
gridSize,
scroll
});
const delta = getDeltaFromToolEvent({
currentPosition: position,
prevPosition: prevMouse.position
});
const mousedown = getMousedownFromToolEvent({
toolEvent,
currentTile: position.tile,
prevMouse
});
return {
position,
delta,
mousedown
};
};

View File

@@ -1,27 +1,21 @@
import React, { useEffect, useState } from 'react';
import Paper from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import React from 'react';
import { Box } from '@mui/material';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { OriginEnum, getTilePosition } from 'src/utils';
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 { getTilePosition } from './utils/gridHelpers';
import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer';
import { Lasso } from './components/Lasso/Lasso';
import { Connector } from './components/Connector/Connector';
import { Group } from './components/Group/Group';
import { TILE_SIZE } from './utils/constants';
// import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer';
import { Grid } from './components/Grid/Grid';
import { Cursor } from './components/Cursor/Cursor';
import { NodeV2 } from './components/Node/NodeV2';
const InitialisedRenderer = () => {
const renderer = useRenderer();
const [isReady, setIsReady] = useState(false);
export const Renderer = () => {
const scene = useSceneStore(({ nodes, connectors, groups }) => {
return { nodes, connectors, groups };
});
const gridSize = useSceneStore((state) => {
return state.gridSize;
const icons = useSceneStore((state) => {
return state.icons;
});
const mode = useUiStateStore((state) => {
return state.mode;
@@ -35,73 +29,36 @@ const InitialisedRenderer = () => {
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const { activeLayer } = Paper.project;
useInteractionManager();
const {
init: initRenderer,
zoomTo,
container: rendererContainer,
scrollTo
} = renderer;
const { position: scrollPosition } = scroll;
useEffect(() => {
initRenderer(gridSize);
setIsReady(true);
return () => {
if (activeLayer) gsap.killTweensOf(activeLayer.view);
};
}, [initRenderer, activeLayer, gridSize.toString()]);
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 } = mouse.position;
const tilePosition = getTilePosition(tile);
renderer.cursor.moveTo(tilePosition);
}, [
mode,
mouse,
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, renderer.cursor]);
if (!isReady) return null;
return (
<>
{mode.type === 'LASSO' && (
<Box
sx={{
width: '100%',
height: '100%'
}}
>
<Grid tileSize={TILE_SIZE * zoom} scroll={scroll.position} />
{mode.showCursor && (
<Cursor
position={getTilePosition(mouse.position.tile, OriginEnum.TOP)}
tileSize={TILE_SIZE * zoom}
/>
)}
{scene.nodes.map((node) => {
return (
<NodeV2
key={node.id}
position={getTilePosition(node.position, OriginEnum.BOTTOM)}
iconUrl={
icons.find((icon) => {
return icon.id === node.iconId;
})?.url
}
/>
);
})}
{/* {mode.type === 'LASSO' && (
<Lasso
parentContainer={renderer.lassoContainer.current as paper.Group}
startTile={mode.selection.startTile}
@@ -134,16 +91,7 @@ const InitialisedRenderer = () => {
parentContainer={renderer.nodeManager.container as paper.Group}
/>
);
})}
</>
);
};
export const Renderer = () => {
return (
<Initialiser>
<InitialisedRenderer />
<ContextMenuLayer />
</Initialiser>
})} */}
</Box>
);
};

View File

@@ -12,9 +12,6 @@ interface ConnectorProps {
export const Connector = ({ parentContainer, connector }: ConnectorProps) => {
const { init, updateFromTo, updateColor } = useConnector();
const gridSize = useSceneStore((state) => {
return state.gridSize;
});
const nodes = useSceneStore((state) => {
return state.nodes;
});
@@ -40,8 +37,8 @@ export const Connector = ({ parentContainer, connector }: ConnectorProps) => {
if (!fromNode || !toNode) return;
updateFromTo(gridSize, fromNode.position, toNode.position);
}, [gridSize, nodes, connector, updateFromTo]);
updateFromTo({ x: 100, y: 100 }, fromNode.position, toNode.position);
}, [nodes, connector, updateFromTo]);
return null;
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import { Group, Path } from 'paper';
import { pathfinder } from 'src/renderer/utils/pathfinder';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { getTileBounds } from 'src/renderer/utils/gridHelpers';
export const useConnector = () => {

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import gsap from 'gsap';
import { Box, useTheme } from '@mui/material';
import { getCSSMatrix } from 'src/renderer/utils/projection';
interface Props {
position: { x: number; y: number };
tileSize: number;
}
// TODO: Remove tilesize
export const Cursor = ({ position, tileSize }: Props) => {
const theme = useTheme();
const [isReady, setIsReady] = useState(false);
const ref = useRef<SVGElement>();
const setPosition = useCallback(
({
position: _position,
animationDuration = 0.15
}: {
position: { x: number; y: number };
animationDuration?: number;
}) => {
if (!ref.current) return;
gsap.to(ref.current, {
duration: animationDuration,
left: _position.x,
top: _position.y
});
},
[]
);
useEffect(() => {
if (!ref.current || !isReady) return;
setPosition({ position });
}, [position, setPosition, isReady]);
useEffect(() => {
if (!ref.current || isReady) return;
gsap.killTweensOf(ref.current);
setPosition({ position, animationDuration: 0 });
ref.current.style.opacity = '1';
setIsReady(true);
}, [position, setPosition, isReady]);
return (
<Box
ref={ref}
component="svg"
sx={{
position: 'absolute',
transform: getCSSMatrix({ x: -(tileSize / 2), y: -(tileSize / 2) }),
opacity: 0
}}
width={tileSize}
height={tileSize}
>
<rect
width={tileSize}
height={tileSize}
fill={theme.palette.primary.main}
opacity={0.7}
rx={10}
/>
</Box>
);
};

View File

@@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { TILE_SIZE, PIXEL_UNIT } from '../../utils/constants';
import { applyProjectionMatrix } from '../../utils/projection';
@@ -33,10 +33,10 @@ export const useCursor = () => {
const moveTo = useCallback(
(position: Coords, opts?: { animationDuration?: number }) => {
const tweenProxy = new Coords(
container.current.position.x,
container.current.position.y
);
const tweenProxy = {
x: container.current.position.x,
y: container.current.position.y
};
gsap.to(tweenProxy, {
duration: opts?.animationDuration || 0.1,

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { Box } from '@mui/material';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { getCSSMatrix } from 'src/renderer/utils/projection';
import { useWindowSize } from 'src/hooks/useWindowSize';
interface Props {
tileSize: number;
scroll: { x: number; y: number };
}
export const Grid = ({ tileSize: _tileSize, scroll }: Props) => {
const windowSize = useWindowSize();
const tileSize = _tileSize;
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none'
}}
>
<Box
sx={{
position: 'absolute',
width: '300%',
height: '300%',
left: '-100%',
top: '-100%',
transform: `${getCSSMatrix()}`
}}
>
<Box component="svg" width="100%" height="100%">
{/* <pattern
id="dotpattern"
x={`calc(50% - ${tileSize * 0.5}px)`}
y={`calc(50% - ${tileSize * 0.5}px)`}
width={tileSize}
height={tileSize}
patternUnits="userSpaceOnUse"
>
<circle cx="0" cy="0" r={2} fill="rgba(0, 0, 0, 0.3)" />
</pattern> */}
<pattern
id="gridpattern"
x={`${windowSize.width * 1.5 - tileSize * 0.5}px`}
y={`${windowSize.height * 1.5 - tileSize * 0.5}px`}
width={tileSize}
height={tileSize}
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width={tileSize}
height={tileSize}
strokeWidth={1}
stroke="rgba(0, 0, 0, 0.3)"
fill="none"
/>
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#gridpattern)"
/>
</Box>
</Box>
</Box>
);
};

View File

@@ -1,67 +0,0 @@
import { useCallback, useRef } from 'react';
import { Path, Point, Group } from 'paper';
import { Coords } from 'src/utils/Coords';
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 init = useCallback((gridSize: Coords) => {
container.current.removeChildren();
const grid = drawGrid(gridSize.x, gridSize.y);
container.current.addChild(grid);
return container.current;
}, []);
return {
init,
container: container.current
};
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useRef } from 'react';
import { Group, Shape } from 'paper';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { PIXEL_UNIT, TILE_SIZE } from 'src/renderer/utils/constants';
import { getColorVariant } from 'src/utils';
import {
@@ -26,7 +26,7 @@ export const useGroup = () => {
const setTiles = useCallback((tiles: Coords[]) => {
if (!pathRef.current) return;
const corners = getBoundingBox(tiles, new Coords(1, 1));
const corners = getBoundingBox(tiles, { x: 1, y: 1 });
if (corners === null) {
containerRef.current.removeChildren();
@@ -34,10 +34,10 @@ export const useGroup = () => {
}
const sorted = sortByPosition(corners);
const size = new Coords(
sorted.highX - sorted.lowX,
sorted.highY - sorted.lowY
);
const size = {
x: sorted.highX - sorted.lowX,
y: sorted.highY - sorted.lowY
};
pathRef.current.set({
position: [0, 0],

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { useLasso } from './useLasso';
interface Props {

View File

@@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
import {
getBoundingBox,
@@ -25,11 +25,11 @@ export const useLasso = () => {
const lassoStartTile = boundingBox[3];
const lassoScreenPosition = getTileBounds(lassoStartTile).left;
const sorted = sortByPosition(boundingBox);
const position = new Coords(sorted.lowX, sorted.highY);
const size = new Coords(
sorted.highX - sorted.lowX,
sorted.highY - sorted.lowY
);
const position = { x: sorted.lowX, y: sorted.highY };
const size = {
x: sorted.highX - sorted.lowX,
y: sorted.highY - sorted.lowY
};
shapeRef.current.set({
position,

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { Group } from 'paper';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { Node as NodeInterface } from 'src/stores/useSceneStore';
import { useNodeIcon } from './useNodeIcon';
@@ -63,7 +62,7 @@ export const Node = ({ node, parentContainer }: NodeProps) => {
useEffect(() => {
if (!isIconLoaded) return;
const tweenValues = Coords.fromObject(groupRef.current.position);
const tweenValues = groupRef.current.position;
const endState = getTilePosition(node.position);
gsap.to(tweenValues, {

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
interface Props {
iconUrl?: string;
position: { x: number; y: number };
}
export const NodeV2 = ({ iconUrl, position }: Props) => {
const ref = useRef<HTMLImageElement>();
const setPosition = useCallback(
({
position: _position,
animationDuration = 0.15
}: {
position: { x: number; y: number };
animationDuration?: number;
}) => {
if (!ref.current) return;
gsap.to(ref.current, {
duration: animationDuration,
x: _position.x - PROJECTED_TILE_DIMENSIONS.width / 2,
y:
_position.y -
PROJECTED_TILE_DIMENSIONS.height / 2 -
ref.current.height
});
},
[]
);
useEffect(() => {
if (!ref.current) return;
setPosition({ position });
}, [position, setPosition]);
const onImageLoaded = useCallback(() => {
if (!ref.current) return;
gsap.killTweensOf(ref.current);
setPosition({ position, animationDuration: 0 });
ref.current.style.opacity = '1';
}, [position, setPosition]);
return (
<Box
ref={ref}
onLoad={onImageLoaded}
component="img"
src={iconUrl}
sx={{
position: 'absolute',
width: PROJECTED_TILE_DIMENSIONS.width,
pointerEvents: 'none',
opacity: 0
}}
/>
);
};

View File

@@ -16,7 +16,7 @@ export const useLabelConnector = () => {
pathRef.current.segments[1].point.y = -(
labelHeight +
PROJECTED_TILE_DIMENSIONS.y * 0.5
PROJECTED_TILE_DIMENSIONS.height * 0.5
);
}, []);

View File

@@ -8,14 +8,18 @@ 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 icons = useSceneStore((state) => {
return state.icons;
});
const update = useCallback(
async (iconId: string) => {
setIsLoaded(false);
container.current.removeChildren();
const icon = icons.find((_icon) => _icon.id === iconId);
const icon = icons.find((_icon) => {
return _icon.id === iconId;
});
if (!icon) return;
@@ -26,7 +30,7 @@ export const useNodeIcon = () => {
if (!container.current) return;
iconRaster.scale(
(PROJECTED_TILE_DIMENSIONS.x - NODE_IMG_PADDING) /
(PROJECTED_TILE_DIMENSIONS.width - NODE_IMG_PADDING) /
iconRaster.bounds.width
);
@@ -36,7 +40,7 @@ export const useNodeIcon = () => {
container.current.pivot = iconRaster.bounds.bottomCenter;
container.current.position = new Point(
0,
PROJECTED_TILE_DIMENSIONS.y * 0.5
PROJECTED_TILE_DIMENSIONS.height * 0.5
);
resolve(null);

View File

@@ -1,10 +1,9 @@
import { useCallback, useRef } from 'react';
import Paper, { Group } from 'paper';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { useGrid } from './components/Grid/useGrid';
import { CoordsUtils } from 'src/utils';
import { useNodeManager } from './useNodeManager';
import { useCursor } from './components/Cursor/useCursor';
import { useGroupManager } from './useGroupManager';
import { useConnectorManager } from './useConnectorManager';
@@ -13,52 +12,36 @@ export const useRenderer = () => {
const innerContainer = useRef(new Group());
// TODO: Store layers in a giant ref object called layers? layers = { lasso: new Group(), grid: new Group() etc }
const lassoContainer = useRef(new Group());
const grid = useGrid();
const nodeManager = useNodeManager();
const connectorManager = useConnectorManager();
const groupManager = useGroupManager();
const cursor = useCursor();
const uiStateActions = useUiStateStore((state) => {
return 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(
(gridSize: Coords) => {
// TODO: Grid and Cursor should be initialised in their JSX components (create if they don't exist)
// to be inline with other initialisation patterns
const gridContainer = initGrid(gridSize);
const cursorContainer = initCursor();
const init = useCallback(() => {
// TODO: Grid and Cursor should be initialised in their JSX components (create if they don't exist)
// to be inline with other initialisation patterns
innerContainer.current.addChild(gridContainer);
innerContainer.current.addChild(groupManager.container);
innerContainer.current.addChild(cursorContainer);
innerContainer.current.addChild(lassoContainer.current);
innerContainer.current.addChild(connectorManager.container);
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,
groupManager.container
]
);
// innerContainer.current.addChild(gridContainer);
innerContainer.current.addChild(groupManager.container);
innerContainer.current.addChild(lassoContainer.current);
innerContainer.current.addChild(connectorManager.container);
innerContainer.current.addChild(nodeManager.container);
container.current.addChild(innerContainer.current);
container.current.set({ position: [0, 0] });
Paper.project.activeLayer.addChild(container.current);
setScroll({
position: CoordsUtils.zero(),
offset: CoordsUtils.zero()
});
}, [setScroll, nodeManager.container, groupManager.container]);
const scrollTo = useCallback((to: Coords) => {
const { center: viewCenter } = Paper.project.view.bounds;
@@ -74,13 +57,11 @@ export const useRenderer = () => {
return {
init,
container,
grid,
zoomTo,
scrollTo,
nodeManager,
groupManager,
lassoContainer,
connectorManager,
cursor
connectorManager
};
};

View File

@@ -1,9 +1,9 @@
import { Coords } from 'src/utils/Coords';
import { Size } from 'src/types';
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 TILE_SIZE = 100;
export const PROJECTED_TILE_DIMENSIONS: Size = {
width: TILE_SIZE * 1.415,
height: TILE_SIZE * 0.819
};
export const PIXEL_UNIT = TILE_SIZE * 0.02;
export const SCALING_CONST = 0.9425;

View File

@@ -1,12 +1,12 @@
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 { Coords } from 'src/types';
import { clamp, CoordsUtils } from 'src/utils';
import { SortedSceneItems, Node } from 'src/stores/useSceneStore';
import { Scroll } from 'src/stores/useUiStateStore';
const halfW = PROJECTED_TILE_DIMENSIONS.x * 0.5;
const halfH = PROJECTED_TILE_DIMENSIONS.y * 0.5;
const halfW = PROJECTED_TILE_DIMENSIONS.width * 0.5;
const halfH = PROJECTED_TILE_DIMENSIONS.height * 0.5;
interface GetTileFromMouse {
mousePosition: Coords;
@@ -19,13 +19,15 @@ export const getTileFromMouse = ({
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
);
const canvasPosition = {
x: mousePosition.x - (scroll.position.x + Paper.view.bounds.center.x),
y:
mousePosition.y - (scroll.position.y + Paper.view.bounds.center.y) + halfH
};
const row = Math.floor(
(canvasPosition.x / halfW + canvasPosition.y / halfH) / 2
((mousePosition.x - scroll.position.x) / halfW + canvasPosition.y / halfH) /
2
);
const col = Math.floor(
(canvasPosition.y / halfH - canvasPosition.x / halfW) / 2
@@ -34,34 +36,37 @@ export const getTileFromMouse = ({
const halfRowNum = Math.floor(gridSize.x * 0.5);
const halfColNum = Math.floor(gridSize.y * 0.5);
return new Coords(
clamp(row, -halfRowNum, halfRowNum),
clamp(col, -halfColNum, halfColNum)
);
return {
x: clamp(row, -halfRowNum, halfRowNum),
y: clamp(col, -halfColNum, halfColNum)
};
};
export const getTilePosition = ({ x, y }: Coords) => {
return new Coords(x * halfW - y * halfW, x * halfH + y * halfH);
return { x: x * halfW - y * halfW, y: x * halfH + y * halfH };
};
export const getTileBounds = (coords: Coords) => {
const position = getTilePosition(coords);
return {
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)
left: {
x: position.x - PROJECTED_TILE_DIMENSIONS.width * 0.5,
y: position.y
},
right: {
x: position.x + PROJECTED_TILE_DIMENSIONS.width * 0.5,
y: position.y
},
top: {
x: position.x,
y: position.y - PROJECTED_TILE_DIMENSIONS.height * 0.5
},
bottom: {
x: position.x,
y: position.y + PROJECTED_TILE_DIMENSIONS.height * 0.5
},
center: { x: position.x, y: position.y }
};
};
@@ -76,7 +81,7 @@ export const getItemsByTile = ({
sortedSceneItems
}: GetItemsByTile): { nodes: Node[] } => {
const nodes = sortedSceneItems.nodes.filter((node) => {
return node.position.isEqual(tile);
return CoordsUtils.isEqual(node.position, tile);
});
return { nodes };
@@ -89,7 +94,7 @@ interface GetItemsByTileV2 {
export const getItemsByTileV2 = ({ tile, sceneItems }: GetItemsByTileV2) => {
return sceneItems.filter((item) => {
return item.position.isEqual(tile);
return CoordsUtils.isEqual(item.position, tile);
});
};
@@ -109,23 +114,25 @@ export const canvasCoordsToScreenCoords = ({
Paper.project.view.element;
const container = Paper.project.activeLayer.children[0];
const globalItemsGroupPosition = container.globalToLocal([0, 0]);
const onScreenPosition = new Coords(
(position.x +
scrollPosition.x +
globalItemsGroupPosition.x +
container.position.x +
viewW * 0.5) *
zoom +
const onScreenPosition = {
x:
(position.x +
scrollPosition.x +
globalItemsGroupPosition.x +
container.position.x +
viewW * 0.5) *
zoom +
offsetX,
(position.y +
scrollPosition.y +
globalItemsGroupPosition.y +
container.position.y +
viewH * 0.5) *
zoom +
y:
(position.y +
scrollPosition.y +
globalItemsGroupPosition.y +
container.position.y +
viewH * 0.5) *
zoom +
offsetY
);
};
return onScreenPosition;
};
@@ -191,7 +198,7 @@ export const getGridSubset = (tiles: Coords[]) => {
for (let x = lowX; x < highX + 1; x += 1) {
for (let y = lowY; y < highY + 1; y += 1) {
subset.push(new Coords(x, y));
subset.push({ x, y });
}
}
@@ -208,7 +215,7 @@ export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
// passed in (at least 1 tile needed)
export const getBoundingBox = (
tiles: Coords[],
offset: Coords = new Coords(0, 0)
offset: Coords = CoordsUtils.zero()
) => {
if (tiles.length === 0) {
return null;
@@ -217,9 +224,9 @@ export const getBoundingBox = (
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)
{ x: lowX - offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: highY + offset.y },
{ x: lowX - offset.x, y: highY + offset.y }
];
};

View File

@@ -1,5 +1,5 @@
import PF from 'pathfinding';
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
// TODO1: This file is a mess, refactor it
// TODO: Have one single place for utils
@@ -10,18 +10,18 @@ export const pathfinder = (gridSize: Coords) => {
diagonalMovement: PF.DiagonalMovement.Always
});
const convertToGridXY = ({ x, y }: Coords) => {
return new Coords(
x + Math.floor(gridSize.x * 0.5),
y + Math.floor(gridSize.y * 0.5)
);
const convertToGridXY = ({ x, y }: Coords): Coords => {
return {
x: x + Math.floor(gridSize.x * 0.5),
y: y + Math.floor(gridSize.y * 0.5)
};
};
const convertToSceneXY = ({ x, y }: Coords) => {
return new Coords(
x - Math.floor(gridSize.x * 0.5),
y - Math.floor(gridSize.y * 0.5)
);
const convertToSceneXY = ({ x, y }: Coords): Coords => {
return {
x: x - Math.floor(gridSize.x * 0.5),
y: y - Math.floor(gridSize.y * 0.5)
};
};
const setWalkableAt = (coords: Coords, isWalkable: boolean) => {
@@ -29,7 +29,7 @@ export const pathfinder = (gridSize: Coords) => {
grid.setWalkableAt(x, y, isWalkable);
};
const findPath = (tiles: Coords[]) => {
const findPath = (tiles: Coords[]): Coords[] => {
const normalisedRoute = tiles.map((tile) => {
return convertToGridXY(tile);
});
@@ -56,7 +56,7 @@ export const pathfinder = (gridSize: Coords) => {
}, [] as number[][]);
return path.map((tile) => {
return convertToSceneXY(new Coords(tile[0], tile[1]));
return convertToSceneXY({ x: tile[0], y: tile[1] });
});
};

View File

@@ -23,3 +23,9 @@ export const applyProjectionMatrix = (
return matrix;
};
export const getCSSMatrix = (
translate: { x: number; y: number } = { x: 0, y: 0 }
) => {
return `translate(${translate.x}px, ${translate.y}px) matrix(0.707, 0.409, -0.707, 0.409, 0, -0.816)`;
};

View File

@@ -1,4 +1,4 @@
import { Coords } from 'src/utils/Coords';
import { Coords } from 'src/types';
import { getGridSubset, isWithinBounds } from '../gridHelpers';
jest.mock('paper', () => {
@@ -7,27 +7,33 @@ jest.mock('paper', () => {
describe('Tests gridhelpers', () => {
test('getGridSubset() works correctly', () => {
const gridSubset = getGridSubset([new Coords(5, 5), new Coords(7, 7)]);
const gridSubset = getGridSubset([
{ x: 5, y: 5 },
{ x: 7, y: 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)
{ x: 5, y: 5 },
{ x: 5, y: 6 },
{ x: 5, y: 7 },
{ x: 6, y: 5 },
{ x: 6, y: 6 },
{ x: 6, y: 7 },
{ x: 7, y: 5 },
{ x: 7, y: 6 },
{ x: 7, y: 7 }
]);
});
test('isWithinBounds() works correctly', () => {
const BOUNDS = [new Coords(4, 4), new Coords(6, 6)];
const bounds: Coords[] = [
{ x: 4, y: 4 },
{ x: 6, y: 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);
const withinBounds = isWithinBounds({ x: 5, y: 5 }, bounds);
const onBorder = isWithinBounds({ x: 4, y: 4 }, bounds);
const outsideBounds = isWithinBounds({ x: 3, y: 3 }, bounds);
expect(withinBounds).toBe(true);
expect(onBorder).toBe(true);

View File

@@ -3,15 +3,9 @@ import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import { produce } from 'immer';
import { NODE_DEFAULTS } from 'src/utils/config';
import { IconInput } from '../validation/SceneInput';
import { Coords } from '../utils/Coords';
import { IconInput, SceneItemTypeEnum, Coords } from 'src/types';
// TODO: Move all types into a types file for easier access and less mental load over where to look
export enum SceneItemTypeEnum {
NODE = 'NODE',
CONNECTOR = 'CONNECTOR',
GROUP = 'GROUP'
}
export interface Node {
type: SceneItemTypeEnum.NODE;
@@ -57,7 +51,6 @@ export interface SortedSceneItems {
// TODO: This typing is super confusing to work with
export type Scene = SortedSceneItems & {
icons: IconInput[];
gridSize: Coords;
};
export interface SceneActions {
@@ -78,7 +71,6 @@ export const useSceneStore = create<UseSceneStore>((set, get) => {
connectors: [],
groups: [],
icons: [],
gridSize: new Coords(51, 51),
actions: {
set: (scene) => {
set(scene);

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { clamp, roundToOneDecimalPlace } from 'src/utils';
import { Coords } from 'src/utils/Coords';
import { SortedSceneItems, SceneItem, Node } from 'src/stores/useSceneStore';
import { clamp, roundToOneDecimalPlace, CoordsUtils } from 'src/utils';
import { Coords } from 'src/types';
import { SceneItem, Node } from 'src/stores/useSceneStore';
// TODO: Move into the defaults file
const ZOOM_INCREMENT = 0.2;
@@ -41,6 +41,7 @@ export interface Mouse {
// TODO: Extract modes into own file for simplicity
export interface CursorMode {
type: 'CURSOR';
showCursor: boolean;
mousedown: {
items: { nodes: Node[] };
tile: Coords;
@@ -49,10 +50,12 @@ export interface CursorMode {
export interface PanMode {
type: 'PAN';
showCursor: boolean;
}
export interface LassoMode {
type: 'LASSO'; // TODO: Put these into an enum
showCursor: boolean;
selection: {
startTile: Coords;
endTile: Coords;
@@ -63,6 +66,7 @@ export interface LassoMode {
export interface DragItemsMode {
type: 'DRAG_ITEMS';
showCursor: boolean;
items: { nodes: Node[] };
}
@@ -108,18 +112,19 @@ export const useUiStateStore = create<UseUiStateStore>((set, get) => {
return {
mode: {
type: 'CURSOR',
showCursor: true,
mousedown: null
},
mouse: {
position: { screen: new Coords(0, 0), tile: new Coords(0, 0) },
position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() },
mousedown: null,
delta: null
},
itemControls: null,
contextMenu: null,
scroll: {
position: new Coords(0, 0),
offset: new Coords(0, 0)
position: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
},
zoom: 1,
actions: {

View File

@@ -1,10 +1,6 @@
import { SceneInput } from 'src/validation/SceneInput';
import { SceneInput } from 'src/types';
export const scene: SceneInput = {
gridSize: {
width: 10,
height: 10
},
icons: [
{
id: 'icon1',

9
src/types/common.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface Coords {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}

3
src/types/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './common';
export * from './inputs';
export * from './scene';

20
src/types/inputs.ts Normal file
View File

@@ -0,0 +1,20 @@
import z from 'zod';
import {
iconInput,
nodeInput,
connectorInput,
groupInput
} from 'src/validation/sceneItems';
export type IconInput = z.infer<typeof iconInput>;
export type NodeInput = z.infer<typeof nodeInput> & {
labelElement?: React.ReactNode;
};
export type ConnectorInput = z.infer<typeof connectorInput>;
export type GroupInput = z.infer<typeof groupInput>;
export type SceneInput = {
icons: IconInput[];
nodes: NodeInput[];
connectors: ConnectorInput[];
groups: GroupInput[];
};

5
src/types/scene.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum SceneItemTypeEnum {
NODE = 'NODE',
CONNECTOR = 'CONNECTOR',
GROUP = 'GROUP'
}

View File

@@ -1,71 +0,0 @@
export class Coords {
x: number = 0;
y: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
set(x: number, y: number) {
this.x = x;
this.y = y;
}
setX(x: number) {
this.x = x;
}
setY(y: number) {
this.y = y;
}
isEqual(comparator: Coords) {
return this.x === comparator.x && this.y === comparator.y;
}
subtract(operand: Coords) {
return new Coords(this.x - operand.x, this.y - operand.y);
}
subtractX(operand: number) {
return new Coords(this.x - operand, this.y);
}
subtractY(operand: number) {
return new Coords(this.x, this.y - operand);
}
add(operand: Coords) {
return new Coords(this.x + operand.x, this.y + operand.y);
}
addX(operand: number) {
return new Coords(this.x + operand, this.y);
}
addY(operand: number) {
return new Coords(this.x, this.y + operand);
}
clone() {
return new Coords(this.x, this.y);
}
toString() {
return `x: ${this.x}, y: ${this.y}`;
}
toObject() {
return { x: this.x, y: this.y };
}
static fromObject({ x, y }: { x: number; y: number }) {
return new Coords(x, y);
}
static zero() {
return new Coords(0, 0);
}
}

27
src/utils/CoordsUtils.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Coords } from 'src/types';
export class CoordsUtils {
static isEqual(base: Coords, operand: Coords) {
return base.x === operand.x && base.y === operand.y;
}
static subtract(base: Coords, operand: Coords): Coords {
return { x: base.x - operand.x, y: base.y - operand.y };
}
static add(base: Coords, operand: Coords): Coords {
return { x: base.x + operand.x, y: base.y + operand.y };
}
static multiply(base: Coords, operand: Coords): Coords {
return { x: base.x * operand.x, y: base.y * operand.y };
}
static toString(coords: Coords) {
return `x: ${coords.x}, y: ${coords.y}`;
}
static zero() {
return { x: 0, y: 0 };
}
}

223
src/utils/common.ts Normal file
View File

@@ -0,0 +1,223 @@
import gsap from 'gsap';
import {
Coords,
SceneInput,
NodeInput,
ConnectorInput,
GroupInput,
SceneItemTypeEnum
} from 'src/types';
import { customVars } from 'src/styles/theme';
import chroma from 'chroma-js';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { Scene, Node, Connector, Group } from 'src/stores/useSceneStore';
import { NODE_DEFAULTS } from 'src/utils/config';
import { CoordsUtils } from 'src/utils';
export const clamp = (num: number, min: number, max: number) => {
// eslint-disable-next-line no-nested-ternary
return 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.
return {
x: coords.x === 0 ? 0.000001 : coords.x,
y: coords.y === 0 ? 0.000001 : coords.y
};
};
export const getRandom = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min) + min);
};
export const tweenPosition = (
item: paper.Item,
{ x, y, duration }: { x: number; y: number; duration: number }
) => {
// paperjs doesn't like it when you try to tween the position of an item directly,
// so we have to use a proxy object
const currPosition = {
x: item.position.x,
y: item.position.y
};
gsap.to(currPosition, {
duration,
overwrite: 'auto',
x,
y,
onUpdate: () => {
item.set({ position: currPosition });
}
});
};
export const roundToOneDecimalPlace = (num: number) => {
return Math.round(num * 10) / 10;
};
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
labelElement: nodeInput.labelElement,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
color: nodeInput.color ?? NODE_DEFAULTS.color,
iconId: nodeInput.iconId,
position: nodeInput.position,
isSelected: false
};
};
export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds
};
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput
): Connector => {
return {
type: SceneItemTypeEnum.CONNECTOR,
id: connectorInput.id,
color: connectorInput.color ?? customVars.diagramPalette.blue,
from: connectorInput.from,
to: connectorInput.to
};
};
export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const groups = sceneInput.groups.map((groupInput) => {
return groupInputToGroup(groupInput);
});
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput);
});
return {
...sceneInput,
nodes,
groups,
connectors,
icons: sceneInput.icons
} as Scene;
};
export const sceneToSceneInput = (scene: Scene): SceneInput => {
const nodes: SceneInput['nodes'] = scene.nodes.map((node) => {
return {
id: node.id,
position: node.position,
label: node.label,
labelHeight: node.labelHeight,
color: node.color,
iconId: node.iconId
};
});
return {
nodes,
connectors: [],
groups: [],
icons: scene.icons
} as SceneInput;
};
interface GetColorVariantOpts {
alpha?: number;
grade?: number;
}
export const getColorVariant = (
color: string,
variant: 'light' | 'dark',
{ alpha = 1, grade = 0 }: GetColorVariantOpts
) => {
switch (variant) {
case 'light':
return chroma(color)
.brighten(grade ?? 1)
.alpha(alpha)
.css();
case 'dark':
return chroma(color)
.darken(grade ?? 1)
.alpha(alpha)
.css();
default:
return chroma(color).alpha(alpha).css();
}
};
export const screenToIso = ({ x, y }: { x: number; y: number }) => {
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const halfW = PROJECTED_TILE_DIMENSIONS.width / 2;
const halfH = PROJECTED_TILE_DIMENSIONS.height / 2;
// The origin is the center of the project.
const projectPosition = {
x: x - editorWidth * 0.5,
y: y - editorHeight * 0.5
};
const tile = {
x: Math.floor(
(projectPosition.x + halfW) / PROJECTED_TILE_DIMENSIONS.width -
projectPosition.y / PROJECTED_TILE_DIMENSIONS.height
),
y: -Math.floor(
(projectPosition.y + halfH) / PROJECTED_TILE_DIMENSIONS.height +
projectPosition.x / PROJECTED_TILE_DIMENSIONS.width
)
};
return tile;
};
export enum OriginEnum {
CENTER = 'CENTER',
TOP = 'TOP',
BOTTOM = 'BOTTOM',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}
export const getTilePosition = (
{ x, y }: { x: number; y: number },
origin: OriginEnum = OriginEnum.CENTER
) => {
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const halfW = PROJECTED_TILE_DIMENSIONS.width / 2;
const halfH = PROJECTED_TILE_DIMENSIONS.height / 2;
const position: Coords = {
x: editorWidth * 0.5 + (halfW * x - halfW * y),
y: editorHeight * 0.5 - (halfH * x + halfH * y) + halfH
};
switch (origin) {
case OriginEnum.TOP:
return CoordsUtils.add(position, { x: 0, y: -halfH });
case OriginEnum.BOTTOM:
return CoordsUtils.add(position, { x: 0, y: halfH });
case OriginEnum.LEFT:
return CoordsUtils.add(position, { x: -halfW, y: 0 });
case OriginEnum.RIGHT:
return CoordsUtils.add(position, { x: halfW, y: 0 });
case OriginEnum.CENTER:
default:
return position;
}
};

View File

@@ -1,167 +1,3 @@
import gsap from 'gsap';
import { Coords } from 'src/utils/Coords';
import { customVars } from 'src/styles/theme';
import chroma from 'chroma-js';
import type {
SceneInput,
NodeInput,
ConnectorInput,
GroupInput
} from 'src/validation/SceneInput';
import {
SceneItemTypeEnum,
Scene,
Node,
Connector,
Group
} from 'src/stores/useSceneStore';
import { NODE_DEFAULTS, GRID_DEFAULTS } from 'src/utils/config';
export const clamp = (num: number, min: number, max: number) => {
// eslint-disable-next-line no-nested-ternary
return 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.
return new Coords(
coords.x === 0 ? 0.000001 : coords.x,
coords.y === 0 ? 0.000001 : coords.y
);
};
export const getRandom = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min) + min);
};
export const tweenPosition = (
item: paper.Item,
{ x, y, duration }: { x: number; y: number; duration: number }
) => {
// paperjs doesn't like it when you try to tween the position of an item directly,
// so we have to use a proxy object
const currPosition = {
x: item.position.x,
y: item.position.y
};
gsap.to(currPosition, {
duration,
overwrite: 'auto',
x,
y,
onUpdate: () => {
item.set({ position: currPosition });
}
});
};
export const roundToOneDecimalPlace = (num: number) => {
return Math.round(num * 10) / 10;
};
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
labelElement: nodeInput.labelElement,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
color: nodeInput.color ?? NODE_DEFAULTS.color,
iconId: nodeInput.iconId,
position: Coords.fromObject(nodeInput.position),
isSelected: false
};
};
export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds
};
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput
): Connector => {
return {
type: SceneItemTypeEnum.CONNECTOR,
id: connectorInput.id,
color: connectorInput.color ?? customVars.diagramPalette.blue,
from: connectorInput.from,
to: connectorInput.to
};
};
export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const groups = sceneInput.groups.map((groupInput) => {
return groupInputToGroup(groupInput);
});
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput);
});
return {
...sceneInput,
nodes,
groups,
connectors,
icons: sceneInput.icons,
gridSize: sceneInput.gridSize
? new Coords(sceneInput.gridSize.width, sceneInput.gridSize.height)
: Coords.fromObject(GRID_DEFAULTS.size)
} as Scene;
};
export const sceneToSceneInput = (scene: Scene): SceneInput => {
const nodes: SceneInput['nodes'] = scene.nodes.map((node) => {
return {
id: node.id,
position: node.position.toObject(),
label: node.label,
labelHeight: node.labelHeight,
color: node.color,
iconId: node.iconId
};
});
return {
nodes,
connectors: [],
groups: [],
icons: scene.icons,
gridSize: { width: scene.gridSize.x, height: scene.gridSize.y }
} as SceneInput;
};
interface GetColorVariantOpts {
alpha?: number;
grade?: number;
}
export const getColorVariant = (
color: string,
variant: 'light' | 'dark',
{ alpha = 1, grade = 0 }: GetColorVariantOpts
) => {
switch (variant) {
case 'light':
return chroma(color)
.brighten(grade ?? 1)
.alpha(alpha)
.css();
case 'dark':
return chroma(color)
.darken(grade ?? 1)
.alpha(alpha)
.css();
default:
return chroma(color).alpha(alpha).css();
}
};
export * from './CoordsUtils';
export * from './common';
export * from './config';

View File

@@ -1,141 +0,0 @@
// TODO: Split into individual files
import { z } from 'zod';
export const iconInput = z.object({
id: z.string(),
name: z.string(),
url: z.string(),
category: z.string().optional()
});
export const nodeInput = z.object({
id: z.string(),
label: z.string().optional(),
labelHeight: z.number().optional(),
color: z.string().optional(),
iconId: z.string(),
position: z.object({
x: z.number(),
y: z.number()
})
});
export const connectorInput = z.object({
id: z.string(),
label: z.string().nullable(),
color: z.string().optional(),
from: z.string(),
to: z.string()
});
export const groupInput = z.object({
id: z.string(),
label: z.string().nullable(),
nodeIds: z.array(z.string())
});
export const gridSizeInput = z
.object({
width: z.number(),
height: z.number()
})
.optional();
export type IconInput = z.infer<typeof iconInput>;
export type NodeInput = z.infer<typeof nodeInput> & {
labelElement?: React.ReactNode;
};
export type ConnectorInput = z.infer<typeof connectorInput>;
export type GroupInput = z.infer<typeof groupInput>;
export type GridSizeInput = z.infer<typeof gridSizeInput>;
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) => {
return nodes.find((node) => {
const validIcon = icons.find((icon) => {
return node.iconId === icon.id;
});
return !validIcon;
});
};
export const findInvalidConnector = (
connectors: ConnectorInput[],
nodes: NodeInput[]
) => {
return connectors.find((con) => {
const fromNode = nodes.find((node) => {
return con.from === node.id;
});
const toNode = nodes.find((node) => {
return con.to === node.id;
});
return Boolean(!fromNode || !toNode);
});
};
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) => {
return groups.find((grp) => {
return grp.nodeIds.find((nodeId) => {
const validNode = nodes.find((node) => {
return node.id === nodeId;
});
return Boolean(!validNode);
});
});
};
export const sceneInput = z
.object({
icons: z.array(iconInput),
nodes: z.array(nodeInput),
connectors: z.array(connectorInput),
groups: z.array(groupInput),
gridSize: gridSizeInput
})
.superRefine((scene, ctx) => {
const invalidNode = findInvalidNode(scene.nodes, scene.icons);
if (invalidNode) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['nodes', invalidNode.id],
message: 'Invalid node found in scene'
});
return;
}
const invalidConnector = findInvalidConnector(
scene.connectors,
scene.nodes
);
if (invalidConnector) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['connectors', invalidConnector.id],
message: 'Invalid connector found in scene'
});
return;
}
const invalidGroup = findInvalidGroup(scene.groups, scene.nodes);
if (invalidGroup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['groups', invalidGroup.id],
message: 'Invalid group found in scene'
});
}
});
export type SceneInput = {
icons: IconInput[];
nodes: NodeInput[];
connectors: ConnectorInput[];
groups: GroupInput[];
gridSize?: GridSizeInput;
};

54
src/validation/scene.ts Normal file
View File

@@ -0,0 +1,54 @@
// TODO: Split into individual files
import { z } from 'zod';
import { iconInput, nodeInput, connectorInput, groupInput } from './sceneItems';
import {
findInvalidConnector,
findInvalidGroup,
findInvalidNode
} from './utils';
export const sceneInput = z
.object({
icons: z.array(iconInput),
nodes: z.array(nodeInput),
connectors: z.array(connectorInput),
groups: z.array(groupInput)
})
.superRefine((scene, ctx) => {
const invalidNode = findInvalidNode(scene.nodes, scene.icons);
if (invalidNode) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['nodes', invalidNode.id],
message: 'Invalid node found in scene'
});
return;
}
const invalidConnector = findInvalidConnector(
scene.connectors,
scene.nodes
);
if (invalidConnector) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['connectors', invalidConnector.id],
message: 'Invalid connector found in scene'
});
return;
}
const invalidGroup = findInvalidGroup(scene.groups, scene.nodes);
if (invalidGroup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['groups', invalidGroup.id],
message: 'Invalid group found in scene'
});
}
});

View File

@@ -0,0 +1,34 @@
import z from 'zod';
export const iconInput = z.object({
id: z.string(),
name: z.string(),
url: z.string(),
category: z.string().optional()
});
export const nodeInput = z.object({
id: z.string(),
label: z.string().optional(),
labelHeight: z.number().optional(),
color: z.string().optional(),
iconId: z.string(),
position: z.object({
x: z.number(),
y: z.number()
})
});
export const connectorInput = z.object({
id: z.string(),
label: z.string().nullable(),
color: z.string().optional(),
from: z.string(),
to: z.string()
});
export const groupInput = z.object({
id: z.string(),
label: z.string().nullable(),
nodeIds: z.array(z.string())
});

View File

@@ -1,12 +1,10 @@
import { NodeInput, ConnectorInput, GroupInput } from 'src/types/inputs';
import { sceneInput } from '../scene';
import {
sceneInput,
NodeInput,
ConnectorInput,
GroupInput,
findInvalidNode,
findInvalidConnector,
findInvalidGroup
} from '../SceneInput';
} from '../utils';
import { scene } from '../../tests/fixtures/scene';
describe('scene validation works correctly', () => {

42
src/validation/utils.ts Normal file
View File

@@ -0,0 +1,42 @@
import {
ConnectorInput,
NodeInput,
GroupInput,
IconInput
} from 'src/types/inputs';
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) => {
return nodes.find((node) => {
const validIcon = icons.find((icon) => {
return node.iconId === icon.id;
});
return !validIcon;
});
};
export const findInvalidConnector = (
connectors: ConnectorInput[],
nodes: NodeInput[]
) => {
return connectors.find((con) => {
const fromNode = nodes.find((node) => {
return con.from === node.id;
});
const toNode = nodes.find((node) => {
return con.to === node.id;
});
return Boolean(!fromNode || !toNode);
});
};
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) => {
return groups.find((grp) => {
return grp.nodeIds.find((nodeId) => {
const validNode = nodes.find((node) => {
return node.id === nodeId;
});
return Boolean(!validNode);
});
});
};