Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/add-interactive-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@floormap-tools/core": minor
"@floormap-tools/svg": minor
"@floormap-tools/react": minor
---

Add interactive entity resizing via drag handles.

- `@floormap-tools/core` exports a new `ResizeDirection` type (`'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'`), `hitTestResizeHandles` to detect which handle is under the cursor in screen space, and `applyResizeDelta` to compute new bounds from a world-space drag delta (with optional snap-to-grid on the edge position)
- `@floormap-tools/svg` adds `paintResizeHandles` painter that draws 8 handles around each selected entity at a consistent pixel size regardless of zoom, and wires resize drag into `attachHandlers` with a priority-over-entity-drag rule
- `@floormap-tools/react` adds a `ResizeHandles` internal component that renders the same 8 handles declaratively, and wires resize drag into `attachHandlers` with the same state machine (`idle → pending → resize → idle`)
4 changes: 4 additions & 0 deletions packages/adapters/react/src/FloormapCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { useEntities } from './internal/useEntities';
import { useViewportTransform } from './internal/useViewportTransform';
import { GridLayer } from './internal/GridLayer';
import { SelectionOverlay } from './internal/SelectionOverlay';
import { ResizeHandles } from './internal/ResizeHandles';
import { useSelection } from './hooks/useSelection';
import { useViewport } from './hooks/useViewport';
import type { GridOptions, SelectionOverlayStyle } from './types';

