feat: implements transform controls for rectangles

This commit is contained in:
Mark Mankarious
2023-09-01 14:06:46 +01:00
parent 78b243ca22
commit cb36408173
15 changed files with 361 additions and 12 deletions

View File

@@ -11,7 +11,7 @@ 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 { getIsoProjectionCss } from 'src/utils';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { Header } from '../components/Header';
@@ -76,12 +76,12 @@ export const TextBoxControls = ({ id }: Props) => {
}}
>
<ToggleButton value={ProjectionOrientationEnum.X}>
<TextRotationNoneIcon sx={{ transform: getIsoMatrixCSS() }} />
<TextRotationNoneIcon sx={{ transform: getIsoProjectionCss() }} />
</ToggleButton>
<ToggleButton value={ProjectionOrientationEnum.Y}>
<TextRotationNoneIcon
sx={{
transform: `scale(-1, 1) ${getIsoMatrixCSS()} scale(-1, 1)`
transform: `scale(-1, 1) ${getIsoProjectionCss()} scale(-1, 1)`
}}
/>
</ToggleButton>

View File

@@ -11,6 +11,7 @@ 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';
import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager';
export const Renderer = () => {
const containerRef = useRef<HTMLDivElement>();
@@ -20,6 +21,7 @@ export const Renderer = () => {
const mode = useUiStateStore((state) => {
return state.mode;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
@@ -76,6 +78,9 @@ export const Renderer = () => {
<DebugUtils />
</SceneLayer>
)}
<SceneLayer>
<TransformControlsManager />
</SceneLayer>
{/* Interaction layer: this is where events are detected */}
<SceneLayer ref={containerRef} />
</Box>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useRectangle } from 'src/hooks/useRectangle';
import { TransformControls } from './TransformControls';
interface Props {
id: string;
}
export const RectangleTransformControls = ({ id }: Props) => {
const rectangle = useRectangle(id);
return (
<TransformControls
showCornerAnchors
from={rectangle.from}
to={rectangle.to}
/>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Coords } from 'src/types';
import { useTheme } from '@mui/material';
import { getIsoProjectionCss } from 'src/utils';
import { Svg } from 'src/components/Svg/Svg';
import { TRANSFORM_ANCHOR_SIZE, TRANSFORM_CONTROLS_COLOR } from 'src/config';
interface Props {
position: Coords;
}
const strokeWidth = 2;
export const TransformAnchor = ({ position }: Props) => {
const theme = useTheme();
return (
<Svg
style={{
position: 'absolute',
left: position.x - TRANSFORM_ANCHOR_SIZE / 2,
top: position.y - TRANSFORM_ANCHOR_SIZE / 2,
transform: getIsoProjectionCss(),
width: TRANSFORM_ANCHOR_SIZE,
height: TRANSFORM_ANCHOR_SIZE
}}
>
<g transform={`translate(${strokeWidth}, ${strokeWidth})`}>
<rect
fill={theme.palette.common.white}
width={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
height={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
stroke={TRANSFORM_CONTROLS_COLOR}
strokeWidth={strokeWidth}
rx={3}
/>
</g>
</Svg>
);
};

View File

@@ -0,0 +1,72 @@
import React, { useMemo } from 'react';
import { useTheme } from '@mui/material';
import { Coords } from 'src/types';
import { Svg } from 'src/components/Svg/Svg';
import { TRANSFORM_CONTROLS_COLOR } from 'src/config';
import { useIsoProjection } from 'src/hooks/useIsoProjection';
import { getBoundingBox, outermostCornerPositions } from 'src/utils';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { TransformAnchor } from './TransformAnchor';
interface Props {
from: Coords;
to: Coords;
onMouseOver?: () => void;
showCornerAnchors?: boolean;
}
const strokeWidth = 2;
export const TransformControls = ({
from,
to,
onMouseOver,
showCornerAnchors
}: Props) => {
const theme = useTheme();
const { css, pxSize } = useIsoProjection({
from,
to
});
const { getTilePosition } = useGetTilePosition();
const anchorPositions = useMemo<Coords[]>(() => {
if (!showCornerAnchors) return [];
const corners = getBoundingBox([from, to]);
const cornerPositions = corners.map((corner, i) => {
return getTilePosition({
tile: corner,
origin: outermostCornerPositions[i]
});
});
return cornerPositions;
}, [showCornerAnchors, from, to, getTilePosition]);
return (
<>
<Svg
style={css}
onMouseOver={() => {
onMouseOver?.();
}}
>
<g transform={`translate(${strokeWidth}, ${strokeWidth})`}>
<rect
width={pxSize.width - strokeWidth * 2}
height={pxSize.height - strokeWidth * 2}
fill="none"
stroke={TRANSFORM_CONTROLS_COLOR}
strokeDasharray={`${strokeWidth * 2} ${strokeWidth * 2}`}
strokeWidth={strokeWidth}
/>
</g>
</Svg>
{anchorPositions.map((position) => {
return <TransformAnchor position={position} />;
})}
</>
);
};

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { RectangleTransformControls } from './RectangleTransformControls';
export const TransformControlsManager = () => {
const itemControls = useUiStateStore((state) => {
return state.itemControls;
});
switch (itemControls?.type) {
case 'RECTANGLE':
return <RectangleTransformControls id={itemControls.id} />;
default:
return null;
}
};

View File

@@ -7,6 +7,7 @@ import {
} from 'src/types';
import { customVars } from './styles/theme';
// TODO: This file could do with better organisation and convention for easier reading.
export const UNPROJECTED_TILE_SIZE = 100;
export const TILE_PROJECTION_MULTIPLIERS: Size = {
width: 1.415,
@@ -49,3 +50,5 @@ export const EMPTY_SCENE: SceneInput = {
textBoxes: [],
rectangles: []
};
export const TRANSFORM_ANCHOR_SIZE = 30;
export const TRANSFORM_CONTROLS_COLOR = '#0392ff';

View File

@@ -5,7 +5,7 @@ import {
Size,
ProjectionOrientationEnum
} from 'src/types';
import { getBoundingBox, getIsoMatrixCSS } from 'src/utils';
import { getBoundingBox, getIsoProjectionCss } from 'src/utils';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from './useGetTilePosition';
@@ -71,7 +71,7 @@ export const useIsoProjection = ({
top: position.y,
width: `${pxSize.width}px`,
height: `${pxSize.height}px`,
transform: getIsoMatrixCSS(orientation),
transform: getIsoProjectionCss(orientation),
transformOrigin: 'top left'
},
position,

View File

@@ -54,6 +54,15 @@ export const Cursor: ModeActions = {
type: 'RECTANGLE',
id: uiState.mode.mousedownItem.id
});
uiState.actions.setMode({
type: 'RECTANGLE.TRANSFORM',
id: uiState.mode.mousedownItem.id,
showCursor: true,
selectedAnchor: null
});
return;
} else if (uiState.mode.mousedownItem.type === 'CONNECTOR') {
uiState.actions.setItemControls({
type: 'CONNECTOR',

View File

@@ -0,0 +1,148 @@
import { produce } from 'immer';
import {
isWithinBounds,
getItemById,
getItemAtTile,
getBoundingBox,
outermostCornerPositions,
getTilePosition,
convertBoundsToNamedAnchors,
hasMovedTile
} from 'src/utils';
import { TRANSFORM_ANCHOR_SIZE } from 'src/config';
import { ModeActions, AnchorPositionsEnum } from 'src/types';
export const TransformRectangle: ModeActions = {
entry: () => {},
exit: () => {},
mousemove: ({ uiState, scene }) => {
if (
uiState.mode.type !== 'RECTANGLE.TRANSFORM' ||
!hasMovedTile(uiState.mouse)
)
return;
if (uiState.mode.selectedAnchor) {
// User is dragging an anchor
const { item: rectangle } = getItemById(
scene.rectangles,
uiState.mode.id
);
const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]);
const namedBounds = convertBoundsToNamedAnchors(rectangleBounds);
if (
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_LEFT ||
uiState.mode.selectedAnchor === AnchorPositionsEnum.TOP_RIGHT
) {
const nextBounds = getBoundingBox([
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_LEFT
? namedBounds.TOP_RIGHT
: namedBounds.BOTTOM_LEFT,
uiState.mouse.position.tile
]);
const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds);
scene.actions.updateRectangle(uiState.mode.id, {
from: nextNamedBounds.TOP_RIGHT,
to: nextNamedBounds.BOTTOM_LEFT
});
} else if (
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_RIGHT ||
uiState.mode.selectedAnchor === AnchorPositionsEnum.TOP_LEFT
) {
const nextBounds = getBoundingBox([
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_RIGHT
? namedBounds.TOP_LEFT
: namedBounds.BOTTOM_RIGHT,
uiState.mouse.position.tile
]);
const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds);
scene.actions.updateRectangle(uiState.mode.id, {
from: nextNamedBounds.TOP_LEFT,
to: nextNamedBounds.BOTTOM_RIGHT
});
}
}
},
mousedown: ({ uiState, scene }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
const { item: rectangle } = getItemById(scene.rectangles, uiState.mode.id);
// Check if the user has mousedown'd on an anchor
const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]);
const anchorPositions = rectangleBounds.map((corner, i) => {
return getTilePosition({
tile: corner,
scroll: uiState.scroll,
zoom: uiState.zoom,
rendererSize: uiState.rendererSize,
origin: outermostCornerPositions[i]
});
});
const activeAnchorIndex = anchorPositions.findIndex((anchorPosition) => {
return isWithinBounds(uiState.mouse.position.screen, [
{
x: anchorPosition.x - TRANSFORM_ANCHOR_SIZE / 2,
y: anchorPosition.y - TRANSFORM_ANCHOR_SIZE / 2
},
{
x: anchorPosition.x + TRANSFORM_ANCHOR_SIZE / 2,
y: anchorPosition.y + TRANSFORM_ANCHOR_SIZE / 2
}
]);
});
if (activeAnchorIndex !== -1) {
const activeAnchor =
Object.values(AnchorPositionsEnum)[activeAnchorIndex];
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.selectedAnchor = activeAnchor;
})
);
return;
}
// Check if the userhas mousedown'd on the rectangle itself
const isMouseWithinRectangle = isWithinBounds(uiState.mouse.position.tile, [
rectangle.from,
rectangle.to
]);
if (isMouseWithinRectangle) {
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: rectangle,
showCursor: true
});
return;
}
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: itemAtTile,
showCursor: true
});
},
mouseup: ({ uiState }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
if (uiState.mode.selectedAnchor) {
uiState.actions.setMode(
produce(uiState.mode, (draftState) => {
draftState.selectedAnchor = null;
})
);
}
}
};

