mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
feat: implements text tool
This commit is contained in:
@@ -17,7 +17,7 @@ import { sceneToSceneInput } from 'src/utils';
|
||||
import { useSceneStore, SceneProvider } from 'src/stores/sceneStore';
|
||||
import { GlobalStyles } from 'src/styles/GlobalStyles';
|
||||
import { Renderer } from 'src/components/Renderer/Renderer';
|
||||
import { LabelContainer } from 'src/components/Nodes/Node/LabelContainer';
|
||||
import { LabelContainer } from 'src/components/SceneLayers/Nodes/Node/LabelContainer';
|
||||
import { useWindowUtils } from 'src/hooks/useWindowUtils';
|
||||
import { sceneInput as sceneValidationSchema } from 'src/validation/scene';
|
||||
import { EMPTY_SCENE } from 'src/config';
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { Connector as ConnectorI } from 'src/types';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
import {
|
||||
getAnchorPosition,
|
||||
getRectangleFromSize,
|
||||
CoordsUtils,
|
||||
getColorVariant
|
||||
} from 'src/utils';
|
||||
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
|
||||
import { Circle } from 'src/components/Circle/Circle';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
|
||||
interface Props {
|
||||
connector: ConnectorI;
|
||||
}
|
||||
|
||||
export const Connector = ({ connector }: Props) => {
|
||||
const theme = useTheme();
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const nodes = useSceneStore((state) => {
|
||||
return state.nodes;
|
||||
});
|
||||
|
||||
const unprojectedTileSize = useMemo(() => {
|
||||
return UNPROJECTED_TILE_SIZE * zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const drawOffset = useMemo(() => {
|
||||
return {
|
||||
x: unprojectedTileSize / 2,
|
||||
y: unprojectedTileSize / 2
|
||||
};
|
||||
}, [unprojectedTileSize]);
|
||||
|
||||
const pathString = useMemo(() => {
|
||||
return connector.path.tiles.reduce((acc, tile) => {
|
||||
return `${acc} ${tile.x * unprojectedTileSize + drawOffset.x},${
|
||||
tile.y * unprojectedTileSize + drawOffset.y
|
||||
}`;
|
||||
}, '');
|
||||
}, [unprojectedTileSize, connector.path.tiles, drawOffset]);
|
||||
|
||||
const anchorPositions = useMemo(() => {
|
||||
return connector.anchors.map((anchor) => {
|
||||
const position = getAnchorPosition({ anchor, nodes });
|
||||
|
||||
return {
|
||||
x: (connector.path.origin.x - position.x) * unprojectedTileSize,
|
||||
y: (connector.path.origin.y - position.y) * unprojectedTileSize
|
||||
};
|
||||
});
|
||||
}, [connector.path.origin, connector.anchors, nodes, unprojectedTileSize]);
|
||||
|
||||
const connectorWidthPx = useMemo(() => {
|
||||
return (unprojectedTileSize / 100) * connector.width;
|
||||
}, [connector.width, unprojectedTileSize]);
|
||||
|
||||
const strokeDashArray = useMemo(() => {
|
||||
switch (connector.style) {
|
||||
case 'DASHED':
|
||||
return `${connectorWidthPx * 2}, ${connectorWidthPx * 2}`;
|
||||
case 'DOTTED':
|
||||
return `0, ${connectorWidthPx * 1.8}`;
|
||||
case 'SOLID':
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}, [connector.style, connectorWidthPx]);
|
||||
|
||||
return (
|
||||
<IsoTileArea
|
||||
{...getRectangleFromSize(connector.path.origin, connector.path.areaSize)}
|
||||
origin={connector.path.origin}
|
||||
zoom={zoom}
|
||||
fill="none"
|
||||
>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={getColorVariant(connector.color, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{anchorPositions.map((anchor) => {
|
||||
return (
|
||||
<>
|
||||
<Circle
|
||||
position={CoordsUtils.add(anchor, drawOffset)}
|
||||
radius={18 * zoom}
|
||||
fill={theme.palette.common.white}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
<Circle
|
||||
position={CoordsUtils.add(anchor, drawOffset)}
|
||||
radius={12 * zoom}
|
||||
stroke={theme.palette.common.black}
|
||||
fill={theme.palette.common.white}
|
||||
strokeWidth={6 * zoom}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</IsoTileArea>
|
||||
);
|
||||
};
|
||||
@@ -5,20 +5,19 @@ import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
|
||||
export const Cursor = () => {
|
||||
const theme = useTheme();
|
||||
const tile = useUiStateStore((state) => {
|
||||
return state.mouse.position.tile;
|
||||
});
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<IsoTileArea
|
||||
from={tile}
|
||||
to={tile}
|
||||
fill={chroma(theme.palette.primary.main).alpha(0.5).css()}
|
||||
zoom={zoom}
|
||||
cornerRadius={10 * zoom}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/Nodes/Node/NodeIcon';
|
||||
import { NodeIcon } from 'src/components/SceneLayers/Nodes/Node/NodeIcon';
|
||||
|
||||
interface Props {
|
||||
icon: IconInput;
|
||||
|
||||
@@ -1,76 +1,31 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
import { Size, Coords, TileOriginEnum } from 'src/types';
|
||||
import {
|
||||
getIsoMatrixCSS,
|
||||
getProjectedTileSize,
|
||||
getBoundingBox
|
||||
} from 'src/utils';
|
||||
import { Coords } from 'src/types';
|
||||
import { Svg } from 'src/components/Svg/Svg';
|
||||
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
|
||||
import { useIsoProjection } from 'src/hooks/useIsoProjection';
|
||||
|
||||
interface Props {
|
||||
from: Coords;
|
||||
to: Coords;
|
||||
origin?: Coords;
|
||||
fill: string;
|
||||
fill?: string;
|
||||
cornerRadius?: number;
|
||||
stroke?: {
|
||||
width: number;
|
||||
color: string;
|
||||
};
|
||||
zoom: number;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const IsoTileArea = ({
|
||||
from,
|
||||
to,
|
||||
origin: _origin,
|
||||
fill,
|
||||
fill = 'none',
|
||||
cornerRadius = 0,
|
||||
stroke,
|
||||
zoom,
|
||||
children
|
||||
stroke
|
||||
}: Props) => {
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
const projectedTileSize = useMemo(() => {
|
||||
return getProjectedTileSize({ zoom });
|
||||
}, [zoom]);
|
||||
|
||||
const size = useMemo(() => {
|
||||
return {
|
||||
width: Math.abs(from.x - to.x) + 1,
|
||||
height: Math.abs(from.y - to.y) + 1
|
||||
};
|
||||
}, [from, to]);
|
||||
|
||||
const origin = useMemo(() => {
|
||||
if (_origin) return _origin;
|
||||
|
||||
const boundingBox = getBoundingBox([from, to]);
|
||||
|
||||
return boundingBox[2];
|
||||
}, [from, to, _origin]);
|
||||
|
||||
const position = useMemo(() => {
|
||||
return getTilePosition({
|
||||
tile: origin,
|
||||
origin: TileOriginEnum.TOP
|
||||
});
|
||||
}, [origin, getTilePosition]);
|
||||
|
||||
const viewbox = useMemo<Size>(() => {
|
||||
return {
|
||||
width: (size.width / 2 + size.height / 2) * projectedTileSize.width,
|
||||
height: (size.width / 2 + size.height / 2) * projectedTileSize.height
|
||||
};
|
||||
}, [size, projectedTileSize]);
|
||||
|
||||
const translate = useMemo<Coords>(() => {
|
||||
return { x: size.width * (projectedTileSize.width / 2), y: 0 };
|
||||
}, [size, projectedTileSize]);
|
||||
const { css, pxSize } = useIsoProjection({
|
||||
from,
|
||||
to
|
||||
});
|
||||
|
||||
const strokeParams = useMemo(() => {
|
||||
if (!stroke) return {};
|
||||
@@ -81,37 +36,15 @@ export const IsoTileArea = ({
|
||||
};
|
||||
}, [stroke]);
|
||||
|
||||
const marginLeft = useMemo(() => {
|
||||
return -(size.width * projectedTileSize.width * 0.5);
|
||||
}, [projectedTileSize.width, size.width]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
marginLeft: `${marginLeft}px`,
|
||||
left: position.x,
|
||||
top: position.y
|
||||
}}
|
||||
>
|
||||
<Svg
|
||||
viewBox={`0 0 ${viewbox.width} ${viewbox.height}`}
|
||||
width={`${viewbox.width}px`}
|
||||
height={`${viewbox.height}px`}
|
||||
>
|
||||
<g transform={`translate(${translate.x}, ${translate.y})`}>
|
||||
<g transform={getIsoMatrixCSS()}>
|
||||
<rect
|
||||
width={size.width * UNPROJECTED_TILE_SIZE * zoom}
|
||||
height={size.height * UNPROJECTED_TILE_SIZE * zoom}
|
||||
fill={fill}
|
||||
rx={cornerRadius}
|
||||
{...strokeParams}
|
||||
/>
|
||||
{children}
|
||||
</g>
|
||||
</g>
|
||||
</Svg>
|
||||
</Box>
|
||||
<Svg viewboxSize={pxSize} style={css}>
|
||||
<rect
|
||||
width={pxSize.width}
|
||||
height={pxSize.height}
|
||||
fill={fill}
|
||||
rx={cornerRadius}
|
||||
{...strokeParams}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconSelection } from 'src/components/ItemControls/IconSelection/IconSel
|
||||
import { UiElement } from 'components/UiElement/UiElement';
|
||||
import { NodeControls } from './NodeControls/NodeControls';
|
||||
import { ConnectorControls } from './ConnectorControls/ConnectorControls';
|
||||
import { TextBoxControls } from './TextBoxControls/TextBoxControls';
|
||||
import { RectangleControls } from './RectangleControls/RectangleControls';
|
||||
|
||||
export const ItemControlsManager = () => {
|
||||
@@ -19,6 +20,8 @@ export const ItemControlsManager = () => {
|
||||
return <NodeControls key={itemControls.id} id={itemControls.id} />;
|
||||
case 'CONNECTOR':
|
||||
return <ConnectorControls key={itemControls.id} id={itemControls.id} />;
|
||||
case 'TEXTBOX':
|
||||
return <TextBoxControls key={itemControls.id} id={itemControls.id} />;
|
||||
case 'RECTANGLE':
|
||||
return <RectangleControls key={itemControls.id} id={itemControls.id} />;
|
||||
case 'ADD_ITEM':
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TextBox, ProjectionOrientationEnum } from 'src/types';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Slider
|
||||
} from '@mui/material';
|
||||
import { TextRotationNone as TextRotationNoneIcon } from '@mui/icons-material';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { useTextBox } from 'src/hooks/useTextBox';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { getIsoMatrixCSS } from 'src/utils';
|
||||
import { ControlsContainer } from '../components/ControlsContainer';
|
||||
import { Section } from '../components/Section';
|
||||
import { Header } from '../components/Header';
|
||||
import { DeleteButton } from '../components/DeleteButton';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const TextBoxControls = ({ id }: Props) => {
|
||||
const sceneActions = useSceneStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
const uiStateActions = useUiStateStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
const textBox = useTextBox(id);
|
||||
|
||||
const onTextBoxUpdated = useCallback(
|
||||
(updates: Partial<TextBox>) => {
|
||||
sceneActions.updateTextBox(id, updates);
|
||||
},
|
||||
[sceneActions, id]
|
||||
);
|
||||
|
||||
const onTextBoxDeleted = useCallback(() => {
|
||||
uiStateActions.setItemControls(null);
|
||||
sceneActions.deleteTextBox(id);
|
||||
}, [sceneActions, id, uiStateActions]);
|
||||
|
||||
return (
|
||||
<ControlsContainer header={<Header title="Text settings" />}>
|
||||
<Section>
|
||||
<TextField
|
||||
value={textBox.text}
|
||||
onChange={(e) => {
|
||||
onTextBoxUpdated({ text: e.target.value as string });
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Text size">
|
||||
<Slider
|
||||
marks
|
||||
step={0.3}
|
||||
min={0.3}
|
||||
max={0.9}
|
||||
value={textBox.fontSize}
|
||||
onChange={(e, newSize) => {
|
||||
onTextBoxUpdated({ fontSize: newSize as number });
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Alignment">
|
||||
<ToggleButtonGroup
|
||||
value={textBox.orientation}
|
||||
exclusive
|
||||
onChange={(e, orientation) => {
|
||||
if (textBox.orientation === orientation || orientation === null)
|
||||
return;
|
||||
|
||||
onTextBoxUpdated({ orientation });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={ProjectionOrientationEnum.X}>
|
||||
<TextRotationNoneIcon sx={{ transform: getIsoMatrixCSS() }} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ProjectionOrientationEnum.Y}>
|
||||
<TextRotationNoneIcon
|
||||
sx={{
|
||||
transform: `scale(-1, 1) ${getIsoMatrixCSS()} scale(-1, 1)`
|
||||
}}
|
||||
/>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Section>
|
||||
<Section>
|
||||
<Box>
|
||||
<DeleteButton onClick={onTextBoxDeleted} />
|
||||
</Box>
|
||||
</Section>
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const Section = ({ children, sx, title }: Props) => {
|
||||
>
|
||||
<Stack>
|
||||
{title && (
|
||||
<Typography variant="body2" color="text.secondary" pb={0.5}>
|
||||
<Typography variant="body2" color="text.secondary" pb={1}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
height?: number;
|
||||
styles?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const tools = ['bold', 'italic', 'underline', 'strike', 'link'];
|
||||
@@ -15,7 +16,8 @@ export const MarkdownEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
height = 120
|
||||
height = 120,
|
||||
styles
|
||||
}: Props) => {
|
||||
const modules = useMemo(() => {
|
||||
if (!readOnly)
|
||||
@@ -42,7 +44,8 @@ export const MarkdownEditor = ({
|
||||
height
|
||||
},
|
||||
'.ql-container.ql-snow': {
|
||||
...(readOnly ? { border: 'none' } : {})
|
||||
...(readOnly ? { border: 'none' } : {}),
|
||||
...styles
|
||||
},
|
||||
'.ql-editor': {
|
||||
...(readOnly ? { p: 0 } : {})
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useInteractionManager } from 'src/interaction/useInteractionManager';
|
||||
import { Grid } from 'src/components/Grid/Grid';
|
||||
import { Cursor } from 'src/components/Cursor/Cursor';
|
||||
import { Nodes } from 'src/components/Nodes/Nodes';
|
||||
import { Rectangles } from 'src/components/Rectangles/Rectangles';
|
||||
import { Connectors } from 'src/components/Connectors/Connectors';
|
||||
import { Nodes } from 'src/components/SceneLayers/Nodes/Nodes';
|
||||
import { Rectangles } from 'src/components/SceneLayers/Rectangles/Rectangles';
|
||||
import { Connectors } from 'src/components/SceneLayers/Connectors/Connectors';
|
||||
import { TextBoxes } from 'src/components/SceneLayers/TextBoxes/TextBoxes';
|
||||
import { DebugUtils } from 'src/components/DebugUtils/DebugUtils';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
|
||||
@@ -64,6 +65,9 @@ export const Renderer = () => {
|
||||
<SceneLayer>
|
||||
<Connectors />
|
||||
</SceneLayer>
|
||||
<SceneLayer>
|
||||
<TextBoxes />
|
||||
</SceneLayer>
|
||||
<SceneLayer>
|
||||
<Nodes />
|
||||
</SceneLayer>
|
||||
|
||||
127
src/components/SceneLayers/Connectors/Connector.tsx
Normal file
127
src/components/SceneLayers/Connectors/Connector.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTheme, Box } from '@mui/material';
|
||||
import { Connector as ConnectorI } from 'src/types';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
import { getAnchorPosition, CoordsUtils, getColorVariant } from 'src/utils';
|
||||
import { Circle } from 'src/components/Circle/Circle';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { Svg } from 'src/components/Svg/Svg';
|
||||
import { useIsoProjection } from 'src/hooks/useIsoProjection';
|
||||
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
|
||||
|
||||
interface Props {
|
||||
connector: ConnectorI;
|
||||
}
|
||||
|
||||
export const Connector = ({ connector }: Props) => {
|
||||
const theme = useTheme();
|
||||
const { css, pxSize } = useIsoProjection({
|
||||
...connector.path.rectangle
|
||||
});
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const unprojectedTileSize = useMemo(() => {
|
||||
return UNPROJECTED_TILE_SIZE * zoom;
|
||||
}, [zoom]);
|
||||
const nodes = useSceneStore((state) => {
|
||||
return state.nodes;
|
||||
});
|
||||
|
||||
const drawOffset = useMemo(() => {
|
||||
return {
|
||||
x: unprojectedTileSize / 2,
|
||||
y: unprojectedTileSize / 2
|
||||
};
|
||||
}, [unprojectedTileSize]);
|
||||
|
||||
const pathString = useMemo(() => {
|
||||
return connector.path.tiles.reduce((acc, tile) => {
|
||||
return `${acc} ${tile.x * unprojectedTileSize + drawOffset.x},${
|
||||
tile.y * unprojectedTileSize + drawOffset.y
|
||||
}`;
|
||||
}, '');
|
||||
}, [unprojectedTileSize, connector.path.tiles, drawOffset]);
|
||||
|
||||
const anchorPositions = useMemo(() => {
|
||||
return connector.anchors.map((anchor) => {
|
||||
const position = getAnchorPosition({ anchor, nodes });
|
||||
|
||||
return {
|
||||
x: (connector.path.rectangle.from.x - position.x) * unprojectedTileSize,
|
||||
y: (connector.path.rectangle.from.y - position.y) * unprojectedTileSize
|
||||
};
|
||||
});
|
||||
}, [connector.path.rectangle, connector.anchors, nodes, unprojectedTileSize]);
|
||||
|
||||
const connectorWidthPx = useMemo(() => {
|
||||
return (unprojectedTileSize / 100) * connector.width;
|
||||
}, [connector.width, unprojectedTileSize]);
|
||||
|
||||
const strokeDashArray = useMemo(() => {
|
||||
switch (connector.style) {
|
||||
case 'DASHED':
|
||||
return `${connectorWidthPx * 2}, ${connectorWidthPx * 2}`;
|
||||
case 'DOTTED':
|
||||
return `0, ${connectorWidthPx * 1.8}`;
|
||||
case 'SOLID':
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}, [connector.style, connectorWidthPx]);
|
||||
|
||||
return (
|
||||
<Box sx={css}>
|
||||
<Svg
|
||||
style={{
|
||||
// TODO: The original x coordinates of each tile seems to be calculated wrongly.
|
||||
// They are mirrored along the x-axis. The hack below fixes this, but we should
|
||||
// try to fix this issue at the root of the problem (might have further implications).
|
||||
transform: 'scale(-1, 1)'
|
||||
}}
|
||||
viewboxSize={pxSize}
|
||||
>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={getColorVariant(connector.color, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{anchorPositions.map((anchor, i) => {
|
||||
return (
|
||||
<>
|
||||
<Circle
|
||||
position={CoordsUtils.add(anchor, drawOffset)}
|
||||
radius={18 * zoom}
|
||||
fill={theme.palette.common.white}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
<Circle
|
||||
position={CoordsUtils.add(anchor, drawOffset)}
|
||||
radius={12 * zoom}
|
||||
stroke={theme.palette.common.black}
|
||||
fill={i === 0 ? 'red' : theme.palette.common.white}
|
||||
strokeWidth={6 * zoom}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { Connector } from './Connector/Connector';
|
||||
import { Connector } from './Connector';
|
||||
|
||||
export const Connectors = () => {
|
||||
const connectors = useSceneStore((state) => {
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
|
||||
import { useTileSize } from 'src/hooks/useTileSize';
|
||||
|
||||
interface Props {
|
||||
labelHeight: number;
|
||||
@@ -20,7 +20,7 @@ export const LabelContainer = ({
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const projectedTileSize = useProjectedTileSize();
|
||||
const { projectedTileSize } = useTileSize();
|
||||
const yOffset = useMemo(() => {
|
||||
return projectedTileSize.height / 2;
|
||||
}, [projectedTileSize]);
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Node as NodeI, IconInput, TileOriginEnum } from 'src/types';
|
||||
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
|
||||
import { useTileSize } from 'src/hooks/useTileSize';
|
||||
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
|
||||
import { LabelContainer } from './LabelContainer';
|
||||
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
export const Node = ({ node, icon, order }: Props) => {
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
const projectedTileSize = useProjectedTileSize();
|
||||
const { projectedTileSize } = useTileSize();
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
|
||||
const onImageLoaded = useCallback(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
|
||||
import { useTileSize } from 'src/hooks/useTileSize';
|
||||
import { useResizeObserver } from 'src/hooks/useResizeObserver';
|
||||
import { IconInput } from 'src/types';
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
|
||||
export const NodeIcon = ({ icon, onImageLoaded }: Props) => {
|
||||
const ref = useRef();
|
||||
const projectedTileSize = useProjectedTileSize();
|
||||
const { projectedTileSize } = useTileSize();
|
||||
const { size, observe, disconnect } = useResizeObserver();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,7 +20,6 @@ export const Rectangle = ({ from, to, color }: Props) => {
|
||||
from={from}
|
||||
to={to}
|
||||
fill={color}
|
||||
zoom={zoom}
|
||||
cornerRadius={22 * zoom}
|
||||
stroke={{
|
||||
color: getColorVariant(color, 'dark', { grade: 2 }),
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { DEFAULT_COLOR } from 'src/config';
|
||||
import { Rectangle } from './Rectangle/Rectangle';
|
||||
import { Rectangle } from './Rectangle';
|
||||
|
||||
export const Rectangles = () => {
|
||||
const rectangles = useSceneStore((state) => {
|
||||
84
src/components/SceneLayers/TextBoxes/TextBox.tsx
Normal file
84
src/components/SceneLayers/TextBoxes/TextBox.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { CoordsUtils, getTextWidth, toPx } from 'src/utils';
|
||||
import { TextBox as TextBoxI } from 'src/types';
|
||||
import { DEFAULT_FONT_FAMILY } from 'src/config';
|
||||
import { useIsoProjection } from 'src/hooks/useIsoProjection';
|
||||
import { useTileSize } from 'src/hooks/useTileSize';
|
||||
|
||||
interface Props {
|
||||
textBox: TextBoxI;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export const TextBox = ({ textBox, isSelected }: Props) => {
|
||||
const { unprojectedTileSize } = useTileSize();
|
||||
|
||||
const fontProps = useMemo(() => {
|
||||
return {
|
||||
fontSize: toPx(unprojectedTileSize * textBox.fontSize),
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontWeight: 'bold'
|
||||
};
|
||||
}, [unprojectedTileSize, textBox.fontSize]);
|
||||
|
||||
const paddingX = useMemo(() => {
|
||||
return unprojectedTileSize * 0.2;
|
||||
}, [unprojectedTileSize]);
|
||||
|
||||
const textWidth = useMemo(() => {
|
||||
return getTextWidth(textBox.text, fontProps) + paddingX * 2;
|
||||
}, [textBox.text, fontProps, paddingX]);
|
||||
|
||||
const to = useMemo(() => {
|
||||
return CoordsUtils.add(textBox.tile, {
|
||||
x: Math.ceil(textWidth / unprojectedTileSize),
|
||||
y: 0
|
||||
});
|
||||
}, [textBox.tile, textWidth, unprojectedTileSize]);
|
||||
|
||||
const { css } = useIsoProjection({
|
||||
from: textBox.tile,
|
||||
to,
|
||||
orientation: textBox.orientation
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={css}>
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 1,
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
px: toPx(paddingX)
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
...fontProps
|
||||
}}
|
||||
>
|
||||
{textBox.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
46
src/components/SceneLayers/TextBoxes/TextBoxes.tsx
Normal file
46
src/components/SceneLayers/TextBoxes/TextBoxes.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { textBoxInputToTextBox } from 'src/utils';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { TEXTBOX_DEFAULTS } from 'src/config';
|
||||
import { TextBox } from './TextBox';
|
||||
|
||||
export const TextBoxes = () => {
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
const mouse = useUiStateStore((state) => {
|
||||
return state.mouse;
|
||||
});
|
||||
const itemControls = useUiStateStore((state) => {
|
||||
return state.itemControls;
|
||||
});
|
||||
const textBoxes = useSceneStore((state) => {
|
||||
return state.textBoxes;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{textBoxes.map((textBox) => {
|
||||
return (
|
||||
<TextBox
|
||||
textBox={textBox}
|
||||
isSelected={
|
||||
itemControls?.type === 'TEXTBOX' && itemControls.id === textBox.id
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{mode.type === 'TEXTBOX' && (
|
||||
<TextBox
|
||||
textBox={textBoxInputToTextBox({
|
||||
id: 'temp-textbox',
|
||||
...TEXTBOX_DEFAULTS,
|
||||
tile: mouse.position.tile
|
||||
})}
|
||||
isSelected
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Size } from 'src/types';
|
||||
|
||||
type Props = React.SVGProps<SVGSVGElement> & {
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
viewboxSize?: Size;
|
||||
};
|
||||
|
||||
export const Svg = ({ children, ...rest }: Props) => {
|
||||
export const Svg = ({ children, style, viewboxSize, ...rest }: Props) => {
|
||||
const dimensionProps = useMemo(() => {
|
||||
if (!viewboxSize) return {};
|
||||
|
||||
return {
|
||||
viewBox: `0 0 ${viewboxSize.width} ${viewboxSize.height}`,
|
||||
width: `${viewboxSize.width}px`,
|
||||
height: `${viewboxSize.height}px`
|
||||
};
|
||||
}, [viewboxSize]);
|
||||
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={style}
|
||||
{...dimensionProps}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Title as TitleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { MAX_ZOOM, MIN_ZOOM } from 'src/config';
|
||||
import { MAX_ZOOM, MIN_ZOOM, TEXTBOX_DEFAULTS } from 'src/config';
|
||||
import { IconButton } from 'src/components/IconButton/IconButton';
|
||||
import { UiElement } from 'src/components/UiElement/UiElement';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TILE_PROJECTION_MULTIPLIERS: Size = {
|
||||
height: 0.819
|
||||
};
|
||||
export const DEFAULT_COLOR = customVars.diagramPalette.blue;
|
||||
|
||||
export const DEFAULT_FONT_FAMILY = 'Roboto, Arial, sans-serif';
|
||||
export const NODE_DEFAULTS = {
|
||||
label: '',
|
||||
labelHeight: 140,
|
||||
@@ -30,9 +30,15 @@ export const CONNECTOR_DEFAULTS: ConnectorDefaults = {
|
||||
width: 10,
|
||||
// The boundaries of the search area for the pathfinder algorithm
|
||||
// is the grid that encompasses the two nodes + the offset below.
|
||||
searchOffset: { x: 3, y: 3 },
|
||||
searchOffset: { x: 1, y: 1 },
|
||||
style: ConnectorStyleEnum.SOLID
|
||||
};
|
||||
|
||||
export const TEXTBOX_DEFAULTS = {
|
||||
fontSize: 0.6,
|
||||
text: 'Text'
|
||||
};
|
||||
|
||||
export const ZOOM_INCREMENT = 0.2;
|
||||
export const MIN_ZOOM = 0.2;
|
||||
export const MAX_ZOOM = 1;
|
||||
|
||||
@@ -40,17 +40,17 @@ export const initialData: InitialData = {
|
||||
{
|
||||
id: 'connector1',
|
||||
anchors: [{ nodeId: 'server' }, { nodeId: 'database' }]
|
||||
},
|
||||
{
|
||||
id: 'connector2',
|
||||
style: ConnectorStyleEnum.DOTTED,
|
||||
width: 10,
|
||||
anchors: [
|
||||
{ nodeId: 'server' },
|
||||
{ tile: { x: -1, y: 2 } },
|
||||
{ nodeId: 'client' }
|
||||
]
|
||||
}
|
||||
// {
|
||||
// id: 'connector2',
|
||||
// style: ConnectorStyleEnum.DOTTED,
|
||||
// width: 10,
|
||||
// anchors: [
|
||||
// { nodeId: 'server' },
|
||||
// { tile: { x: -1, y: 2 } },
|
||||
// { nodeId: 'client' }
|
||||
// ]
|
||||
// }
|
||||
],
|
||||
textBoxes: [],
|
||||
rectangles: [
|
||||
|
||||
81
src/hooks/useIsoProjection.ts
Normal file
81
src/hooks/useIsoProjection.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Coords,
|
||||
TileOriginEnum,
|
||||
Size,
|
||||
ProjectionOrientationEnum
|
||||
} from 'src/types';
|
||||
import { getBoundingBox, getIsoMatrixCSS } from 'src/utils';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useGetTilePosition } from './useGetTilePosition';
|
||||
|
||||
interface Props {
|
||||
from: Coords;
|
||||
to: Coords;
|
||||
originOverride?: Coords;
|
||||
orientation?: ProjectionOrientationEnum;
|
||||
}
|
||||
|
||||
export const useIsoProjection = ({
|
||||
from,
|
||||
to,
|
||||
originOverride,
|
||||
orientation
|
||||
}: Props): {
|
||||
css: React.CSSProperties;
|
||||
position: Coords;
|
||||
gridSize: Size;
|
||||
pxSize: Size;
|
||||
} => {
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
|
||||
const gridSize = useMemo(() => {
|
||||
return {
|
||||
width: Math.abs(from.x - to.x) + 1,
|
||||
height: Math.abs(from.y - to.y) + 1
|
||||
};
|
||||
}, [from, to]);
|
||||
|
||||
const origin = useMemo(() => {
|
||||
if (originOverride) return originOverride;
|
||||
|
||||
const boundingBox = getBoundingBox([from, to]);
|
||||
|
||||
return boundingBox[3];
|
||||
}, [from, to, originOverride]);
|
||||
|
||||
const position = useMemo(() => {
|
||||
const pos = getTilePosition({
|
||||
tile: origin,
|
||||
origin: orientation === 'Y' ? TileOriginEnum.TOP : TileOriginEnum.LEFT
|
||||
});
|
||||
|
||||
return pos;
|
||||
}, [origin, getTilePosition, orientation]);
|
||||
|
||||
const pxSize = useMemo(() => {
|
||||
return {
|
||||
width: gridSize.width * UNPROJECTED_TILE_SIZE * zoom,
|
||||
height: gridSize.height * UNPROJECTED_TILE_SIZE * zoom
|
||||
};
|
||||
}, [zoom, gridSize]);
|
||||
|
||||
return {
|
||||
css: {
|
||||
position: 'absolute',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: `${pxSize.width}px`,
|
||||
height: `${pxSize.height}px`,
|
||||
transform: getIsoMatrixCSS(orientation),
|
||||
transformOrigin: 'top left'
|
||||
},
|
||||
position,
|
||||
gridSize,
|
||||
pxSize
|
||||
};
|
||||
};
|
||||
@@ -7,9 +7,9 @@ export const useRectangle = (id: string) => {
|
||||
return state.rectangles;
|
||||
});
|
||||
|
||||
const node = useMemo(() => {
|
||||
const rectangle = useMemo(() => {
|
||||
return getItemById(rectangles, id).item;
|
||||
}, [rectangles, id]);
|
||||
|
||||
return node;
|
||||
return rectangle;
|
||||
};
|
||||
|
||||
15
src/hooks/useTextBox.ts
Normal file
15
src/hooks/useTextBox.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSceneStore } from 'src/stores/sceneStore';
|
||||
import { getItemById } from 'src/utils';
|
||||
|
||||
export const useTextBox = (id: string) => {
|
||||
const textBoxes = useSceneStore((state) => {
|
||||
return state.textBoxes;
|
||||
});
|
||||
|
||||
const textBox = useMemo(() => {
|
||||
return getItemById(textBoxes, id).item;
|
||||
}, [textBoxes, id]);
|
||||
|
||||
return textBox;
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { getProjectedTileSize } from 'src/utils';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
|
||||
export const useProjectedTileSize = () => {
|
||||
export const useTileSize = () => {
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
@@ -11,5 +12,9 @@ export const useProjectedTileSize = () => {
|
||||
return getProjectedTileSize({ zoom });
|
||||
}, [zoom]);
|
||||
|
||||
return projectedTileSize;
|
||||
const unprojectedTileSize = useMemo(() => {
|
||||
return UNPROJECTED_TILE_SIZE * zoom;
|
||||
}, [zoom]);
|
||||
|
||||
return { projectedTileSize, unprojectedTileSize };
|
||||
};
|
||||
@@ -59,6 +59,11 @@ export const Cursor: ModeActions = {
|
||||
type: 'CONNECTOR',
|
||||
id: uiState.mode.mousedownItem.id
|
||||
});
|
||||
} else if (uiState.mode.mousedownItem.type === 'TEXTBOX') {
|
||||
uiState.actions.setItemControls({
|
||||
type: 'TEXTBOX',
|
||||
id: uiState.mode.mousedownItem.id
|
||||
});
|
||||
}
|
||||
} else {
|
||||
uiState.actions.setItemControls(null);
|
||||
|
||||
@@ -20,6 +20,12 @@ const dragItems = (
|
||||
const newTo = CoordsUtils.add(rectangle.to, delta);
|
||||
|
||||
scene.actions.updateRectangle(item.id, { from: newFrom, to: newTo });
|
||||
} else if (item.type === 'TEXTBOX') {
|
||||
const textBox = getItemById(scene.textBoxes, item.id).item;
|
||||
|
||||
scene.actions.updateTextBox(item.id, {
|
||||
tile: CoordsUtils.add(textBox.tile, delta)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setWindowCursor } from 'src/utils';
|
||||
import { setWindowCursor, generateId } from 'src/utils';
|
||||
import { ModeActions } from 'src/types';
|
||||
import { TEXTBOX_DEFAULTS } from 'src/config';
|
||||
|
||||
export const TextBox: ModeActions = {
|
||||
entry: () => {
|
||||
@@ -9,6 +10,26 @@ export const TextBox: ModeActions = {
|
||||
setWindowCursor('default');
|
||||
},
|
||||
mousemove: () => {},
|
||||
mousedown: () => {},
|
||||
mouseup: () => {}
|
||||
mouseup: ({ scene, uiState, isRendererInteraction }) => {
|
||||
if (!isRendererInteraction) return;
|
||||
|
||||
const id = generateId();
|
||||
|
||||
scene.actions.createTextBox({
|
||||
...TEXTBOX_DEFAULTS,
|
||||
id,
|
||||
tile: uiState.mouse.position.tile
|
||||
});
|
||||
|
||||
uiState.actions.setMode({
|
||||
type: 'CURSOR',
|
||||
showCursor: true,
|
||||
mousedownItem: null
|
||||
});
|
||||
|
||||
uiState.actions.setItemControls({
|
||||
type: 'TEXTBOX',
|
||||
id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getConnectorPath,
|
||||
rectangleInputToRectangle,
|
||||
connectorInputToConnector,
|
||||
textBoxInputToTextBox,
|
||||
sceneInputToScene,
|
||||
nodeInputToNode
|
||||
} from 'src/utils';
|
||||
@@ -89,6 +90,16 @@ const initialState = () => {
|
||||
set({ nodes: newScene.nodes, connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
createConnector: (connector) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
draftState.connectors.push(
|
||||
connectorInputToConnector(connector, draftState.nodes)
|
||||
);
|
||||
});
|
||||
|
||||
set({ connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
updateConnector: (id, updates) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { item: connector, index } = getItemById(
|
||||
@@ -105,6 +116,58 @@ const initialState = () => {
|
||||
set({ connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
deleteConnector: (id: string) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { index } = getItemById(draftState.connectors, id);
|
||||
|
||||
draftState.connectors.splice(index, 1);
|
||||
});
|
||||
|
||||
set({ connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
createRectangle: (rectangle) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
draftState.rectangles.push(rectangleInputToRectangle(rectangle));
|
||||
});
|
||||
|
||||
set({ rectangles: newScene.rectangles });
|
||||
},
|
||||
|
||||
createTextBox: (textBox) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
draftState.textBoxes.push(textBoxInputToTextBox(textBox));
|
||||
});
|
||||
|
||||
set({ textBoxes: newScene.textBoxes });
|
||||
},
|
||||
|
||||
updateTextBox: (id, updates) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { item: textBox, index } = getItemById(
|
||||
draftState.textBoxes,
|
||||
id
|
||||
);
|
||||
|
||||
draftState.textBoxes[index] = {
|
||||
...textBox,
|
||||
...updates
|
||||
};
|
||||
});
|
||||
|
||||
set({ textBoxes: newScene.textBoxes });
|
||||
},
|
||||
|
||||
deleteTextBox: (id: string) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { index } = getItemById(draftState.textBoxes, id);
|
||||
|
||||
draftState.textBoxes.splice(index, 1);
|
||||
});
|
||||
|
||||
set({ textBoxes: newScene.textBoxes });
|
||||
},
|
||||
|
||||
updateRectangle: (id, updates) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { item: rectangle, index } = getItemById(
|
||||
@@ -121,16 +184,6 @@ const initialState = () => {
|
||||
set({ rectangles: newScene.rectangles });
|
||||
},
|
||||
|
||||
deleteConnector: (id: string) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { index } = getItemById(draftState.connectors, id);
|
||||
|
||||
draftState.connectors.splice(index, 1);
|
||||
});
|
||||
|
||||
set({ connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
deleteRectangle: (id: string) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
const { index } = getItemById(draftState.rectangles, id);
|
||||
@@ -138,24 +191,6 @@ const initialState = () => {
|
||||
draftState.rectangles.splice(index, 1);
|
||||
});
|
||||
|
||||
set({ rectangles: newScene.rectangles });
|
||||
},
|
||||
|
||||
createConnector: (connector) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
draftState.connectors.push(
|
||||
connectorInputToConnector(connector, draftState.nodes)
|
||||
);
|
||||
});
|
||||
|
||||
set({ connectors: newScene.connectors });
|
||||
},
|
||||
|
||||
createRectangle: (rectangle) => {
|
||||
const newScene = produce(get(), (draftState) => {
|
||||
draftState.rectangles.push(rectangleInputToRectangle(rectangle));
|
||||
});
|
||||
|
||||
set({ rectangles: newScene.rectangles });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,13 @@ export const customVars: CustomThemeVars = {
|
||||
export const themeConfig: ThemeOptions = {
|
||||
customVars,
|
||||
typography: {
|
||||
h2: {
|
||||
fontSize: '4em',
|
||||
fontStyle: 'bold',
|
||||
lineHeight: 1.2
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.3rem',
|
||||
fontSize: '1.3em',
|
||||
lineHeight: 1.2
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,3 +13,13 @@ export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
from: Coords;
|
||||
to: Coords;
|
||||
}
|
||||
|
||||
export enum ProjectionOrientationEnum {
|
||||
X = 'X',
|
||||
Y = 'Y'
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Coords, Size } from './common';
|
||||
import {
|
||||
IconInput,
|
||||
SceneInput,
|
||||
RectangleInput,
|
||||
ConnectorInput,
|
||||
TextBoxInput,
|
||||
NodeInput,
|
||||
ConnectorStyleEnum
|
||||
} from './inputs';
|
||||
} from 'src/types/inputs';
|
||||
import { ProjectionOrientationEnum } from 'src/types/common';
|
||||
import { Coords, Rect } from './common';
|
||||
|
||||
export enum TileOriginEnum {
|
||||
CENTER = 'CENTER',
|
||||
@@ -53,15 +55,17 @@ export interface Connector {
|
||||
anchors: ConnectorAnchor[];
|
||||
path: {
|
||||
tiles: Coords[];
|
||||
origin: Coords;
|
||||
areaSize: Size;
|
||||
rectangle: Rect;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TextBox {
|
||||
type: SceneItemTypeEnum.TEXTBOX;
|
||||
id: string;
|
||||
fontSize: number;
|
||||
tile: Coords;
|
||||
text: string;
|
||||
orientation: ProjectionOrientationEnum;
|
||||
}
|
||||
|
||||
export interface Rectangle {
|
||||
@@ -72,7 +76,7 @@ export interface Rectangle {
|
||||
to: Coords;
|
||||
}
|
||||
|
||||
export type SceneItem = Node | Connector | Rectangle;
|
||||
export type SceneItem = Node | Connector | TextBox | Rectangle;
|
||||
export type SceneItemReference = {
|
||||
type: SceneItemTypeEnum;
|
||||
id: string;
|
||||
@@ -84,13 +88,16 @@ export interface SceneActions {
|
||||
setScene: (scene: SceneInput) => void;
|
||||
updateScene: (scene: Scene) => void;
|
||||
createNode: (node: NodeInput) => void;
|
||||
createConnector: (connector: ConnectorInput) => void;
|
||||
createRectangle: (rectangle: RectangleInput) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>) => void;
|
||||
updateConnector: (id: string, updates: Partial<Connector>) => void;
|
||||
updateRectangle: (id: string, updates: Partial<Rectangle>) => void;
|
||||
deleteNode: (id: string) => void;
|
||||
createConnector: (connector: ConnectorInput) => void;
|
||||
updateConnector: (id: string, updates: Partial<Connector>) => void;
|
||||
deleteConnector: (id: string) => void;
|
||||
createTextBox: (textBox: TextBoxInput) => void;
|
||||
updateTextBox: (id: string, updates: Partial<TextBox>) => void;
|
||||
deleteTextBox: (id: string) => void;
|
||||
createRectangle: (rectangle: RectangleInput) => void;
|
||||
updateRectangle: (id: string, updates: Partial<Rectangle>) => void;
|
||||
deleteRectangle: (id: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Coords, Size } from './common';
|
||||
import { SceneItem, Connector, SceneItemReference } from './scene';
|
||||
import { SceneItem, Connector, SceneItemReference, TextBox } from './scene';
|
||||
import { IconInput } from './inputs';
|
||||
|
||||
interface NodeControls {
|
||||
@@ -12,6 +12,11 @@ interface ConnectorControls {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface TextBoxControls {
|
||||
type: 'TEXTBOX';
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RectangleControls {
|
||||
type: 'RECTANGLE';
|
||||
id: string;
|
||||
@@ -26,6 +31,7 @@ export type ItemControls =
|
||||
| ConnectorControls
|
||||
| RectangleControls
|
||||
| AddItemControls
|
||||
| TextBoxControls
|
||||
| null;
|
||||
|
||||
export interface Mouse {
|
||||
|
||||
@@ -13,8 +13,8 @@ export class CoordsUtils {
|
||||
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 multiply(base: Coords, operand: number): Coords {
|
||||
return { x: base.x * operand, y: base.y * operand };
|
||||
}
|
||||
|
||||
static toString(coords: Coords) {
|
||||
|
||||
@@ -40,3 +40,7 @@ export const getColorVariant = (
|
||||
export const setWindowCursor = (cursor: string) => {
|
||||
window.document.body.style.cursor = cursor;
|
||||
};
|
||||
|
||||
export const toPx = (value: number | string) => {
|
||||
return `${value}px`;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
NodeInput,
|
||||
ConnectorInput,
|
||||
TextBoxInput,
|
||||
ProjectionOrientationEnum,
|
||||
RectangleInput,
|
||||
SceneItemTypeEnum,
|
||||
Scene,
|
||||
@@ -14,7 +15,12 @@ import {
|
||||
ConnectorAnchor,
|
||||
Coords
|
||||
} from 'src/types';
|
||||
import { NODE_DEFAULTS, DEFAULT_COLOR, CONNECTOR_DEFAULTS } from 'src/config';
|
||||
import {
|
||||
NODE_DEFAULTS,
|
||||
DEFAULT_COLOR,
|
||||
CONNECTOR_DEFAULTS,
|
||||
TEXTBOX_DEFAULTS
|
||||
} from 'src/config';
|
||||
import { getConnectorPath } from './renderer';
|
||||
|
||||
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
|
||||
@@ -85,6 +91,9 @@ export const textBoxInputToTextBox = (textBoxInput: TextBoxInput): TextBox => {
|
||||
return {
|
||||
type: SceneItemTypeEnum.TEXTBOX,
|
||||
id: textBoxInput.id,
|
||||
orientation: textBoxInput.orientation ?? ProjectionOrientationEnum.X,
|
||||
fontSize: textBoxInput.fontSize ?? TEXTBOX_DEFAULTS.fontSize,
|
||||
tile: textBoxInput.tile,
|
||||
text: textBoxInput.text
|
||||
};
|
||||
};
|
||||
@@ -174,6 +183,7 @@ export const sceneToSceneInput = (scene: Scene): SceneInput => {
|
||||
connectorToConnectorInput,
|
||||
nodes
|
||||
);
|
||||
const textBoxes: TextBoxInput[] = scene.textBoxes.map(textBoxInputToTextBox);
|
||||
const rectangles: RectangleInput[] = scene.rectangles.map(
|
||||
rectangleToRectangleInput
|
||||
);
|
||||
@@ -181,6 +191,7 @@ export const sceneToSceneInput = (scene: Scene): SceneInput => {
|
||||
return {
|
||||
nodes,
|
||||
connectors,
|
||||
textBoxes,
|
||||
rectangles,
|
||||
icons: scene.icons
|
||||
} as SceneInput;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { produce } from 'immer';
|
||||
import {
|
||||
TILE_PROJECTION_MULTIPLIERS,
|
||||
UNPROJECTED_TILE_SIZE,
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
Mouse,
|
||||
ConnectorAnchor,
|
||||
SceneItem,
|
||||
Scene
|
||||
Scene,
|
||||
Rect,
|
||||
ProjectionOrientationEnum
|
||||
} from 'src/types';
|
||||
import {
|
||||
CoordsUtils,
|
||||
@@ -197,8 +200,25 @@ export const getBoundingBoxSize = (boundingBox: Coords[]): Size => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getIsoMatrixCSS = () => {
|
||||
return `matrix(-0.707, 0.409, 0.707, 0.409, 0, -0.816)`;
|
||||
const isoProjectionBaseValues = [0.707, -0.409, 0.707, 0.409, 0, -0.816];
|
||||
|
||||
export const getIsoMatrix = (orientation?: ProjectionOrientationEnum) => {
|
||||
switch (orientation) {
|
||||
case ProjectionOrientationEnum.Y:
|
||||
return produce(isoProjectionBaseValues, (draftState) => {
|
||||
draftState[1] = -draftState[1];
|
||||
draftState[2] = -draftState[2];
|
||||
});
|
||||
case ProjectionOrientationEnum.X:
|
||||
default:
|
||||
return isoProjectionBaseValues;
|
||||
}
|
||||
};
|
||||
|
||||
export const getIsoMatrixCSS = (orientation?: ProjectionOrientationEnum) => {
|
||||
const matrixTransformValues = getIsoMatrix(orientation);
|
||||
|
||||
return `matrix(${matrixTransformValues.join(', ')})`;
|
||||
};
|
||||
|
||||
export const getTranslateCSS = (translate: Coords = { x: 0, y: 0 }) => {
|
||||
@@ -337,7 +357,10 @@ interface GetConnectorPath {
|
||||
export const getConnectorPath = ({
|
||||
anchors,
|
||||
nodes
|
||||
}: GetConnectorPath): { tiles: Coords[]; origin: Coords; areaSize: Size } => {
|
||||
}: GetConnectorPath): {
|
||||
tiles: Coords[];
|
||||
rectangle: Rect;
|
||||
} => {
|
||||
if (anchors.length < 2)
|
||||
throw new Error(
|
||||
`Connector needs at least two anchors (receieved: ${anchors.length})`
|
||||
@@ -346,15 +369,21 @@ export const getConnectorPath = ({
|
||||
const anchorPositions = anchors.map((anchor) => {
|
||||
return getAnchorPosition({ anchor, nodes });
|
||||
});
|
||||
|
||||
const searchArea = getBoundingBox(
|
||||
anchorPositions,
|
||||
CONNECTOR_DEFAULTS.searchOffset
|
||||
);
|
||||
const searchAreaSize = getBoundingBoxSize(searchArea);
|
||||
|
||||
const sorted = sortByPosition(searchArea);
|
||||
const origin = { x: sorted.highX, y: sorted.highY };
|
||||
const searchAreaSize = getBoundingBoxSize(searchArea);
|
||||
const rectangle = {
|
||||
from: { x: sorted.highX, y: sorted.highY },
|
||||
to: { x: sorted.lowX, y: sorted.lowY }
|
||||
};
|
||||
|
||||
const positionsNormalisedFromSearchArea = anchorPositions.map((position) => {
|
||||
return normalisePositionFromOrigin({ position, origin });
|
||||
return normalisePositionFromOrigin({ position, origin: rectangle.from });
|
||||
});
|
||||
|
||||
const tiles = positionsNormalisedFromSearchArea.reduce<Coords[]>(
|
||||
@@ -373,7 +402,7 @@ export const getConnectorPath = ({
|
||||
[]
|
||||
);
|
||||
|
||||
return { tiles, origin, areaSize: searchAreaSize };
|
||||
return { tiles, rectangle };
|
||||
};
|
||||
|
||||
type GetRectangleFromSize = (
|
||||
@@ -416,11 +445,17 @@ export const getItemAtTile = ({
|
||||
|
||||
if (node) return node;
|
||||
|
||||
const textBox = scene.textBoxes.find((tb) => {
|
||||
return CoordsUtils.isEqual(tb.tile, tile);
|
||||
});
|
||||
|
||||
if (textBox) return textBox;
|
||||
|
||||
const connector = scene.connectors.find((con) => {
|
||||
return con.path.tiles.find((pathTile) => {
|
||||
const globalPathTile = connectorPathTileToGlobal(
|
||||
pathTile,
|
||||
con.path.origin
|
||||
con.path.rectangle.from
|
||||
);
|
||||
|
||||
return CoordsUtils.isEqual(globalPathTile, tile);
|
||||
@@ -437,3 +472,23 @@ export const getItemAtTile = ({
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface FontProps {
|
||||
fontWeight: number | string;
|
||||
fontSize: string;
|
||||
fontFamily: string;
|
||||
}
|
||||
|
||||
export const getTextWidth = (text: string, fontProps: FontProps) => {
|
||||
const canvas: HTMLCanvasElement = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
context.font = `${fontProps.fontWeight} ${fontProps.fontSize} ${fontProps.fontFamily}`;
|
||||
const metrics = context.measureText(text);
|
||||
|
||||
return metrics.width;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from 'zod';
|
||||
import { ProjectionOrientationEnum } from 'src/types/common';
|
||||
|
||||
const coords = z.object({
|
||||
x: z.number(),
|
||||
@@ -60,7 +61,15 @@ export const connectorInput = z.object({
|
||||
|
||||
export const textBoxInput = z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
tile: coords,
|
||||
text: z.string(),
|
||||
fontSize: z.number().optional(),
|
||||
orientation: z
|
||||
.union([
|
||||
z.literal(ProjectionOrientationEnum.X),
|
||||
z.literal(ProjectionOrientationEnum.Y)
|
||||
])
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const rectangleInput = z.object({
|
||||
|
||||
Reference in New Issue
Block a user