working lasso rectangle mode

This commit is contained in:
stan
2025-10-01 19:56:11 +01:00
parent 4d12c01393
commit fec88785d7
10 changed files with 214 additions and 163 deletions

View File

@@ -12,6 +12,7 @@ interface Props {
stroke?: {
width: number;
color: string;
dashArray?: string;
};
}
@@ -30,10 +31,16 @@ export const IsoTileArea = ({
const strokeParams = useMemo(() => {
if (!stroke) return {};
return {
const params: Record<string, any> = {
stroke: stroke.color,
strokeWidth: stroke.width
};
if (stroke.dashArray) {
params.strokeDasharray = stroke.dashArray;
}
return params;
}, [stroke]);
return (

View File

@@ -1,83 +1,29 @@
// import { useRef, useCallback } from 'react';
// import { Rectangle, Shape } from 'paper';
// import gsap from 'gsap';
// import { Coords } from 'src/types';
// import { UNPROJECTED_TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
// import {
// getBoundingBox,
// sortByPosition,
// getTileBounds
// } from 'src/renderer/utils/gridHelpers';
// import { applyProjectionMatrix } from 'src/renderer/utils/projection';
import React from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
// export const useLasso = () => {
// const containerRef = useRef(new Rectangle());
// const shapeRef = useRef<paper.Shape.Rectangle>();
export const Lasso = () => {
const mode = useUiStateStore((state) => {
return state.mode;
});
// const setSelection = useCallback((startTile: Coords, endTile: Coords) => {
// if (!shapeRef.current) return;
if (mode.type !== 'LASSO' || !mode.selection) {
return null;
}
// const boundingBox = getBoundingBox([startTile, endTile]);
const { startTile, endTile } = mode.selection;
// // TODO: Enforce at least one node being passed to this getBoundingBox() to prevent null returns
// if (!boundingBox) return;
// const lassoStartTile = boundingBox[3];
// const lassoScreenPosition = getTileBounds(lassoStartTile).left;
// const sorted = sortByPosition(boundingBox);
// const position = { x: sorted.lowX, y: sorted.highY };
// const size = {
// x: sorted.highX - sorted.lowX,
// y: sorted.highY - sorted.lowY
// };
// shapeRef.current.set({
// position,
// size: [
// (size.x + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3),
// (size.y + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3)
// ]
// });
// containerRef.current.set({
// pivot: shapeRef.current.bounds.bottomLeft,
// position: lassoScreenPosition
// });
// }, []);
// const init = useCallback(() => {
// containerRef.current.removeChildren();
// containerRef.current.set({ pivot: [0, 0] });
// shapeRef.current = new Shape.Rectangle({
// strokeCap: 'round',
// fillColor: 'lightBlue',
// size: [UNPROJECTED_TILE_SIZE, UNPROJECTED_TILE_SIZE],
// opacity: 0.5,
// radius: PIXEL_UNIT * 8,
// strokeWidth: PIXEL_UNIT * 3,
// strokeColor: 'blue',
// dashArray: [5, 10],
// pivot: [0, 0]
// });
// gsap
// .fromTo(
// shapeRef.current,
// { dashOffset: 0 },
// { dashOffset: PIXEL_UNIT * 10, ease: 'none', duration: 0.25 }
// )
// .repeat(-1);
// containerRef.current.addChild(shapeRef.current);
// applyProjectionMatrix(containerRef.current);
// return containerRef.current;
// }, []);
// return {
// init,
// containerRef,
// setSelection
// };
// };
return (
<IsoTileArea
from={startTile}
to={endTile}
fill="rgba(33, 150, 243, 0.15)"
cornerRadius={8}
stroke={{
color: '#2196f3',
width: 2,
dashArray: '8 4'
}}
/>
);
};

View File

@@ -12,6 +12,7 @@ import { TextBoxes } from 'src/components/SceneLayers/TextBoxes/TextBoxes';
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 { useScene } from 'src/hooks/useScene';
import { RendererProps } from 'src/types/rendererProps';
@@ -59,6 +60,9 @@ export const Renderer = ({ showGrid, backgroundColor }: RendererProps) => {
<SceneLayer>
<Rectangles rectangles={rectangles} />
</SceneLayer>
<SceneLayer>
<Lasso />
</SceneLayer>
<Box
sx={{
position: 'absolute',

View File

@@ -9,7 +9,8 @@ import {
Title as TitleIcon,
Undo as UndoIcon,
Redo as RedoIcon,
Help as HelpIcon
Help as HelpIcon,
HighlightAltOutlined as LassoIcon
} from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { IconButton } from 'src/components/IconButton/IconButton';
@@ -92,6 +93,19 @@ export const ToolMenu = () => {
}}
isActive={mode.type === 'CURSOR' || mode.type === 'DRAG_ITEMS'}
/>
<IconButton
name={`Lasso select${hotkeys.lasso ? ` (${hotkeys.lasso.toUpperCase()})` : ''}`}
Icon={<LassoIcon />}
onClick={() => {
uiStateStoreActions.setMode({
type: 'LASSO',
showCursor: true,
selection: null,
isDragging: false
});
}}
isActive={mode.type === 'LASSO'}
/>
<IconButton
name={`Pan${hotkeys.pan ? ` (${hotkeys.pan.toUpperCase()})` : ''}`}
Icon={<PanToolIcon />}

View File

@@ -7,6 +7,7 @@ export interface HotkeyMapping {
rectangle: string | null;
connector: string | null;
text: string | null;
lasso: string | null;
}
export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
@@ -16,7 +17,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
addItem: 'e',
rectangle: 'r',
connector: 't',
text: 'y'
text: 'y',
lasso: 'l'
},
smnrct: {
select: 's',
@@ -24,7 +26,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
addItem: 'n',
rectangle: 'r',
connector: 'c',
text: 't'
text: 't',
lasso: 'l'
},
none: {
select: null,
@@ -32,7 +35,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
addItem: null,
rectangle: null,
connector: null,
text: null
text: null,
lasso: null
}
};

View File

@@ -225,16 +225,21 @@ export const useScene = () => {
);
const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>) => {
if (!model?.actions || !scene?.actions || !currentViewId) return;
(id: string, updates: Partial<ViewItem>, currentState?: State) => {
if (!model?.actions || !scene?.actions || !currentViewId) return getState();
saveToHistoryBeforeChange();
if (!transactionInProgress.current) {
saveToHistoryBeforeChange();
}
const stateToUse = currentState || getState();
const newState = reducers.view({
action: 'UPDATE_VIEWITEM',
payload: { id, ...updates },
ctx: { viewId: currentViewId, state: getState() }
ctx: { viewId: currentViewId, state: stateToUse }
});
setState(newState);
return newState; // Return for chaining
},
[
getState,

View File

@@ -1,6 +1,7 @@
import { produce } from 'immer';
import { ModeActions, Coords, ItemReference } from 'src/types';
import { useScene } from 'src/hooks/useScene';
import type { State } from 'src/stores/reducers/types';
import {
getItemByIdOrThrow,
CoordsUtils,
@@ -19,7 +20,7 @@ const dragItems = (
// Separate items from other draggable elements
const itemRefs = items.filter(item => item.type === 'ITEM');
const otherRefs = items.filter(item => item.type !== 'ITEM');
// If there are items being dragged, find nearest unoccupied tiles for them
if (itemRefs.length > 0) {
const itemsWithTargets = itemRefs.map(item => {
@@ -29,19 +30,24 @@ const dragItems = (
targetTile: CoordsUtils.add(node.tile, delta)
};
});
// Find nearest unoccupied tiles for all items
const newTiles = findNearestUnoccupiedTilesForGroup(
itemsWithTargets,
scene,
itemRefs.map(item => item.id) // Exclude the items being dragged
);
// If we found valid positions for all items, move them
if (newTiles) {
itemRefs.forEach((item, index) => {
scene.updateViewItem(item.id, {
tile: newTiles[index]
// Wrap all updates in a transaction to prevent history issues
scene.transaction(() => {
// Chain state updates to avoid race conditions
let currentState: State | undefined;
itemRefs.forEach((item, index) => {
currentState = scene.updateViewItem(item.id, {
tile: newTiles[index]
}, currentState);
});
});
}

View File

@@ -1,79 +1,122 @@
// import { CoordsUtils, isWithinBounds } from 'src/utils';
// import { ModeActions } from 'src/types';
import { produce } from 'immer';
import { ModeActions, ItemReference } from 'src/types';
import { CoordsUtils, isWithinBounds, hasMovedTile } from 'src/utils';
// export const Lasso: ModeActions = {
// type: 'LASSO',
// mousemove: ({ uiState, Model }) => {
// if (uiState.mode.type !== 'LASSO') return;
// Helper to find all items within the lasso bounds
const getItemsInBounds = (
startTile: { x: number; y: number },
endTile: { x: number; y: number },
scene: any
): ItemReference[] => {
const items: ItemReference[] = [];
// if (uiState.mouse.mousedown === null) return;
// // User is in mousedown mode
// Check all nodes/items
scene.items.forEach((item: any) => {
if (isWithinBounds(item.tile, [startTile, endTile])) {
items.push({ type: 'ITEM', id: item.id });
}
});
// if (
// uiState.mouse.delta === null ||
// CoordsUtils.isEqual(uiState.mouse.delta.tile, CoordsUtils.zero())
// )
// return;
// // User has moved tile since they moused down
// Check all rectangles
scene.rectangles.forEach((rectangle: any) => {
// Check if rectangle's center or any corner is within bounds
if (
isWithinBounds(rectangle.from, [startTile, endTile]) ||
isWithinBounds(rectangle.to, [startTile, endTile])
) {
items.push({ type: 'RECTANGLE', id: rectangle.id });
}
});
// if (!uiState.mode.isDragging) {
// const { mousedown } = uiState.mouse;
// const items = Model.nodes.filter((node) => {
// return CoordsUtils.isEqual(node.tile, mousedown.tile);
// });
// Check all text boxes
scene.textBoxes.forEach((textBox: any) => {
if (isWithinBounds(textBox.tile, [startTile, endTile])) {
items.push({ type: 'TEXTBOX', id: textBox.id });
}
});
// // User is creating a selection
// uiState.mode.selection = {
// startTile: uiState.mouse.mousedown.tile,
// endTile: uiState.mouse.position.tile,
// items
// };
return items;
};
// return;
// }
export const Lasso: ModeActions = {
mousemove: ({ uiState, scene }) => {
if (uiState.mode.type !== 'LASSO' || !uiState.mouse.mousedown) return;
// if (uiState.mode.isDragging) {
// // User is dragging an existing selection
// uiState.mode.selection.startTile = CoordsUtils.add(
// uiState.mode.selection.startTile,
// uiState.mouse.delta.tile
// );
// uiState.mode.selection.endTile = CoordsUtils.add(
// uiState.mode.selection.endTile,
// uiState.mouse.delta.tile
// );
// }
// },
// mousedown: (draft) => {
// if (draft.mode.type !== 'LASSO') return;
if (!hasMovedTile(uiState.mouse)) return;
// if (draft.mode.selection) {
// const isWithinSelection = isWithinBounds(draft.mouse.position.tile, [
// draft.mode.selection.startTile,
// draft.mode.selection.endTile
// ]);
if (uiState.mode.isDragging && uiState.mode.selection) {
// User is dragging an existing selection - switch to DRAG_ITEMS mode
uiState.actions.setMode({
type: 'DRAG_ITEMS',
showCursor: true,
items: uiState.mode.selection.items,
isInitialMovement: true
});
return;
}
// if (!isWithinSelection) {
// draft.mode = {
// type: 'CURSOR',
// showCursor: true,
// mousedown: null
// };
// User is creating/updating the selection box
const startTile = uiState.mouse.mousedown.tile;
const endTile = uiState.mouse.position.tile;
const items = getItemsInBounds(startTile, endTile, scene);
// return;
// }
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'LASSO') {
draft.selection = {
startTile,
endTile,
items
};
}
})
);
},
// if (isWithinSelection) {
// draft.mode.isDragging = true;
mousedown: ({ uiState }) => {
if (uiState.mode.type !== 'LASSO') return;
// return;
// }
// }
// If there's an existing selection, check if click is within it
if (uiState.mode.selection) {
const isWithinSelection = isWithinBounds(uiState.mouse.position.tile, [
uiState.mode.selection.startTile,
uiState.mode.selection.endTile
]);
// draft.mode = {
// type: 'CURSOR',
// showCursor: true,
// mousedown: null
// };
// }
// };
if (isWithinSelection) {
// Clicked within selection - prepare to drag
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'LASSO') {
draft.isDragging = true;
}
})
);
return;
}
// Clicked outside selection - clear it and stay in LASSO mode
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'LASSO') {
draft.selection = null;
draft.isDragging = false;
}
})
);
}
},
mouseup: ({ uiState }) => {
if (uiState.mode.type !== 'LASSO') return;
// Reset dragging state but keep selection
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'LASSO') {
draft.isDragging = false;
}
})
);
}
};

