mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-27 00:19:14 -05:00
feat: allows icons to be drag and dropped onto canvas
This commit is contained in:
@@ -21,6 +21,8 @@ import { useWindowUtils } from 'src/hooks/useWindowUtils';
|
||||
import { sceneInput as sceneValidationSchema } from 'src/validation/scene';
|
||||
import { ItemControlsManager } from './components/ItemControls/ItemControlsManager';
|
||||
import { UiStateProvider, useUiStateStore } from './stores/uiStateStore';
|
||||
import { SceneLayer } from './components/SceneLayer/SceneLayer';
|
||||
import { DragAndDrop } from './components/DragAndDrop/DragAndDrop';
|
||||
|
||||
interface Props {
|
||||
initialScene?: SceneInput & {
|
||||
@@ -55,6 +57,12 @@ const App = ({
|
||||
const interactionsEnabled = useUiStateStore((state) => {
|
||||
return state.interactionsEnabled;
|
||||
});
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
const mouse = useUiStateStore((state) => {
|
||||
return state.mouse;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
uiActions.setZoom(initialScene?.zoom ?? 1);
|
||||
@@ -93,6 +101,11 @@ const App = ({
|
||||
<Renderer />
|
||||
<ItemControlsManager />
|
||||
{interactionsEnabled && <ToolMenu />}
|
||||
{mode.type === 'PLACE_ELEMENT' && mode.icon && (
|
||||
<SceneLayer>
|
||||
<DragAndDrop icon={mode.icon} tile={mouse.position.tile} />
|
||||
</SceneLayer>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,15 +3,11 @@ import { Box } from '@mui/material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { NodeContextMenu } from 'src/components/ContextMenu/NodeContextMenu';
|
||||
import { EmptyTileContextMenu } from 'src/components/ContextMenu/EmptyTileContextMenu';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
|
||||
export const ContextMenuLayer = () => {
|
||||
const contextMenu = useUiStateStore((state) => {
|
||||
return state.contextMenu;
|
||||
});
|
||||
const sceneActions = useSceneStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -29,9 +25,7 @@ export const ContextMenuLayer = () => {
|
||||
{contextMenu?.type === 'EMPTY_TILE' && (
|
||||
<EmptyTileContextMenu
|
||||
key={contextMenu.position.toString()}
|
||||
onAddNode={() => {
|
||||
return sceneActions.createNode(contextMenu.position);
|
||||
}}
|
||||
onAddNode={() => {}}
|
||||
position={contextMenu.position}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { SizeIndicator } from './SizeIndicator';
|
||||
import { Value } from './Value';
|
||||
import { LineItem } from './LineItem';
|
||||
|
||||
export const DebugUtils = () => {
|
||||
const {
|
||||
@@ -10,6 +12,9 @@ export const DebugUtils = () => {
|
||||
const uiState = useUiStateStore(({ scroll, mouse, zoom, rendererSize }) => {
|
||||
return { scroll, mouse, zoom, rendererSize };
|
||||
});
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
|
||||
const { scroll, mouse, zoom, rendererSize } = uiState;
|
||||
|
||||
@@ -20,24 +25,32 @@ export const DebugUtils = () => {
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: appPadding.x,
|
||||
top: appPadding.y,
|
||||
bottom: appPadding.y,
|
||||
bgcolor: 'common.white',
|
||||
p: 2,
|
||||
width: 350,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
minWidth: 200
|
||||
border: (theme) => {
|
||||
return `1px solid ${theme.palette.grey[400]}`;
|
||||
},
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Scroll: {scroll.position.x}, {scroll.position.y}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Mouse: {mouse.position.tile.x}, {mouse.position.tile.y}
|
||||
</Typography>
|
||||
<Typography variant="body2">Zoom: {zoom}</Typography>
|
||||
<Typography variant="body2">
|
||||
Renderer size: {rendererSize.width} {rendererSize.height}
|
||||
</Typography>
|
||||
<LineItem
|
||||
title="Mouse"
|
||||
value={`${mouse.position.tile.x}, ${mouse.position.tile.y}`}
|
||||
/>
|
||||
<LineItem
|
||||
title="Scroll"
|
||||
value={`${scroll.position.x}, ${scroll.position.y}`}
|
||||
/>
|
||||
<LineItem title="Zoom" value={zoom} />
|
||||
<LineItem
|
||||
title="Size"
|
||||
value={`${rendererSize.width}, ${rendererSize.height}`}
|
||||
/>
|
||||
<LineItem title="Mode" value={mode.type} />
|
||||
<LineItem title="Mode data" value={JSON.stringify(mode)} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
38
src/components/DebugUtils/LineItem.tsx
Normal file
38
src/components/DebugUtils/LineItem.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Typography, Box } from '@mui/material';
|
||||
import { Value } from './Value';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export const LineItem = ({ title, value }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
py: 1,
|
||||
borderBottom: (theme) => {
|
||||
return `1px solid ${theme.palette.grey[300]}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 100
|
||||
}}
|
||||
>
|
||||
<Typography>{title}</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<Value value={value.toString()} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
29
src/components/DebugUtils/Value.tsx
Normal file
29
src/components/DebugUtils/Value.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const Value = ({ value }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
bgcolor: 'grey.300',
|
||||
wordWrap: 'break-word',
|
||||
py: 0.25,
|
||||
px: 0.5,
|
||||
border: (theme) => {
|
||||
return `1px solid ${theme.palette.grey[400]}`;
|
||||
},
|
||||
borderRadius: 2,
|
||||
maxWidth: 200
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.8em' }} variant="body2">
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
30
src/components/DragAndDrop/DragAndDrop.tsx
Normal file
30
src/components/DragAndDrop/DragAndDrop.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Coords, TileOriginEnum, IconInput } from 'src/types';
|
||||
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
|
||||
import { NodeIcon } from 'src/components/Node/NodeIcon';
|
||||
|
||||
interface Props {
|
||||
icon: IconInput;
|
||||
tile: Coords;
|
||||
}
|
||||
|
||||
export const DragAndDrop = ({ icon, tile }: Props) => {
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
|
||||
const tilePosition = useMemo(() => {
|
||||
return getTilePosition({ tile, origin: TileOriginEnum.BOTTOM });
|
||||
}, [tile, getTilePosition]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: tilePosition.x,
|
||||
top: tilePosition.y
|
||||
}}
|
||||
>
|
||||
<NodeIcon icon={icon} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,12 +6,18 @@ import { Icon as IconI } from 'src/types';
|
||||
|
||||
interface Props {
|
||||
icon: IconI;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
onMouseDown?: () => void;
|
||||
}
|
||||
|
||||
export const Icon = ({ icon, onClick }: Props) => {
|
||||
export const Icon = ({ icon, onClick, onMouseDown }: Props) => {
|
||||
return (
|
||||
<Button variant="text" onClick={onClick}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
sx={{ userSelect: 'none' }}
|
||||
>
|
||||
<Stack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
@@ -19,6 +25,7 @@ export const Icon = ({ icon, onClick }: Props) => {
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
draggable={false}
|
||||
src={icon.url}
|
||||
alt={`Icon ${icon.name}`}
|
||||
sx={{ width: '100%', height: 80 }}
|
||||
@@ -2,15 +2,16 @@ import React from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Icon as IconI } from 'src/types';
|
||||
import { Icon } from './Icon';
|
||||
import { Section } from '../../components/Section';
|
||||
import { Section } from '../components/Section';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
icons: IconI[];
|
||||
onClick: (icon: IconI) => void;
|
||||
onClick?: (icon: IconI) => void;
|
||||
onMouseDown?: (icon: IconI) => void;
|
||||
}
|
||||
|
||||
export const IconCategory = ({ name, icons, onClick }: Props) => {
|
||||
export const IconCategory = ({ name, icons, onClick, onMouseDown }: Props) => {
|
||||
return (
|
||||
<Section title={name}>
|
||||
<Grid container spacing={2}>
|
||||
@@ -20,8 +21,15 @@ export const IconCategory = ({ name, icons, onClick }: Props) => {
|
||||
<Icon
|
||||
icon={icon}
|
||||
onClick={() => {
|
||||
if (!onClick) return;
|
||||
|
||||
return onClick(icon);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!onMouseDown) return;
|
||||
|
||||
return onMouseDown(icon);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
37
src/components/ItemControls/IconSelection/IconSelection.tsx
Normal file
37
src/components/ItemControls/IconSelection/IconSelection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { ControlsContainer } from 'src/components/ItemControls/components/ControlsContainer';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { Icon } from 'src/types';
|
||||
import { Icons } from './Icons';
|
||||
|
||||
export const IconSelection = () => {
|
||||
const uiStateActions = useUiStateStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
const icons = useSceneStore((state) => {
|
||||
return state.icons;
|
||||
});
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(icon: Icon) => {
|
||||
if (mode.type !== 'PLACE_ELEMENT') return;
|
||||
|
||||
uiStateActions.setMode({
|
||||
type: 'PLACE_ELEMENT',
|
||||
showCursor: true,
|
||||
icon
|
||||
});
|
||||
},
|
||||
[mode, uiStateActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<ControlsContainer>
|
||||
<Icons icons={icons} onMouseDown={onMouseDown} />
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -5,10 +5,11 @@ import { IconCategory } from './IconCategory';
|
||||
|
||||
interface Props {
|
||||
icons: Icon[];
|
||||
onClick: (icon: Icon) => void;
|
||||
onClick?: (icon: Icon) => void;
|
||||
onMouseDown?: (icon: Icon) => void;
|
||||
}
|
||||
|
||||
export const Icons = ({ icons, onClick }: Props) => {
|
||||
export const Icons = ({ icons, onClick, onMouseDown }: Props) => {
|
||||
const categorisedIcons = useMemo(() => {
|
||||
const cats: { name?: string; icons: Icon[] }[] = [];
|
||||
|
||||
@@ -42,7 +43,11 @@ export const Icons = ({ icons, onClick }: Props) => {
|
||||
{categorisedIcons.map((cat) => {
|
||||
return (
|
||||
<Grid item xs={12} key={`icon-category-${cat.name}`}>
|
||||
<IconCategory {...cat} onClick={onClick} />
|
||||
<IconCategory
|
||||
{...cat}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, useTheme } from '@mui/material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { IconSelection } from 'src/components/ItemControls/IconSelection/IconSelection';
|
||||
import { NodeControls } from './NodeControls/NodeControls';
|
||||
import { ProjectControls } from './ProjectControls/ProjectControls';
|
||||
|
||||
export const ItemControlsManager = () => {
|
||||
const itemControls = useUiStateStore((state) => state.itemControls);
|
||||
const itemControls = useUiStateStore((state) => {
|
||||
return state.itemControls;
|
||||
});
|
||||
const theme = useTheme();
|
||||
|
||||
const Controls = useMemo(() => {
|
||||
@@ -14,6 +17,8 @@ export const ItemControlsManager = () => {
|
||||
return <NodeControls nodeId={itemControls.nodeId} />;
|
||||
case 'PROJECT_SETTINGS':
|
||||
return <ProjectControls />;
|
||||
case 'PLACE_ELEMENT':
|
||||
return <IconSelection />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Node } from 'src/types';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { useNode } from 'src/hooks/useNode';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
import { Icons } from './IconSelection/IconSelection';
|
||||
import { Icons } from '../IconSelection/Icons';
|
||||
import { Header } from '../components/Header';
|
||||
import { NodeSettings } from './NodeSettings/NodeSettings';
|
||||
|
||||
|
||||
@@ -7,27 +7,29 @@ interface Props {
|
||||
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
|
||||
export const Transition = ({ children, isIn }: Props) => {
|
||||
return (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={isIn}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{
|
||||
transitionDelay: isIn ? '150ms' : '0ms'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export const ControlsContainer = ({ header, children }: Props) => {
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', boxShadow: 6, zIndex: 1 }}>{header}</Box>
|
||||
{header && (
|
||||
<Box sx={{ width: '100%', boxShadow: 6, zIndex: 1 }}>{header}</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import gsap from 'gsap';
|
||||
import { Size, Coords, TileOriginEnum, Node as NodeI } from 'src/types';
|
||||
import { getProjectedTileSize, getColorVariant } from 'src/utils';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { Coords, TileOriginEnum, Node as NodeI, IconInput } from 'src/types';
|
||||
import { getColorVariant } from 'src/utils';
|
||||
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
|
||||
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
|
||||
import { LabelContainer } from './LabelContainer';
|
||||
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';
|
||||
import { NodeIcon } from './NodeIcon';
|
||||
|
||||
interface Props {
|
||||
node: NodeI;
|
||||
iconUrl?: string;
|
||||
icon?: IconInput;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const Node = ({ node, iconUrl, order }: Props) => {
|
||||
export const Node = ({ node, icon, order }: Props) => {
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
const iconRef = useRef<HTMLImageElement>();
|
||||
const { observe, size: iconSize } = useResizeObserver();
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconRef.current) return;
|
||||
|
||||
observe(iconRef.current);
|
||||
}, [observe]);
|
||||
|
||||
const projectedTileSize = useMemo<Size>(() => {
|
||||
return getProjectedTileSize({ zoom });
|
||||
}, [zoom]);
|
||||
const projectedTileSize = useProjectedTileSize();
|
||||
|
||||
const moveToTile = useCallback(
|
||||
({
|
||||
@@ -43,19 +33,13 @@ export const Node = ({ node, iconUrl, order }: Props) => {
|
||||
tile: Coords;
|
||||
animationDuration?: number;
|
||||
}) => {
|
||||
if (!nodeRef.current || !iconRef.current) return;
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const position = getTilePosition({
|
||||
tile,
|
||||
origin: TileOriginEnum.BOTTOM
|
||||
});
|
||||
|
||||
gsap.to(iconRef.current, {
|
||||
duration: animationDuration,
|
||||
x: -iconRef.current.width * 0.5,
|
||||
y: -iconRef.current.height
|
||||
});
|
||||
|
||||
gsap.to(nodeRef.current, {
|
||||
duration: animationDuration,
|
||||
x: position.x,
|
||||
@@ -66,12 +50,10 @@ export const Node = ({ node, iconUrl, order }: Props) => {
|
||||
);
|
||||
|
||||
const onImageLoaded = useCallback(() => {
|
||||
if (!nodeRef.current || !iconRef.current) return;
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
gsap.killTweensOf(nodeRef.current);
|
||||
moveToTile({ tile: node.position, animationDuration: 0 });
|
||||
nodeRef.current.style.opacity = '1';
|
||||
}, [node.position, moveToTile]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
moveToTile({ tile: node.position, animationDuration: 0 });
|
||||
@@ -112,24 +94,22 @@ export const Node = ({ node, iconUrl, order }: Props) => {
|
||||
/>
|
||||
</Box>
|
||||
<LabelContainer
|
||||
labelHeight={node.labelHeight + iconSize.height}
|
||||
labelHeight={node.labelHeight + 100}
|
||||
tileSize={projectedTileSize}
|
||||
connectorDotSize={5 * zoom}
|
||||
>
|
||||
{node.label && <MarkdownLabel label={node.label} />}
|
||||
</LabelContainer>
|
||||
</Box>
|
||||
<Box
|
||||
component="img"
|
||||
ref={iconRef}
|
||||
onLoad={onImageLoaded}
|
||||
src={iconUrl}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: projectedTileSize.width,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
{icon && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute'
|
||||
}}
|
||||
>
|
||||
<NodeIcon icon={icon} onImageLoaded={onImageLoaded} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
40
src/components/Node/NodeIcon.tsx
Normal file
40
src/components/Node/NodeIcon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { IconInput } from 'src/types';
|
||||
|
||||
interface Props {
|
||||
icon: IconInput;
|
||||
onImageLoaded?: () => void;
|
||||
}
|
||||
|
||||
export const NodeIcon = ({ icon, onImageLoaded }: Props) => {
|
||||
const ref = useRef();
|
||||
const projectedTileSize = useProjectedTileSize();
|
||||
const { size, observe, disconnect } = useResizeObserver();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
observe(ref.current);
|
||||
|
||||
return disconnect;
|
||||
}, [observe, disconnect]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="img"
|
||||
onLoad={onImageLoaded}
|
||||
src={icon.url}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: projectedTileSize.width,
|
||||
pointerEvents: 'none',
|
||||
top: -size.height,
|
||||
left: -size.width / 2
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -128,11 +128,9 @@ export const Renderer = () => {
|
||||
key={node.id}
|
||||
order={-node.position.x - node.position.y}
|
||||
node={node}
|
||||
iconUrl={
|
||||
icons.find((icon) => {
|
||||
return icon.id === node.iconId;
|
||||
})?.url
|
||||
}
|
||||
icon={icons.find((icon) => {
|
||||
return icon.id === node.iconId;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,5 +7,18 @@ interface Props {
|
||||
}
|
||||
|
||||
export const SceneLayer = ({ children, order = 0 }: Props) => {
|
||||
return <Box sx={{ position: 'absolute', zIndex: order }}>{children}</Box>;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
zIndex: order,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Card, useTheme } from '@mui/material';
|
||||
import { ItemControlsTypeEnum } from 'src/types';
|
||||
import {
|
||||
PanTool as PanToolIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
ZoomOut as ZoomOutIcon,
|
||||
NearMe as NearMeIcon,
|
||||
CenterFocusStrong as CenterFocusStrongIcon
|
||||
CenterFocusStrong as CenterFocusStrongIcon,
|
||||
Add as AddIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
|
||||
@@ -35,11 +37,26 @@ export const ToolMenu = () => {
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
name="Add element"
|
||||
Icon={<AddIcon />}
|
||||
onClick={() => {
|
||||
uiStateStoreActions.setItemControls({
|
||||
type: ItemControlsTypeEnum.PLACE_ELEMENT
|
||||
});
|
||||
uiStateStoreActions.setMode({
|
||||
type: 'PLACE_ELEMENT',
|
||||
showCursor: true,
|
||||
icon: null
|
||||
});
|
||||
}}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
/>
|
||||
<IconButton
|
||||
name="Select"
|
||||
Icon={<NearMeIcon />}
|
||||
onClick={() => {
|
||||
return uiStateStoreActions.setMode({
|
||||
uiStateStoreActions.setMode({
|
||||
type: 'CURSOR',
|
||||
showCursor: true,
|
||||
mousedown: null
|
||||
@@ -52,7 +69,7 @@ export const ToolMenu = () => {
|
||||
name="Pan"
|
||||
Icon={<PanToolIcon />}
|
||||
onClick={() => {
|
||||
return uiStateStoreActions.setMode({
|
||||
uiStateStoreActions.setMode({
|
||||
type: 'PAN',
|
||||
showCursor: false
|
||||
});
|
||||
|
||||
15
src/hooks/useProjectedTileSize.ts
Normal file
15
src/hooks/useProjectedTileSize.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { getProjectedTileSize } from 'src/utils';
|
||||
|
||||
export const useProjectedTileSize = () => {
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
|
||||
const projectedTileSize = useMemo(() => {
|
||||
return getProjectedTileSize({ zoom });
|
||||
}, [zoom]);
|
||||
|
||||
return projectedTileSize;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SidebarTypeEnum, InteractionReducer } from 'src/types';
|
||||
import { ItemControlsTypeEnum, InteractionReducer } from 'src/types';
|
||||
import { CoordsUtils, filterNodesByTile } from 'src/utils';
|
||||
|
||||
export const Cursor: InteractionReducer = {
|
||||
@@ -74,7 +74,7 @@ export const Cursor: InteractionReducer = {
|
||||
draftState.contextMenu = draftState.scene.nodes[nodeIndex];
|
||||
draftState.scene.nodes[nodeIndex].isSelected = true;
|
||||
draftState.itemControls = {
|
||||
type: SidebarTypeEnum.SINGLE_NODE,
|
||||
type: ItemControlsTypeEnum.SINGLE_NODE,
|
||||
nodeId: draftState.scene.nodes[nodeIndex].id
|
||||
};
|
||||
draftState.mode.mousedown = null;
|
||||
|
||||
44
src/interaction/reducers/PlaceElement.ts
Normal file
44
src/interaction/reducers/PlaceElement.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { InteractionReducer } from 'src/types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { nodeInputToNode, filterNodesByTile } from 'src/utils';
|
||||
|
||||
export const PlaceElement: InteractionReducer = {
|
||||
type: 'PLACE_ELEMENT',
|
||||
mousemove: () => {},
|
||||
mousedown: (draftState) => {
|
||||
if (draftState.mode.type !== 'PLACE_ELEMENT') return;
|
||||
|
||||
if (!draftState.mode.icon) {
|
||||
const itemsAtTile = filterNodesByTile({
|
||||
tile: draftState.mouse.position.tile,
|
||||
nodes: draftState.scene.nodes
|
||||
});
|
||||
|
||||
draftState.mode = {
|
||||
type: 'CURSOR',
|
||||
mousedown: {
|
||||
items: itemsAtTile,
|
||||
tile: draftState.mouse.position.tile
|
||||
},
|
||||
showCursor: true
|
||||
};
|
||||
|
||||
draftState.itemControls = null;
|
||||
}
|
||||
},
|
||||
mouseup: (draftState) => {
|
||||
if (draftState.mode.type !== 'PLACE_ELEMENT') return;
|
||||
|
||||
if (draftState.mode.icon !== null) {
|
||||
const newNode = nodeInputToNode({
|
||||
id: uuid(),
|
||||
iconId: draftState.mode.icon.id,
|
||||
label: 'New Node',
|
||||
position: draftState.mouse.position.tile
|
||||
});
|
||||
|
||||
draftState.mode.icon = null;
|
||||
draftState.scene.nodes.push(newNode);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -8,12 +8,14 @@ import { DragItems } from './reducers/DragItems';
|
||||
import { Pan } from './reducers/Pan';
|
||||
import { Cursor } from './reducers/Cursor';
|
||||
import { Lasso } from './reducers/Lasso';
|
||||
import { PlaceElement } from './reducers/PlaceElement';
|
||||
|
||||
const reducers: { [k in string]: InteractionReducer } = {
|
||||
CURSOR: Cursor,
|
||||
DRAG_ITEMS: DragItems,
|
||||
PAN: Pan,
|
||||
LASSO: Lasso
|
||||
LASSO: Lasso,
|
||||
PLACE_ELEMENT: PlaceElement
|
||||
};
|
||||
|
||||
export const useInteractionManager = () => {
|
||||
@@ -88,7 +90,8 @@ export const useInteractionManager = () => {
|
||||
scroll,
|
||||
contextMenu,
|
||||
itemControls,
|
||||
rendererRef: rendererRef.current
|
||||
rendererRef: rendererRef.current,
|
||||
sceneActions
|
||||
};
|
||||
|
||||
const getTransitionaryState = () => {
|
||||
@@ -129,7 +132,7 @@ export const useInteractionManager = () => {
|
||||
uiStateActions.setScroll(newState.scroll);
|
||||
uiStateActions.setMode(newState.mode);
|
||||
uiStateActions.setContextMenu(newState.contextMenu);
|
||||
uiStateActions.setSidebar(newState.itemControls);
|
||||
uiStateActions.setItemControls(newState.itemControls);
|
||||
sceneActions.updateScene(newState.scene);
|
||||
},
|
||||
[
|
||||
@@ -153,7 +156,7 @@ export const useInteractionManager = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = rendererRef.current;
|
||||
const el = window;
|
||||
|
||||
el.addEventListener('mousemove', onMouseEvent);
|
||||
el.addEventListener('mousedown', onMouseEvent);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { createContext, useRef, useContext } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { produce } from 'immer';
|
||||
import { NODE_DEFAULTS } from 'src/config';
|
||||
import { Scene, SceneActions, Node, SceneItemTypeEnum } from 'src/types';
|
||||
import { Scene, SceneActions } from 'src/types';
|
||||
import { sceneInput } from 'src/validation/scene';
|
||||
import { sceneInputtoScene } from 'src/utils';
|
||||
|
||||
@@ -46,19 +44,6 @@ const initialState = () => {
|
||||
});
|
||||
|
||||
set({ nodes: newNodes });
|
||||
},
|
||||
createNode: (position) => {
|
||||
const { nodes, icons } = get();
|
||||
const newNode: Node = {
|
||||
...NODE_DEFAULTS,
|
||||
id: uuid(),
|
||||
type: SceneItemTypeEnum.NODE,
|
||||
iconId: icons[0].id,
|
||||
position,
|
||||
isSelected: false
|
||||
};
|
||||
|
||||
set({ nodes: [...nodes, newNode] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ const initialState = () => {
|
||||
setScroll: ({ position, offset }) => {
|
||||
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
|
||||
},
|
||||
setSidebar: (itemControls) => {
|
||||
setItemControls: (itemControls) => {
|
||||
set({ itemControls });
|
||||
},
|
||||
setContextMenu: (contextMenu) => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
ContextMenu,
|
||||
ItemControls,
|
||||
Mouse,
|
||||
Scene
|
||||
Scene,
|
||||
SceneActions
|
||||
} from 'src/types';
|
||||
|
||||
export interface State {
|
||||
@@ -13,6 +14,7 @@ export interface State {
|
||||
mouse: Mouse;
|
||||
scroll: Scroll;
|
||||
scene: Scene;
|
||||
sceneActions: SceneActions;
|
||||
contextMenu: ContextMenu;
|
||||
itemControls: ItemControls;
|
||||
rendererRef: HTMLElement;
|
||||
|
||||
@@ -57,5 +57,4 @@ export interface SceneActions {
|
||||
setScene: (scene: SceneInput) => void;
|
||||
updateScene: (scene: Scene) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>) => void;
|
||||
createNode: (position: Coords) => void;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Coords, Size } from './common';
|
||||
import { SceneItem } from './scene';
|
||||
import { IconInput } from './inputs';
|
||||
|
||||
export enum SidebarTypeEnum {
|
||||
export enum ItemControlsTypeEnum {
|
||||
SINGLE_NODE = 'SINGLE_NODE',
|
||||
PROJECT_SETTINGS = 'PROJECT_SETTINGS'
|
||||
PROJECT_SETTINGS = 'PROJECT_SETTINGS',
|
||||
PLACE_ELEMENT = 'PLACE_ELEMENT'
|
||||
}
|
||||
|
||||
export type ItemControls =
|
||||
| {
|
||||
type: SidebarTypeEnum.SINGLE_NODE;
|
||||
type: ItemControlsTypeEnum.SINGLE_NODE;
|
||||
nodeId: string;
|
||||
}
|
||||
| {
|
||||
type: SidebarTypeEnum.PROJECT_SETTINGS;
|
||||
type: ItemControlsTypeEnum.PROJECT_SETTINGS;
|
||||
}
|
||||
| {
|
||||
type: ItemControlsTypeEnum.PLACE_ELEMENT;
|
||||
}
|
||||
| null;
|
||||
|
||||
@@ -68,12 +73,19 @@ export interface DragItemsMode {
|
||||
items: SceneItem[];
|
||||
}
|
||||
|
||||
export interface PlaceElement {
|
||||
type: 'PLACE_ELEMENT';
|
||||
showCursor: boolean;
|
||||
icon: IconInput | null;
|
||||
}
|
||||
|
||||
export type Mode =
|
||||
| InteractionsDisabled
|
||||
| CursorMode
|
||||
| PanMode
|
||||
| DragItemsMode
|
||||
| LassoMode;
|
||||
| LassoMode
|
||||
| PlaceElement;
|
||||
// End mode types
|
||||
|
||||
export type ContextMenu =
|
||||
@@ -107,7 +119,7 @@ export interface UiStateActions {
|
||||
decrementZoom: () => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
setScroll: (scroll: Scroll) => void;
|
||||
setSidebar: (itemControls: ItemControls) => void;
|
||||
setItemControls: (itemControls: ItemControls) => void;
|
||||
setContextMenu: (contextMenu: ContextMenu) => void;
|
||||
setMouse: (mouse: Mouse) => void;
|
||||
setRendererSize: (rendererSize: Size) => void;
|
||||
|
||||
Reference in New Issue
Block a user