mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 23:19:13 -05:00
feat: implements node labels
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Box } from '@mui/material';
|
||||
import { theme } from 'src/styles/theme';
|
||||
import { ToolMenu } from 'src/components/ToolMenu';
|
||||
import { ToolMenu } from 'src/components/ToolMenu/ToolMenu';
|
||||
import { SceneInput } from 'src/validation/SceneSchema';
|
||||
import { useSceneStore, Scene } from 'src/stores/useSceneStore';
|
||||
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowRightAlt as ConnectIcon,
|
||||
Delete as DeleteIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { useNodeHooks } from 'src/stores/useSceneStore';
|
||||
import { ContextMenu } from './components/ContextMenu';
|
||||
import { ContextMenuItem } from './components/ContextMenuItem';
|
||||
|
||||
@@ -12,12 +12,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const NodeContextMenu = ({ nodeId }: Props) => {
|
||||
const sceneItems = useSceneStore(({ nodes }) => ({ nodes }));
|
||||
const getNodeById = useSceneStore((state) => state.actions.getNodeById);
|
||||
const node = useMemo(
|
||||
() => getNodeById(nodeId),
|
||||
[getNodeById, nodeId, sceneItems]
|
||||
);
|
||||
const { useGetNodeById } = useNodeHooks();
|
||||
const node = useGetNodeById(nodeId);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useTheme } from '@mui/material';
|
||||
import Card from '@mui/material/Card';
|
||||
import Slide from '@mui/material/Slide';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, useTheme } from '@mui/material';
|
||||
import { useUiStateStore } from 'src/stores/useUiStateStore';
|
||||
import { NodeControls } from './NodeControls/NodeControls';
|
||||
import { ProjectControls } from './ProjectControls/ProjectControls';
|
||||
|
||||
export const ItemControlsManager = () => {
|
||||
const theme = useTheme();
|
||||
const itemControls = useUiStateStore((state) => state.itemControls);
|
||||
const uiStateActions = useUiStateStore((state) => state.actions);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
uiStateActions.setSidebar(null);
|
||||
}, [uiStateActions]);
|
||||
const theme = useTheme();
|
||||
|
||||
const Controls = useMemo(() => {
|
||||
switch (itemControls?.type) {
|
||||
case 'SINGLE_NODE':
|
||||
return <NodeControls onClose={onClose} />;
|
||||
return <NodeControls nodeId={itemControls.nodeId} />;
|
||||
case 'PROJECT_SETTINGS':
|
||||
return <ProjectControls onClose={onClose} />;
|
||||
return <ProjectControls />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [itemControls, onClose]);
|
||||
}, [itemControls]);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
direction="right"
|
||||
in={itemControls !== null}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: '325px',
|
||||
maxHeight: `calc(100% - ${theme.customVars.appPadding.y * 2}px)`,
|
||||
left: theme.customVars.appPadding.x,
|
||||
top: theme.customVars.appPadding.y,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: '400px',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: theme.customVars.appPadding.x,
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
{Controls}
|
||||
</Card>
|
||||
</Slide>
|
||||
{Controls}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Tabs, Tab, Box } from '@mui/material';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { useSceneStore, useNodeHooks, Node } from 'src/stores/useSceneStore';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
import { Icons } from './IconSelection/IconSelection';
|
||||
import { Header } from '../components/Header';
|
||||
import { NodeSettings } from './NodeSettings/NodeSettings';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export const NodeControls = ({ onClose }: Props) => {
|
||||
export const NodeControls = ({ nodeId }: Props) => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const icons = useSceneStore((state) => state.icons);
|
||||
const sceneActions = useSceneStore((state) => state.actions);
|
||||
const { useGetNodeById } = useNodeHooks();
|
||||
const node = useGetNodeById(nodeId);
|
||||
|
||||
const onTabChanged = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTab(newValue);
|
||||
};
|
||||
|
||||
const onNodeUpdated = useCallback(
|
||||
(updates: Partial<Node>) => {
|
||||
sceneActions.updateNode(nodeId, updates);
|
||||
},
|
||||
[sceneActions, nodeId]
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<ControlsContainer
|
||||
header={
|
||||
<Box>
|
||||
<Header title="Node" onClose={onClose} />{' '}
|
||||
<Tabs value={tab} onChange={onTabChanged}>
|
||||
<Header title="Node settings" />
|
||||
<Tabs sx={{ px: 2 }} value={tab} onChange={onTabChanged}>
|
||||
<Tab label="Settings" />
|
||||
<Tab label="Icons" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{tab === 0 && <NodeSettings />}
|
||||
{tab === 1 && <Icons icons={icons} onClick={() => {}} />}
|
||||
{tab === 0 && (
|
||||
<NodeSettings
|
||||
label={node.label}
|
||||
labelHeight={node.labelHeight}
|
||||
onUpdate={onNodeUpdated}
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && (
|
||||
<Icons
|
||||
icons={icons}
|
||||
onClick={(icon) => onNodeUpdated({ iconId: icon.id })}
|
||||
/>
|
||||
)}
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MarkdownEditor } from '../../../MarkdownEditor';
|
||||
import React from 'react';
|
||||
import { Slider } from '@mui/material';
|
||||
import { Node } from 'src/stores/useSceneStore';
|
||||
import { MarkdownEditor } from '../../../MarkdownEditor/MarkdownEditor';
|
||||
import { Section } from '../../components/Section';
|
||||
|
||||
export const NodeSettings = () => {
|
||||
const [label, setLabel] = useState('');
|
||||
interface Props {
|
||||
label: string;
|
||||
labelHeight: number;
|
||||
onUpdate: (updates: Partial<Node>) => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<MarkdownEditor value={label} onChange={setLabel} />
|
||||
export const NodeSettings = ({ label, labelHeight, onUpdate }: Props) => (
|
||||
<>
|
||||
<Section title="Label">
|
||||
<MarkdownEditor
|
||||
value={label}
|
||||
onChange={(text) => onUpdate({ label: text })}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
<Section title="Label height">
|
||||
<Slider
|
||||
marks
|
||||
step={20}
|
||||
min={0}
|
||||
max={200}
|
||||
value={labelHeight}
|
||||
onChange={(e, newHeight) =>
|
||||
onUpdate({ labelHeight: newHeight as number })
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,12 @@ import { Header } from '../components/Header';
|
||||
import { Section } from '../components/Section';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Values {
|
||||
name?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const ProjectControls = ({ onClose }: Props) => {
|
||||
export const ProjectControls = () => {
|
||||
const { register, handleSubmit } = useForm<Values>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
@@ -28,9 +24,7 @@ export const ProjectControls = ({ onClose }: Props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ControlsContainer
|
||||
header={<Header title="Project settings" onClose={onClose} />}
|
||||
>
|
||||
<ControlsContainer header={<Header title="Project settings" />}>
|
||||
<Section>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={4}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
export const ControlsContainer = ({ header, children }: Props) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
@@ -20,16 +21,7 @@ export const ControlsContainer = ({ header, children }: Props) => (
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowY: 'scroll',
|
||||
flexGrow: 1,
|
||||
'*::-webkit-scrollbar': {
|
||||
width: '0.4em'
|
||||
},
|
||||
'*::-webkit-scrollbar-track': {
|
||||
'-webkit-box-shadow': 'inset 0 0 6px rgba(0,0,0,0.00)'
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'rgba(0,0,0,.1)'
|
||||
}
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', pb: 6 }}>{children}</Box>
|
||||
|
||||
@@ -2,28 +2,20 @@ import React from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Button from '@mui/material/Button';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { Section } from './Section';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ title, onClose }: Props) => (
|
||||
export const Header = ({ title }: Props) => (
|
||||
<Section py={2}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={10}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Box>
|
||||
<Button variant="text" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -11,9 +11,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const Section = ({ children, py, px, title }: Props) => (
|
||||
<Box py={py ?? 3} px={px ?? 2}>
|
||||
<Box py={py ?? 3} px={px ?? 3}>
|
||||
<Stack>
|
||||
{title && <Typography fontWeight={600}>{title}</Typography>}
|
||||
{title && (
|
||||
<Typography variant="body2" color="text.secondary" pb={0.5}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
56
src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
56
src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactQuill from 'react-quill';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const tools = ['bold', 'italic', 'underline', 'strike', 'bullet', 'link'];
|
||||
|
||||
export const MarkdownEditor = ({ value, onChange, readOnly }: Props) => {
|
||||
const modules = useMemo(() => {
|
||||
if (!readOnly)
|
||||
return {
|
||||
toolbar: tools
|
||||
};
|
||||
|
||||
return { toolbar: false };
|
||||
}, [readOnly]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'.ql-toolbar.ql-snow': {
|
||||
border: 'none',
|
||||
pt: 0,
|
||||
px: 0
|
||||
},
|
||||
'.ql-toolbar.ql-snow + .ql-container.ql-snow': {
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.800',
|
||||
borderTop: 'auto',
|
||||
borderRadius: 1.5,
|
||||
height: 200
|
||||
},
|
||||
'.ql-container.ql-snow': {
|
||||
...(readOnly ? { border: 'none' } : {})
|
||||
},
|
||||
'.ql-editor': {
|
||||
...(readOnly ? { p: 0 } : {})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
onChange={onChange}
|
||||
formats={tools}
|
||||
modules={modules}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactQuill from "react-quill";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const style = {
|
||||
".ql-toolbar.ql-snow": {
|
||||
border: "none",
|
||||
pt: 0,
|
||||
px: 0,
|
||||
},
|
||||
".ql-toolbar.ql-snow + .ql-container.ql-snow": {
|
||||
border: "1px solid",
|
||||
borderColor: "grey.800",
|
||||
borderTop: "auto",
|
||||
borderRadius: 1.5,
|
||||
height: 200,
|
||||
},
|
||||
};
|
||||
|
||||
const tools = ["bold", "italic", "underline", "strike", "bullet", "link"];
|
||||
|
||||
export const MarkdownEditor = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<Box sx={style}>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
formats={tools}
|
||||
modules={{
|
||||
toolbar: tools,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -23,8 +23,8 @@ export const ToolMenu = () => {
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: theme.spacing(4),
|
||||
right: theme.spacing(4),
|
||||
top: theme.customVars.appPadding.y,
|
||||
right: theme.customVars.appPadding.x,
|
||||
height: theme.customVars.toolMenu.height,
|
||||
borderRadius: 2
|
||||
}}
|
||||
@@ -24,6 +24,7 @@ export const Cursor: InteractionReducer = {
|
||||
type: 'EMPTY_TILE',
|
||||
position: draftState.mouse.tile
|
||||
};
|
||||
draftState.itemControls = null;
|
||||
}
|
||||
},
|
||||
mouseup: () => {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Paper from 'paper';
|
||||
import gsap from 'gsap';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
@@ -13,6 +13,7 @@ import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer
|
||||
|
||||
const InitialisedRenderer = () => {
|
||||
const renderer = useRenderer();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const scene = useSceneStore(({ nodes }) => ({ nodes }));
|
||||
const gridSize = useSceneStore((state) => state.gridSize);
|
||||
const mode = useUiStateStore((state) => state.mode);
|
||||
@@ -32,6 +33,7 @@ const InitialisedRenderer = () => {
|
||||
|
||||
useEffect(() => {
|
||||
initRenderer();
|
||||
setIsReady(true);
|
||||
|
||||
return () => {
|
||||
if (activeLayer) gsap.killTweensOf(activeLayer.view);
|
||||
@@ -84,12 +86,14 @@ const InitialisedRenderer = () => {
|
||||
renderer.cursor.setVisible(isCursorVisible);
|
||||
}, [mode.type, mouse.position, renderer.cursor]);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{scene.nodes.map((node) => (
|
||||
<Node
|
||||
key={node.id}
|
||||
{...node}
|
||||
node={node}
|
||||
parentContainer={renderer.nodeManager.container as paper.Group}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,55 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
import { getTilePosition } from '../../utils/gridHelpers';
|
||||
import { NodeLabel } from './NodeLabel';
|
||||
import { useNodeTile } from './useNodeTile';
|
||||
import {
|
||||
getTilePosition,
|
||||
getTileScreenPosition
|
||||
} from '../../utils/gridHelpers';
|
||||
import { useLabelConnector } from './useLabelConnector';
|
||||
|
||||
export interface NodeProps {
|
||||
position: Coords;
|
||||
iconId: string;
|
||||
node: NodeInterface;
|
||||
parentContainer: paper.Group;
|
||||
}
|
||||
|
||||
export const Node = ({ position, iconId, parentContainer }: NodeProps) => {
|
||||
const isEmptyLabel = (label: string) => label === '<p><br></p>' || label === '';
|
||||
|
||||
export const Node = ({ node, parentContainer }: NodeProps) => {
|
||||
const [isFirstDisplay, setIsFirstDisplay] = useState(true);
|
||||
const container = useRef(new Group());
|
||||
const groupRef = useRef(new Group());
|
||||
const labelRef = useRef<HTMLDivElement>();
|
||||
const nodeIcon = useNodeIcon();
|
||||
const labelConnector = useLabelConnector();
|
||||
const nodeTile = useNodeTile();
|
||||
const scroll = useUiStateStore((state) => state.scroll);
|
||||
const zoom = useUiStateStore((state) => state.zoom);
|
||||
const mode = useUiStateStore((state) => state.mode);
|
||||
const [labelSize, setLabelSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const {
|
||||
init: initNodeIcon,
|
||||
update: updateNodeIcon,
|
||||
isLoaded: isIconLoaded
|
||||
} = nodeIcon;
|
||||
const {
|
||||
init: initLabelConnector,
|
||||
updateHeight: updateLabelHeight,
|
||||
setVisible: setLabelConnectorVisible
|
||||
} = labelConnector;
|
||||
const { init: initNodeTile } = nodeTile;
|
||||
|
||||
useEffect(() => {
|
||||
const nodeIconContainer = initNodeIcon();
|
||||
const labelConnectorContainer = initLabelConnector();
|
||||
const nodeColorContainer = initNodeTile();
|
||||
|
||||
container.current.removeChildren();
|
||||
container.current.addChild(nodeIconContainer);
|
||||
parentContainer.addChild(container.current);
|
||||
}, [initNodeIcon, parentContainer]);
|
||||
groupRef.current.removeChildren();
|
||||
groupRef.current.addChild(nodeColorContainer);
|
||||
groupRef.current.addChild(labelConnectorContainer);
|
||||
groupRef.current.addChild(nodeIconContainer);
|
||||
groupRef.current.pivot = nodeIconContainer.bounds.bottomCenter;
|
||||
parentContainer.addChild(groupRef.current);
|
||||
}, [initNodeIcon, parentContainer, initLabelConnector, initNodeTile]);
|
||||
|
||||
useEffect(() => {
|
||||
updateNodeIcon(iconId);
|
||||
}, [iconId, updateNodeIcon]);
|
||||
updateNodeIcon(node.iconId);
|
||||
}, [node.iconId, updateNodeIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIconLoaded) return;
|
||||
|
||||
const tweenValues = Coords.fromObject(container.current.position);
|
||||
const endState = getTilePosition(position);
|
||||
const tweenValues = Coords.fromObject(groupRef.current.position);
|
||||
const endState = getTilePosition(node.position);
|
||||
|
||||
gsap.to(tweenValues, {
|
||||
duration: isFirstDisplay ? 0 : 0.1,
|
||||
...endState,
|
||||
onUpdate: () => {
|
||||
container.current.position.set(tweenValues);
|
||||
groupRef.current.position.set(tweenValues);
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirstDisplay) setIsFirstDisplay(false);
|
||||
}, [position, isFirstDisplay, isIconLoaded]);
|
||||
}, [node.position, isFirstDisplay, isIconLoaded]);
|
||||
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if (!labelRef.current) return;
|
||||
|
||||
const screenPosition = getTileScreenPosition({
|
||||
position: node.position,
|
||||
scrollPosition: scroll.position,
|
||||
zoom,
|
||||
origin: 'top'
|
||||
});
|
||||
|
||||
gsap.to(labelRef.current, {
|
||||
duration: mode.type === 'PAN' ? 0 : 0.1,
|
||||
left: screenPosition.x - labelSize.width * 0.5,
|
||||
top: screenPosition.y - labelSize.height - node.labelHeight * zoom,
|
||||
scale: zoom
|
||||
});
|
||||
}, [node.position, node.labelHeight, zoom, scroll.position, mode, labelSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelConnectorVisible(!isEmptyLabel(node.label));
|
||||
|
||||
if (!labelRef.current) return;
|
||||
|
||||
setLabelSize({
|
||||
width: labelRef.current.clientWidth ?? 0,
|
||||
height: labelRef.current.clientHeight ?? 0
|
||||
});
|
||||
}, [node.label, setLabelConnectorVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
updateLabelHeight(node.labelHeight);
|
||||
}, [node.labelHeight, updateLabelHeight]);
|
||||
|
||||
if (!node.label) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={labelRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
transformOrigin: 'bottom center'
|
||||
}}
|
||||
>
|
||||
<NodeLabel label={node.label} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
24
src/renderer/components/node/NodeLabel.tsx
Normal file
24
src/renderer/components/node/NodeLabel.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const NodeLabel = ({ label }: Props) => (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'common.white',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.500',
|
||||
maxWidth: 200,
|
||||
maxHeight: 150,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
p: 1.5
|
||||
}}
|
||||
>
|
||||
<MarkdownEditor readOnly value={label} />
|
||||
</Box>
|
||||
);
|
||||
51
src/renderer/components/node/useLabelConnector.ts
Normal file
51
src/renderer/components/node/useLabelConnector.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { Group, Path, Point } from 'paper';
|
||||
import { useTheme } from '@mui/material';
|
||||
import {
|
||||
PIXEL_UNIT,
|
||||
PROJECTED_TILE_DIMENSIONS
|
||||
} from 'src/renderer/utils/constants';
|
||||
|
||||
export const useLabelConnector = () => {
|
||||
const theme = useTheme();
|
||||
const containerRef = useRef(new Group());
|
||||
const pathRef = useRef<paper.Path.Line>();
|
||||
|
||||
const updateHeight = useCallback((labelHeight: number) => {
|
||||
if (!pathRef.current) return;
|
||||
|
||||
pathRef.current.segments[1].point.y = -(
|
||||
labelHeight +
|
||||
PROJECTED_TILE_DIMENSIONS.y * 0.5
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setVisible = useCallback((state: boolean) => {
|
||||
containerRef.current.visible = state;
|
||||
}, []);
|
||||
|
||||
const init = useCallback(() => {
|
||||
containerRef.current.removeChildren();
|
||||
|
||||
pathRef.current = new Path.Line({
|
||||
strokeColor: theme.palette.grey[800],
|
||||
strokeWidth: PIXEL_UNIT * 2.5,
|
||||
dashArray: [0, PIXEL_UNIT * 6],
|
||||
strokeJoin: 'round',
|
||||
strokeCap: 'round',
|
||||
dashOffset: PIXEL_UNIT * 4,
|
||||
from: new Point(0, 0)
|
||||
});
|
||||
|
||||
containerRef.current.addChild(pathRef.current);
|
||||
|
||||
return containerRef.current;
|
||||
}, [theme.palette.grey]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
init,
|
||||
updateHeight,
|
||||
setVisible
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
import { Group, Raster } from 'paper';
|
||||
import { Group, Raster, Point } from 'paper';
|
||||
import { useSceneStore } from 'src/stores/useSceneStore';
|
||||
import { PROJECTED_TILE_DIMENSIONS } from '../../utils/constants';
|
||||
|
||||
@@ -13,6 +13,7 @@ export const useNodeIcon = () => {
|
||||
const update = useCallback(
|
||||
async (iconId: string) => {
|
||||
setIsLoaded(false);
|
||||
container.current.removeChildren();
|
||||
|
||||
const icon = icons.find((_icon) => _icon.id === iconId);
|
||||
|
||||
@@ -31,9 +32,12 @@ export const useNodeIcon = () => {
|
||||
|
||||
const raster = iconRaster.rasterize();
|
||||
|
||||
container.current.removeChildren();
|
||||
container.current.addChild(raster);
|
||||
container.current.pivot = iconRaster.bounds.bottomCenter;
|
||||
container.current.position = new Point(
|
||||
0,
|
||||
PROJECTED_TILE_DIMENSIONS.y * 0.5
|
||||
);
|
||||
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
38
src/renderer/components/node/useNodeTile.tsx
Normal file
38
src/renderer/components/node/useNodeTile.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Group, Shape } from 'paper';
|
||||
import { TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
|
||||
import { applyProjectionMatrix } from 'src/renderer/utils/projection';
|
||||
|
||||
export const useNodeTile = () => {
|
||||
const containerRef = useRef(new Group());
|
||||
|
||||
const init = useCallback(() => {
|
||||
containerRef.current.removeChildren();
|
||||
containerRef.current.set({ pivot: [0, 0] });
|
||||
|
||||
const rectangle = new Shape.Rectangle({
|
||||
strokeCap: 'round',
|
||||
fillColor: 'blue',
|
||||
size: [TILE_SIZE, TILE_SIZE],
|
||||
radius: PIXEL_UNIT * 8,
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
pivot: [0, 0],
|
||||
position: [0, 0],
|
||||
dashArray: null
|
||||
});
|
||||
|
||||
containerRef.current.addChild(rectangle);
|
||||
applyProjectionMatrix(containerRef.current);
|
||||
|
||||
rectangle.position.set(0, 0);
|
||||
rectangle.scaling.set(1.2);
|
||||
rectangle.applyMatrix = true;
|
||||
|
||||
return containerRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
init
|
||||
};
|
||||
};
|
||||
@@ -75,25 +75,24 @@ export const getItemsByTile = ({ tile, sceneItems }: GetItemsByTile) => {
|
||||
return { nodes };
|
||||
};
|
||||
|
||||
interface GetTileScreenPosition {
|
||||
interface CanvasCoordsToScreenCoords {
|
||||
position: Coords;
|
||||
scrollPosition: Coords;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export const getTileScreenPosition = ({
|
||||
export const canvasCoordsToScreenCoords = ({
|
||||
position,
|
||||
scrollPosition,
|
||||
zoom
|
||||
}: GetTileScreenPosition) => {
|
||||
}: CanvasCoordsToScreenCoords) => {
|
||||
const { width: viewW, height: viewH } = Paper.view.bounds;
|
||||
const { offsetLeft: offsetX, offsetTop: offsetY } =
|
||||
Paper.project.view.element;
|
||||
const tilePosition = getTileBounds(position).center;
|
||||
const container = Paper.project.activeLayer.children[0];
|
||||
const globalItemsGroupPosition = container.globalToLocal([0, 0]);
|
||||
const screenPosition = new Coords(
|
||||
(tilePosition.x +
|
||||
const onScreenPosition = new Coords(
|
||||
(position.x +
|
||||
scrollPosition.x +
|
||||
globalItemsGroupPosition.x +
|
||||
container.position.x +
|
||||
@@ -101,7 +100,7 @@ export const getTileScreenPosition = ({
|
||||
zoom +
|
||||
offsetX,
|
||||
|
||||
(tilePosition.y +
|
||||
(position.y +
|
||||
scrollPosition.y +
|
||||
globalItemsGroupPosition.y +
|
||||
container.position.y +
|
||||
@@ -110,5 +109,25 @@ export const getTileScreenPosition = ({
|
||||
offsetY
|
||||
);
|
||||
|
||||
return screenPosition;
|
||||
return onScreenPosition;
|
||||
};
|
||||
|
||||
type GetTileScreenPosition = CanvasCoordsToScreenCoords & {
|
||||
origin?: 'center' | 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
|
||||
export const getTileScreenPosition = ({
|
||||
position,
|
||||
scrollPosition,
|
||||
zoom,
|
||||
origin = 'center'
|
||||
}: GetTileScreenPosition) => {
|
||||
const tilePosition = getTileBounds(position)[origin];
|
||||
const onScreenPosition = canvasCoordsToScreenCoords({
|
||||
position: tilePosition,
|
||||
scrollPosition,
|
||||
zoom
|
||||
});
|
||||
|
||||
return onScreenPosition;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { produce } from 'immer';
|
||||
import { NODE_DEFAULTS } from 'src/utils/defaults';
|
||||
import { IconInput } from '../validation/SceneSchema';
|
||||
import { Coords } from '../utils/Coords';
|
||||
|
||||
@@ -17,7 +19,8 @@ export interface Node {
|
||||
type: SceneItemTypeEnum.NODE;
|
||||
id: string;
|
||||
iconId: string;
|
||||
label?: string;
|
||||
label: string;
|
||||
labelHeight: number;
|
||||
position: Coords;
|
||||
isSelected: boolean;
|
||||
}
|
||||
@@ -37,7 +40,6 @@ export interface SceneActions {
|
||||
set: (scene: Scene) => void;
|
||||
setItems: (elements: SceneItems) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>) => void;
|
||||
getNodeById: (id: string) => Node | undefined;
|
||||
createNode: (position: Coords) => void;
|
||||
}
|
||||
|
||||
@@ -56,10 +58,6 @@ export const useSceneStore = create<UseSceneStore>((set, get) => ({
|
||||
setItems: (items: SceneItems) => {
|
||||
set({ nodes: items.nodes });
|
||||
},
|
||||
getNodeById: (id: string) => {
|
||||
const { nodes } = get();
|
||||
return nodes.find((node) => node.id === id);
|
||||
},
|
||||
updateNode: (id, updates) => {
|
||||
const { nodes } = get();
|
||||
const nodeIndex = nodes.findIndex((node) => node.id === id);
|
||||
@@ -77,6 +75,7 @@ export const useSceneStore = create<UseSceneStore>((set, get) => ({
|
||||
createNode: (position) => {
|
||||
const { nodes, icons } = get();
|
||||
const newNode: Node = {
|
||||
...NODE_DEFAULTS,
|
||||
id: uuid(),
|
||||
type: SceneItemTypeEnum.NODE,
|
||||
iconId: icons[0].id,
|
||||
@@ -88,3 +87,14 @@ export const useSceneStore = create<UseSceneStore>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const useNodeHooks = () => {
|
||||
const nodes = useSceneStore((state) => state.nodes);
|
||||
|
||||
const useGetNodeById = useCallback(
|
||||
(id: string) => nodes.find((node) => node.id === id),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
return { useGetNodeById };
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ declare module '@mui/material/styles' {
|
||||
|
||||
const customVars: CustomThemeVars = {
|
||||
appPadding: {
|
||||
x: 60,
|
||||
y: 60
|
||||
x: 40,
|
||||
y: 40
|
||||
},
|
||||
toolMenu: {
|
||||
height: 55
|
||||
|
||||
4
src/utils/defaults.ts
Normal file
4
src/utils/defaults.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const NODE_DEFAULTS = {
|
||||
label: '',
|
||||
labelHeight: 100
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import gsap from 'gsap';
|
||||
import { Coords } from 'src/utils/Coords';
|
||||
import type { NodeInput } from 'src/validation/SceneSchema';
|
||||
import { Node, SceneItemTypeEnum } from 'src/stores/useSceneStore';
|
||||
import { NODE_DEFAULTS } from 'src/utils/defaults';
|
||||
|
||||
export const clamp = (num: number, min: number, max: number) =>
|
||||
num <= min ? min : num >= max ? max : num;
|
||||
@@ -47,7 +48,8 @@ export const roundToOneDecimalPlace = (num: number) =>
|
||||
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
|
||||
const node: Node = {
|
||||
id: nodeInput.id,
|
||||
label: nodeInput.label,
|
||||
label: nodeInput.label ?? NODE_DEFAULTS.label,
|
||||
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
|
||||
iconId: nodeInput.iconId,
|
||||
position: Coords.fromObject(nodeInput.position),
|
||||
isSelected: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ export const iconInput = z.object({
|
||||
export const nodeInput = z.object({
|
||||
id: z.string(),
|
||||
label: z.string().optional(),
|
||||
labelHeight: z.number().optional(),
|
||||
iconId: z.string(),
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
|
||||
Reference in New Issue
Block a user