View File

@@ -17,6 +17,7 @@ import { Connector } from './modes/Connector';
import { Pan } from './modes/Pan';
import { PlaceIcon } from './modes/PlaceIcon';
import { TextBox } from './modes/TextBox';
import { Lasso } from './modes/Lasso';
import { usePanHandlers } from './usePanHandlers';
const modes: { [k in string]: ModeActions } = {
@@ -27,7 +28,8 @@ const modes: { [k in string]: ModeActions } = {
CONNECTOR: Connector,
PAN: Pan,
PLACE_ICON: PlaceIcon,
TEXTBOX: TextBox
TEXTBOX: TextBox,
LASSO: Lasso
};
const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => {
@@ -162,6 +164,14 @@ export const useInteractionManager = () => {
showCursor: false,
id: textBoxId
});
} else if (hotkeyMapping.lasso && key === hotkeyMapping.lasso) {
e.preventDefault();
uiState.actions.setMode({
type: 'LASSO',
showCursor: true,
selection: null,
isDragging: false
});
}
};
@@ -308,7 +318,7 @@ export const useInteractionManager = () => {
el.addEventListener('touchstart', onTouchStart);
el.addEventListener('touchmove', onTouchMove);
el.addEventListener('touchend', onTouchEnd);
uiState.rendererEl?.addEventListener('wheel', onScroll);
uiState.rendererEl?.addEventListener('wheel', onScroll, { passive: true });
return () => {
el.removeEventListener('mousemove', onMouseEvent);

View File

@@ -95,6 +95,17 @@ export interface TextBoxMode {
id: string | null;
}
export interface LassoMode {
type: 'LASSO';
showCursor: boolean;
selection: {
startTile: Coords;
endTile: Coords;
items: ItemReference[];
} | null;
isDragging: boolean;
}
export type Mode =
| InteractionsDisabled
| CursorMode
@@ -104,7 +115,8 @@ export type Mode =
| DrawRectangleMode
| TransformRectangleMode
| DragItemsMode
| TextBoxMode;
| TextBoxMode
| LassoMode;
// End mode types
export interface Scroll {