mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-23 22:48:57 -05:00
FreeForm lasso mode
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { createSmoothPath } from 'src/utils';
|
||||
|
||||
export const FreehandLasso = () => {
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
|
||||
const rendererSize = useUiStateStore((state) => {
|
||||
return state.rendererEl?.getBoundingClientRect();
|
||||
});
|
||||
|
||||
const smoothPath = useMemo(() => {
|
||||
if (mode.type !== 'FREEHAND_LASSO' || mode.path.length < 2) {
|
||||
return '';
|
||||
}
|
||||
return createSmoothPath(mode.path);
|
||||
}, [mode]);
|
||||
|
||||
if (mode.type !== 'FREEHAND_LASSO' || mode.path.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = rendererSize?.width || 0;
|
||||
const height = rendererSize?.height || 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
<path
|
||||
d={smoothPath}
|
||||
fill="rgba(33, 150, 243, 0.15)"
|
||||
stroke="#2196f3"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="8 4"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { SizeIndicator } from 'src/components/DebugUtils/SizeIndicator';
|
||||
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
|
||||
import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager';
|
||||
import { Lasso } from 'src/components/Lasso/Lasso';
|
||||
import { FreehandLasso } from 'src/components/FreehandLasso/FreehandLasso';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { RendererProps } from 'src/types/rendererProps';
|
||||
|
||||
@@ -63,6 +64,7 @@ export const Renderer = ({ showGrid, backgroundColor }: RendererProps) => {
|
||||
<SceneLayer>
|
||||
<Lasso />
|
||||
</SceneLayer>
|
||||
<FreehandLasso />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Undo as UndoIcon,
|
||||
Redo as RedoIcon,
|
||||
Help as HelpIcon,
|
||||
HighlightAltOutlined as LassoIcon
|
||||
HighlightAltOutlined as LassoIcon,
|
||||
GestureOutlined as FreehandLassoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { IconButton } from 'src/components/IconButton/IconButton';
|
||||
@@ -106,6 +107,20 @@ export const ToolMenu = () => {
|
||||
}}
|
||||
isActive={mode.type === 'LASSO'}
|
||||
/>
|
||||
<IconButton
|
||||
name={`Freehand lasso${hotkeys.freehandLasso ? ` (${hotkeys.freehandLasso.toUpperCase()})` : ''}`}
|
||||
Icon={<FreehandLassoIcon />}
|
||||
onClick={() => {
|
||||
uiStateStoreActions.setMode({
|
||||
type: 'FREEHAND_LASSO',
|
||||
showCursor: true,
|
||||
path: [],
|
||||
selection: null,
|
||||
isDragging: false
|
||||
});
|
||||
}}
|
||||
isActive={mode.type === 'FREEHAND_LASSO'}
|
||||
/>
|
||||
<IconButton
|
||||
name={`Pan${hotkeys.pan ? ` (${hotkeys.pan.toUpperCase()})` : ''}`}
|
||||
Icon={<PanToolIcon />}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface HotkeyMapping {
|
||||
connector: string | null;
|
||||
text: string | null;
|
||||
lasso: string | null;
|
||||
freehandLasso: string | null;
|
||||
}
|
||||
|
||||
export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
|
||||
@@ -18,7 +19,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
|
||||
rectangle: 'r',
|
||||
connector: 't',
|
||||
text: 'y',
|
||||
lasso: 'l'
|
||||
lasso: 'l',
|
||||
freehandLasso: 'f'
|
||||
},
|
||||
smnrct: {
|
||||
select: 's',
|
||||
@@ -27,7 +29,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
|
||||
rectangle: 'r',
|
||||
connector: 'c',
|
||||
text: 't',
|
||||
lasso: 'l'
|
||||
lasso: 'l',
|
||||
freehandLasso: 'f'
|
||||
},
|
||||
none: {
|
||||
select: null,
|
||||
@@ -36,7 +39,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
|
||||
rectangle: null,
|
||||
connector: null,
|
||||
text: null,
|
||||
lasso: null
|
||||
lasso: null,
|
||||
freehandLasso: null
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
170
packages/fossflow-lib/src/interaction/modes/FreehandLasso.ts
Normal file
170
packages/fossflow-lib/src/interaction/modes/FreehandLasso.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { produce } from 'immer';
|
||||
import { ModeActions, ItemReference, Coords } from 'src/types';
|
||||
import { screenToIso, isPointInPolygon } from 'src/utils';
|
||||
|
||||
// Helper to find all items whose centers are within the freehand polygon
|
||||
const getItemsInFreehandBounds = (
|
||||
pathTiles: Coords[],
|
||||
scene: any
|
||||
): ItemReference[] => {
|
||||
const items: ItemReference[] = [];
|
||||
|
||||
if (pathTiles.length < 3) return items;
|
||||
|
||||
// Check all nodes/items
|
||||
scene.items.forEach((item: any) => {
|
||||
if (isPointInPolygon(item.tile, pathTiles)) {
|
||||
items.push({ type: 'ITEM', id: item.id });
|
||||
}
|
||||
});
|
||||
|
||||
// Check all rectangles (check center point)
|
||||
scene.rectangles.forEach((rectangle: any) => {
|
||||
const centerX = (rectangle.from.x + rectangle.to.x) / 2;
|
||||
const centerY = (rectangle.from.y + rectangle.to.y) / 2;
|
||||
const center = { x: centerX, y: centerY };
|
||||
|
||||
if (isPointInPolygon(center, pathTiles)) {
|
||||
items.push({ type: 'RECTANGLE', id: rectangle.id });
|
||||
}
|
||||
});
|
||||
|
||||
// Check all text boxes
|
||||
scene.textBoxes.forEach((textBox: any) => {
|
||||
if (isPointInPolygon(textBox.tile, pathTiles)) {
|
||||
items.push({ type: 'TEXTBOX', id: textBox.id });
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const FreehandLasso: ModeActions = {
|
||||
mousemove: ({ uiState, scene }) => {
|
||||
if (uiState.mode.type !== 'FREEHAND_LASSO' || !uiState.mouse.mousedown) return;
|
||||
|
||||
// If user is dragging an existing selection, switch to DRAG_ITEMS mode
|
||||
if (uiState.mode.isDragging && uiState.mode.selection) {
|
||||
uiState.actions.setMode({
|
||||
type: 'DRAG_ITEMS',
|
||||
showCursor: true,
|
||||
items: uiState.mode.selection.items,
|
||||
isInitialMovement: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User is drawing the freehand path - collect screen coordinates
|
||||
const newScreenPoint = uiState.mouse.position.screen;
|
||||
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
// Add point to path if it's far enough from the last point (throttle)
|
||||
const lastPoint = draft.path[draft.path.length - 1];
|
||||
if (!lastPoint ||
|
||||
Math.abs(newScreenPoint.x - lastPoint.x) > 5 ||
|
||||
Math.abs(newScreenPoint.y - lastPoint.y) > 5) {
|
||||
draft.path.push(newScreenPoint);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
mousedown: ({ uiState }) => {
|
||||
if (uiState.mode.type !== 'FREEHAND_LASSO') return;
|
||||
|
||||
// If there's an existing selection, check if click is within it
|
||||
if (uiState.mode.selection) {
|
||||
// Convert click position to tile
|
||||
const clickTile = uiState.mouse.position.tile;
|
||||
const isWithinSelection = isPointInPolygon(
|
||||
clickTile,
|
||||
uiState.mode.selection.pathTiles
|
||||
);
|
||||
|
||||
if (isWithinSelection) {
|
||||
// Clicked within selection - prepare to drag
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
draft.isDragging = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked outside selection - clear it and start new path
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
draft.path = [uiState.mouse.position.screen];
|
||||
draft.selection = null;
|
||||
draft.isDragging = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a new path
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
draft.path = [uiState.mouse.position.screen];
|
||||
draft.selection = null;
|
||||
draft.isDragging = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
mouseup: ({ uiState, scene }) => {
|
||||
if (uiState.mode.type !== 'FREEHAND_LASSO') return;
|
||||
|
||||
// If we've drawn a path, convert to tiles and find items
|
||||
if (uiState.mode.path.length >= 3 && !uiState.mode.selection) {
|
||||
const rendererSize = uiState.rendererEl?.getBoundingClientRect();
|
||||
if (!rendererSize) return;
|
||||
|
||||
// Convert screen path to tile coordinates
|
||||
const pathTiles = uiState.mode.path.map((screenPoint) => {
|
||||
return screenToIso({
|
||||
mouse: screenPoint,
|
||||
zoom: uiState.zoom,
|
||||
scroll: uiState.scroll,
|
||||
rendererSize: {
|
||||
width: rendererSize.width,
|
||||
height: rendererSize.height
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Find all items within the freehand polygon
|
||||
const items = getItemsInFreehandBounds(pathTiles, scene);
|
||||
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
draft.selection = {
|
||||
pathTiles,
|
||||
items
|
||||
};
|
||||
draft.isDragging = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Reset dragging state but keep selection if it exists
|
||||
uiState.actions.setMode(
|
||||
produce(uiState.mode, (draft) => {
|
||||
if (draft.type === 'FREEHAND_LASSO') {
|
||||
draft.isDragging = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { Pan } from './modes/Pan';
|
||||
import { PlaceIcon } from './modes/PlaceIcon';
|
||||
import { TextBox } from './modes/TextBox';
|
||||
import { Lasso } from './modes/Lasso';
|
||||
import { FreehandLasso } from './modes/FreehandLasso';
|
||||
import { usePanHandlers } from './usePanHandlers';
|
||||
|
||||
const modes: { [k in string]: ModeActions } = {
|
||||
@@ -29,7 +30,8 @@ const modes: { [k in string]: ModeActions } = {
|
||||
PAN: Pan,
|
||||
PLACE_ICON: PlaceIcon,
|
||||
TEXTBOX: TextBox,
|
||||
LASSO: Lasso
|
||||
LASSO: Lasso,
|
||||
FREEHAND_LASSO: FreehandLasso
|
||||
};
|
||||
|
||||
const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => {
|
||||
@@ -172,6 +174,15 @@ export const useInteractionManager = () => {
|
||||
selection: null,
|
||||
isDragging: false
|
||||
});
|
||||
} else if (hotkeyMapping.freehandLasso && key === hotkeyMapping.freehandLasso) {
|
||||
e.preventDefault();
|
||||
uiState.actions.setMode({
|
||||
type: 'FREEHAND_LASSO',
|
||||
showCursor: true,
|
||||
path: [],
|
||||
selection: null,
|
||||
isDragging: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ export interface LassoMode {
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export interface FreehandLassoMode {
|
||||
type: 'FREEHAND_LASSO';
|
||||
showCursor: boolean;
|
||||
path: Coords[]; // Screen coordinates of the drawn path
|
||||
selection: {
|
||||
pathTiles: Coords[]; // Tile coordinates of the path points
|
||||
items: ItemReference[];
|
||||
} | null;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export type Mode =
|
||||
| InteractionsDisabled
|
||||
| CursorMode
|
||||
@@ -116,7 +127,8 @@ export type Mode =
|
||||
| TransformRectangleMode
|
||||
| DragItemsMode
|
||||
| TextBoxMode
|
||||
| LassoMode;
|
||||
| LassoMode
|
||||
| FreehandLassoMode;
|
||||
// End mode types
|
||||
|
||||
export interface Scroll {
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './renderer';
|
||||
export * from './exportOptions';
|
||||
export * from './model';
|
||||
export * from './findNearestUnoccupiedTile';
|
||||
export * from './pointInPolygon';
|
||||
|
||||
78
packages/fossflow-lib/src/utils/pointInPolygon.ts
Normal file
78
packages/fossflow-lib/src/utils/pointInPolygon.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Coords } from 'src/types';
|
||||
|
||||
/**
|
||||
* Ray casting algorithm to determine if a point is inside a polygon
|
||||
* @param point - The point to check (tile coordinates)
|
||||
* @param polygon - Array of vertices defining the polygon (tile coordinates)
|
||||
* @returns true if the point is inside the polygon
|
||||
*/
|
||||
export const isPointInPolygon = (point: Coords, polygon: Coords[]): boolean => {
|
||||
if (polygon.length < 3) return false;
|
||||
|
||||
let inside = false;
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
|
||||
const intersect =
|
||||
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an array of screen coordinates to tile coordinates using the screenToIso function
|
||||
*/
|
||||
export const screenPathToTilePath = (
|
||||
screenPath: Coords[],
|
||||
screenToIsoFn: (coords: Coords) => Coords
|
||||
): Coords[] => {
|
||||
return screenPath.map((point) => screenToIsoFn(point));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a smooth SVG path from a series of points using quadratic curves
|
||||
* @param points - Array of screen coordinates
|
||||
* @returns SVG path string
|
||||
*/
|
||||
export const createSmoothPath = (points: Coords[]): string => {
|
||||
if (points.length < 2) return '';
|
||||
|
||||
let path = `M ${points[0].x},${points[0].y}`;
|
||||
|
||||
// Use quadratic bezier curves for smooth lines
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const current = points[i];
|
||||
const previous = points[i - 1];
|
||||
|
||||
// Calculate control point as midpoint
|
||||
const cpX = (previous.x + current.x) / 2;
|
||||
const cpY = (previous.y + current.y) / 2;
|
||||
|
||||
if (i === 1) {
|
||||
// First segment - line to control point, then curve
|
||||
path += ` L ${cpX},${cpY}`;
|
||||
} else {
|
||||
// Subsequent segments - quadratic curve
|
||||
path += ` Q ${previous.x},${previous.y} ${cpX},${cpY}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the curve to the last point
|
||||
const lastPoint = points[points.length - 1];
|
||||
const secondLastPoint = points[points.length - 2];
|
||||
path += ` Q ${secondLastPoint.x},${secondLastPoint.y} ${lastPoint.x},${lastPoint.y}`;
|
||||
|
||||
// Close the path
|
||||
path += ' Z';
|
||||
|
||||
return path;
|
||||
};
|
||||
Reference in New Issue
Block a user