feat: implements text tool

This commit is contained in:
Mark Mankarious
2023-08-31 12:02:17 +01:00
parent e72dd842c9
commit c240def317
42 changed files with 773 additions and 302 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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