mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
feat: implements transform controls for rectangles
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
40
src/components/TransformControlsManager/TransformAnchor.tsx
Normal file
40
src/components/TransformControlsManager/TransformAnchor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
148
src/interaction/modes/Rectangle/TransformRectangle.ts
Normal file
148
src/interaction/modes/Rectangle/TransformRectangle.ts
Normal 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -23,3 +23,5 @@ export enum ProjectionOrientationEnum {
|
||||
X = 'X',
|
||||
Y = 'Y'
|
||||
}
|
||||
|
||||
export type BoundingBox = [Coords, Coords, Coords, Coords];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user