View File

@@ -6,6 +6,7 @@ import { getMouse } from 'src/utils';
import { Cursor } from './modes/Cursor';
import { DragItems } from './modes/DragItems';
import { DrawRectangle } from './modes/Rectangle/DrawRectangle';
import { TransformRectangle } from './modes/Rectangle/TransformRectangle';
import { Connector } from './modes/Connector';
import { Pan } from './modes/Pan';
import { PlaceElement } from './modes/PlaceElement';
@@ -14,7 +15,9 @@ import { TextBox } from './modes/TextBox';
const modes: { [k in string]: ModeActions } = {
CURSOR: Cursor,
DRAG_ITEMS: DragItems,
// TODO: Adopt this notation for all modes (i.e. {node.type}.{action})
'RECTANGLE.DRAW': DrawRectangle,
'RECTANGLE.TRANSFORM': TransformRectangle,
CONNECTOR: Connector,
PAN: Pan,
PLACE_ELEMENT: PlaceElement,

View File

@@ -23,3 +23,5 @@ export enum ProjectionOrientationEnum {
X = 'X',
Y = 'Y'
}
export type BoundingBox = [Coords, Coords, Coords, Coords];

View File

@@ -1,5 +1,5 @@
import { Coords, Size } from './common';
import { SceneItem, Connector, SceneItemReference, TextBox } from './scene';
import { SceneItem, Connector, SceneItemReference } from './scene';
import { IconInput } from './inputs';
interface NodeControls {
@@ -94,10 +94,18 @@ export interface DrawRectangleMode {
} | null;
}
export interface ResizeRectangleMode {
type: 'RECTANGLE.RESIZE';
export enum AnchorPositionsEnum {
BOTTOM_LEFT = 'BOTTOM_LEFT',
BOTTOM_RIGHT = 'BOTTOM_RIGHT',
TOP_RIGHT = 'TOP_RIGHT',
TOP_LEFT = 'TOP_LEFT'
}
export interface TransformRectangleMode {
type: 'RECTANGLE.TRANSFORM';
showCursor: boolean;
id: string;
selectedAnchor: AnchorPositionsEnum | null;
}
export interface TextBoxMode {
@@ -112,7 +120,7 @@ export type Mode =
| PlaceElementMode
| ConnectorMode
| DrawRectangleMode
| ResizeRectangleMode
| TransformRectangleMode
| DragItemsMode
| TextBoxMode;
// End mode types

View File

@@ -21,6 +21,10 @@ export class CoordsUtils {
return `x: ${coords.x}, y: ${coords.y}`;
}
static sum(coords: Coords) {
return coords.x + coords.y;
}
static zero() {
return { x: 0, y: 0 };
}

View File

@@ -18,7 +18,9 @@ import {
SceneItem,
Scene,
Rect,
ProjectionOrientationEnum
ProjectionOrientationEnum,
AnchorPositionsEnum,
BoundingBox
} from 'src/types';
import {
CoordsUtils,
@@ -180,7 +182,7 @@ export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
export const getBoundingBox = (
tiles: Coords[],
offset: Coords = CoordsUtils.zero()
): Coords[] => {
): BoundingBox => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
return [
@@ -215,7 +217,9 @@ export const getIsoMatrix = (orientation?: ProjectionOrientationEnum) => {
}
};
export const getIsoMatrixCSS = (orientation?: ProjectionOrientationEnum) => {
export const getIsoProjectionCss = (
orientation?: ProjectionOrientationEnum
) => {
const matrixTransformValues = getIsoMatrix(orientation);
return `matrix(${matrixTransformValues.join(', ')})`;
@@ -492,3 +496,19 @@ export const getTextWidth = (text: string, fontProps: FontProps) => {
return metrics.width;
};
export const outermostCornerPositions = [
TileOriginEnum.BOTTOM,
TileOriginEnum.RIGHT,
TileOriginEnum.TOP,
TileOriginEnum.LEFT
];
export const convertBoundsToNamedAnchors = (boundingBox: BoundingBox) => {
return {
[AnchorPositionsEnum.BOTTOM_LEFT]: boundingBox[0],
[AnchorPositionsEnum.BOTTOM_RIGHT]: boundingBox[1],
[AnchorPositionsEnum.TOP_RIGHT]: boundingBox[2],
[AnchorPositionsEnum.TOP_LEFT]: boundingBox[3]
};
};