const DEFAULT_SELECTION_OVERLAY: SelectionOverlayStyle = {};
Expand Down Expand Up @@ -74,6 +76,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({

const entities = useEntities(core);
const selection = useSelection(core);
const viewport = useViewport(core);

// Attach interaction handlers
useEffect(() => {
Expand Down Expand Up @@ -146,6 +149,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({
{selectionOverlay !== false && (
<SelectionOverlay entities={entities} selection={selection} style={selectionOverlay} />
)}
<ResizeHandles entities={entities} selection={selection} zoom={viewport.zoom} />
</g>
</svg>
);
Expand Down
70 changes: 70 additions & 0 deletions packages/adapters/react/src/internal/ResizeHandles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { memo } from 'react';
import type { Entity, EntityId } from '@floormap-tools/core';

export type ResizeHandleStyle = {
size?: number;
fill?: string;
stroke?: string;
strokeWidth?: number;
};

interface ResizeHandlesProps {
entities: Entity[];
selection: ReadonlySet<EntityId>;
zoom: number;
style?: ResizeHandleStyle;
}

const DIRECTIONS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const;
type Dir = typeof DIRECTIONS[number];

function handleCenter(bounds: Entity['bounds'], dir: Dir) {
const { x, y, width: w, height: h } = bounds;
switch (dir) {
case 'nw': return { x, y };
case 'n': return { x: x + w/2, y };
case 'ne': return { x: x + w, y };
case 'e': return { x: x + w, y: y + h/2 };
case 'se': return { x: x + w, y: y + h };
case 's': return { x: x + w/2, y: y + h };
case 'sw': return { x, y: y + h };
case 'w': return { x, y: y + h/2 };
}
}

export const ResizeHandles = memo(function ResizeHandles({
entities,
selection,
zoom,
style = {},
}: ResizeHandlesProps) {
const { size = 8, fill = '#fff', stroke = '#2563eb', strokeWidth = 1.5 } = style;
const halfWorld = size / zoom / 2;

const selected = entities.filter((e) => selection.has(e.id));
if (selected.length === 0) return null;

return (
<g data-fm="overlays">
{selected.flatMap((entity) =>
DIRECTIONS.map((dir) => {
const center = handleCenter(entity.bounds, dir);
return (
<rect
key={`${entity.id}-${dir}`}
x={center.x - halfWorld}
y={center.y - halfWorld}
width={halfWorld * 2}
height={halfWorld * 2}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
vectorEffect="non-scaling-stroke"
pointerEvents="none"
/>
);
})
)}
</g>
);
});
69 changes: 58 additions & 11 deletions packages/adapters/react/src/internal/attachHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { snapToGrid } from '@floormap-tools/core';
import type { FloormapCore, EntityId, Bounds } from '@floormap-tools/core';
import { snapToGrid, hitTestResizeHandles, applyResizeDelta } from '@floormap-tools/core';
import type { FloormapCore, EntityId, Bounds, ResizeDirection } from '@floormap-tools/core';

export type AttachHandlersOptions = {
enableWheel: boolean;
Expand All @@ -21,14 +21,17 @@ export function attachHandlers(
options: AttachHandlersOptions,
) {
const state = {
dragMode: 'idle' as 'idle' | 'pending' | 'pan' | 'entity',
dragMode: 'idle' as 'idle' | 'pending' | 'pan' | 'entity' | 'resize',
downPos: { x: 0, y: 0 },
lastPos: { x: 0, y: 0 },
btn: -1 as number,
capturedPointerId: -1,
hitEntityId: null as EntityId | null,
entitySnapshots: new Map<EntityId, Bounds>(),
entityDragWorldStart: { x: 0, y: 0 },
resizeHandle: null as { entityId: EntityId; direction: ResizeDirection } | null,
resizeSnapshot: null as Bounds | null,
resizeDragWorldStart: { x: 0, y: 0 },
};

const pointers = new Map<number, { x: number; y: number }>();
Expand Down Expand Up @@ -67,9 +70,21 @@ export function attachHandlers(
state.dragMode = 'pending';
state.btn = event.button;
state.downPos = state.lastPos = cursor;
state.hitEntityId = options.enableEntityDrag
? core.hitTest(core.screenToWorld(cursor))
: null;

const selectedEntities = [...core.selection]
.map((id) => core.scene.entities.get(id))
.filter((e): e is NonNullable<typeof e> => e != null);
const handleHit = hitTestResizeHandles(selectedEntities, (p) => core.worldToScreen(p), cursor);

if (handleHit) {
state.resizeHandle = handleHit;
state.hitEntityId = null;
} else {
state.resizeHandle = null;
state.hitEntityId = options.enableEntityDrag
? core.hitTest(core.screenToWorld(cursor))
: null;
}
svg.style.cursor = 'grabbing';
}

Expand All @@ -94,10 +109,19 @@ export function attachHandlers(
if (state.dragMode === 'idle' || state.btn !== options.dragButton) return;

if (state.dragMode === 'pending') {
if (state.hitEntityId !== null) {
const dx = curr.x - state.downPos.x;
const dy = curr.y - state.downPos.y;
if (dx * dx + dy * dy <= options.clickThresholdPx * options.clickThresholdPx) {
const dx = curr.x - state.downPos.x;
const dy = curr.y - state.downPos.y;
const threshold2 = options.clickThresholdPx * options.clickThresholdPx;

if (state.resizeHandle !== null) {
if (dx * dx + dy * dy <= threshold2) { state.lastPos = curr; return; }
state.dragMode = 'resize';
const entity = core.scene.entities.get(state.resizeHandle.entityId);
state.resizeSnapshot = entity ? { ...entity.bounds } : null;
state.resizeDragWorldStart = core.screenToWorld(state.downPos);
svg.style.cursor = 'crosshair';
} else if (state.hitEntityId !== null) {
if (dx * dx + dy * dy <= threshold2) {
state.lastPos = curr;
return;
}
Expand All @@ -117,6 +141,25 @@ export function attachHandlers(
}
}

if (state.dragMode === 'resize') {
if (state.resizeHandle && state.resizeSnapshot) {
const worldCurr = core.screenToWorld(curr);
const worldDelta = {
x: worldCurr.x - state.resizeDragWorldStart.x,
y: worldCurr.y - state.resizeDragWorldStart.y,
};
const newBounds = applyResizeDelta(
state.resizeSnapshot,
state.resizeHandle.direction,
worldDelta,
options.snapToGrid,
);
core.update(state.resizeHandle.entityId, { bounds: newBounds });
}
state.lastPos = curr;
return;
}

if (state.dragMode === 'entity') {
const worldCurr = core.screenToWorld(curr);
const worldDelta = {
Expand Down Expand Up @@ -173,8 +216,10 @@ export function attachHandlers(
state.btn = -1;
state.hitEntityId = null;
state.entitySnapshots.clear();
state.resizeHandle = null;
state.resizeSnapshot = null;

if (wasClick && options.clickSelect && !wasPinching && prevMode !== 'entity') {
if (wasClick && options.clickSelect && !wasPinching && prevMode !== 'entity' && prevMode !== 'resize') {
const mode = options.modifierSelect
? e.shiftKey
? ('add' as const)
Expand Down Expand Up @@ -204,6 +249,8 @@ export function attachHandlers(
state.btn = -1;
state.hitEntityId = null;
state.entitySnapshots.clear();
state.resizeHandle = null;
state.resizeSnapshot = null;
svg.style.cursor = '';
}
}
Expand Down
129 changes: 127 additions & 2 deletions packages/core/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it, expect } from 'vitest';
import { clamp, containsPointAABB, expandBounds, unionBounds, snapToGrid } from '../utils';
import { Bounds, Vec2 } from '../types';
import { clamp, containsPointAABB, expandBounds, unionBounds, snapToGrid, hitTestResizeHandles, applyResizeDelta } from '../utils';
import { Bounds, Entity, EntityId, LayerId, Vec2 } from '../types';

function makeEntity(bounds: Bounds): Entity {
return { id: 'e1' as EntityId, layer: 'l1' as LayerId, bounds, selectable: true };
}

function identityWorldToScreen(p: Vec2): Vec2 { return p; }

describe('clamp', () => {
it('should return value when within range', () => {
Expand Down Expand Up @@ -118,4 +124,123 @@ describe('snapToGrid', () => {
expect(snapToGrid(37, 25)).toBe(25);
expect(snapToGrid(38, 25)).toBe(50);
});
});

describe('hitTestResizeHandles', () => {
const bounds: Bounds = { x: 100, y: 100, width: 100, height: 100 };
const entity = makeEntity(bounds);
const entities = [entity];

it('returns null when no entities', () => {
expect(hitTestResizeHandles([], identityWorldToScreen, { x: 100, y: 100 })).toBeNull();
});

it('returns null when pointer is far from all handles', () => {
expect(hitTestResizeHandles(entities, identityWorldToScreen, { x: 150, y: 150 })).toBeNull();
});

it('hits nw corner handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 100, y: 100 });
expect(result).toEqual({ entityId: 'e1', direction: 'nw' });
});

it('hits ne corner handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 200, y: 100 });
expect(result).toEqual({ entityId: 'e1', direction: 'ne' });
});

it('hits se corner handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 200, y: 200 });
expect(result).toEqual({ entityId: 'e1', direction: 'se' });
});

it('hits sw corner handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 100, y: 200 });
expect(result).toEqual({ entityId: 'e1', direction: 'sw' });
});

it('hits n edge handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 150, y: 100 });
expect(result).toEqual({ entityId: 'e1', direction: 'n' });
});

it('hits s edge handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 150, y: 200 });
expect(result).toEqual({ entityId: 'e1', direction: 's' });
});

it('hits e edge handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 200, y: 150 });
expect(result).toEqual({ entityId: 'e1', direction: 'e' });
});

it('hits w edge handle', () => {
const result = hitTestResizeHandles(entities, identityWorldToScreen, { x: 100, y: 150 });
expect(result).toEqual({ entityId: 'e1', direction: 'w' });
});

it('respects custom handle size', () => {
// pointer 3px away from center — should hit with size=8, miss with size=4
expect(hitTestResizeHandles(entities, identityWorldToScreen, { x: 103, y: 100 }, 8)).toEqual({ entityId: 'e1', direction: 'nw' });
expect(hitTestResizeHandles(entities, identityWorldToScreen, { x: 103, y: 100 }, 4)).toBeNull();
});

it('works with zoom=2 (worldToScreen scales coords)', () => {
// zoom=2: screen = world * 2, entity corner nw at world(100,100) → screen(200,200)
const worldToScreen = (p: Vec2): Vec2 => ({ x: p.x * 2, y: p.y * 2 });
const result = hitTestResizeHandles(entities, worldToScreen, { x: 200, y: 200 });
expect(result).toEqual({ entityId: 'e1', direction: 'nw' });
});
});

describe('applyResizeDelta', () => {
const original: Bounds = { x: 100, y: 100, width: 200, height: 100 };

it('e — extends right edge', () => {
const result = applyResizeDelta(original, 'e', { x: 50, y: 0 });
expect(result).toEqual({ x: 100, y: 100, width: 250, height: 100 });
});

it('w — moves left edge, keeps right edge fixed', () => {
const result = applyResizeDelta(original, 'w', { x: -50, y: 0 });
expect(result).toEqual({ x: 50, y: 100, width: 250, height: 100 });
});

it('s — extends bottom edge', () => {
const result = applyResizeDelta(original, 's', { x: 0, y: 30 });
expect(result).toEqual({ x: 100, y: 100, width: 200, height: 130 });
});

it('n — moves top edge, keeps bottom edge fixed', () => {
const result = applyResizeDelta(original, 'n', { x: 0, y: -20 });
expect(result).toEqual({ x: 100, y: 80, width: 200, height: 120 });
});

it('se — extends right and bottom', () => {
const result = applyResizeDelta(original, 'se', { x: 10, y: 20 });
expect(result).toEqual({ x: 100, y: 100, width: 210, height: 120 });
});

it('nw — moves top-left corner', () => {
const result = applyResizeDelta(original, 'nw', { x: 10, y: 10 });
expect(result).toEqual({ x: 110, y: 110, width: 190, height: 90 });
});

it('enforces minimum width and height of 1', () => {
const result = applyResizeDelta(original, 'e', { x: -300, y: 0 });
expect(result.width).toBe(1);
});

it('respects snapToGrid', () => {
// right edge: 100 + 200 + 15 = 315 → snap to 40 → 320 → width = 320 - 100 = 220
const result = applyResizeDelta(original, 'e', { x: 15, y: 0 }, 40);
expect(result.width).toBe(220);
});

it('snapToGrid on west edge snaps the x position', () => {
// x: 100 + (-15) = 85 → snap to 40 → 80 → width = 200 + (100 - 80) = 220
const result = applyResizeDelta(original, 'w', { x: -15, y: 0 }, 40);
expect(result.x).toBe(80);
expect(result.width).toBe(220);
});
});
4 changes: 3 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ export interface Viewport {
maxZoom?: number;
}

export type SelectionMode = 'replace' | 'add' | 'toggle';
export type SelectionMode = 'replace' | 'add' | 'toggle';

export type ResizeDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
Loading
Loading