From 2d004f6af9c7230e65f9ce1a5cf950772733ace4 Mon Sep 17 00:00:00 2001 From: lucasfelixc Date: Thu, 18 Jun 2026 19:56:42 -0300 Subject: [PATCH 1/2] Implement first version of interactive resizing --- .../adapters/react/src/FloormapCanvas.tsx | 4 + .../react/src/internal/ResizeHandles.tsx | 70 ++++++++++ .../react/src/internal/attachHandlers.ts | 69 ++++++++-- packages/core/src/__tests__/utils.test.ts | 129 +++++++++++++++++- packages/core/src/types.ts | 4 +- packages/core/src/utils.ts | 71 +++++++++- packages/examples/js-vanilla/index.html | 1 + packages/examples/js-vanilla/src/main.ts | 27 +++- packages/examples/react/src/App.tsx | 48 +++++-- packages/svg/src/__tests__/handlers.test.ts | 89 ++++++++++++ .../svg/src/__tests__/resize-handles.test.ts | 110 +++++++++++++++ packages/svg/src/handlers.ts | 69 ++++++++-- packages/svg/src/index.ts | 2 + packages/svg/src/renderer.ts | 6 + packages/svg/src/resize-handles.ts | 64 +++++++++ 15 files changed, 723 insertions(+), 40 deletions(-) create mode 100644 packages/adapters/react/src/internal/ResizeHandles.tsx create mode 100644 packages/svg/src/__tests__/resize-handles.test.ts create mode 100644 packages/svg/src/resize-handles.ts diff --git a/packages/adapters/react/src/FloormapCanvas.tsx b/packages/adapters/react/src/FloormapCanvas.tsx index 0e39bdb..3971283 100644 --- a/packages/adapters/react/src/FloormapCanvas.tsx +++ b/packages/adapters/react/src/FloormapCanvas.tsx @@ -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 = {}; @@ -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(() => { @@ -146,6 +149,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({ {selectionOverlay !== false && ( )} + ); diff --git a/packages/adapters/react/src/internal/ResizeHandles.tsx b/packages/adapters/react/src/internal/ResizeHandles.tsx new file mode 100644 index 0000000..9b9020c --- /dev/null +++ b/packages/adapters/react/src/internal/ResizeHandles.tsx @@ -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; + 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 ( + + {selected.flatMap((entity) => + DIRECTIONS.map((dir) => { + const center = handleCenter(entity.bounds, dir); + return ( + + ); + }) + )} + + ); +}); diff --git a/packages/adapters/react/src/internal/attachHandlers.ts b/packages/adapters/react/src/internal/attachHandlers.ts index dae3e21..5a16b46 100644 --- a/packages/adapters/react/src/internal/attachHandlers.ts +++ b/packages/adapters/react/src/internal/attachHandlers.ts @@ -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; @@ -21,7 +21,7 @@ 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, @@ -29,6 +29,9 @@ export function attachHandlers( hitEntityId: null as EntityId | null, entitySnapshots: new Map(), 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(); @@ -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 => 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'; } @@ -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; } @@ -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 = { @@ -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) @@ -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 = ''; } } diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index a632fa2..861bb59 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -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', () => { @@ -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); + }); }); \ No newline at end of file diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7ff96b3..748592e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -26,4 +26,6 @@ export interface Viewport { maxZoom?: number; } -export type SelectionMode = 'replace' | 'add' | 'toggle'; \ No newline at end of file +export type SelectionMode = 'replace' | 'add' | 'toggle'; + +export type ResizeDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'; \ No newline at end of file diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index abca52d..0c7072b 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,4 +1,4 @@ -import { Bounds, Vec2 } from './types'; +import { Bounds, Entity, EntityId, ResizeDirection, Vec2 } from './types'; export const clamp = (value: number, min: number, max: number) => { return Math.max(min, Math.min(max, value)); @@ -27,4 +27,71 @@ export const unionBounds = (a: Bounds, b: Bounds): Bounds => { }; export const snapToGrid = (value: number, gridSize: number): number => - Math.round(value / gridSize) * gridSize; \ No newline at end of file + Math.round(value / gridSize) * gridSize; + +const DIRECTIONS: ResizeDirection[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; + +function handleCenter(bounds: Bounds, dir: ResizeDirection): Vec2 { + 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 function hitTestResizeHandles( + selectedEntities: Entity[], + worldToScreen: (p: Vec2) => Vec2, + screenPoint: Vec2, + handleSizePx = 8, +): { entityId: EntityId; direction: ResizeDirection } | null { + const half = handleSizePx / 2; + for (const entity of selectedEntities) { + for (const dir of DIRECTIONS) { + const sc = worldToScreen(handleCenter(entity.bounds, dir)); + const dx = screenPoint.x - sc.x; + const dy = screenPoint.y - sc.y; + if (Math.abs(dx) <= half && Math.abs(dy) <= half) { + return { entityId: entity.id, direction: dir }; + } + } + } + return null; +} + +export function applyResizeDelta( + original: Bounds, + direction: ResizeDirection, + worldDelta: Vec2, + snapSize?: number, +): Bounds { + const snap = snapSize ? (v: number) => snapToGrid(v, snapSize) : (v: number) => v; + let { x, y, width, height } = original; + + if (direction.includes('w')) { + const newX = snap(x + worldDelta.x); + width = width - (newX - x); + x = newX; + } + if (direction.includes('e')) { + const newRight = snap(x + width + worldDelta.x); + width = newRight - x; + } + if (direction.includes('n')) { + const newY = snap(y + worldDelta.y); + height = height - (newY - y); + y = newY; + } + if (direction.includes('s')) { + const newBottom = snap(y + height + worldDelta.y); + height = newBottom - y; + } + + return { x, y, width: Math.max(1, width), height: Math.max(1, height) }; +} \ No newline at end of file diff --git a/packages/examples/js-vanilla/index.html b/packages/examples/js-vanilla/index.html index 7b4e757..3d0d28a 100644 --- a/packages/examples/js-vanilla/index.html +++ b/packages/examples/js-vanilla/index.html @@ -35,6 +35,7 @@

Click an entity to select it.

Shift+click to add, Ctrl/⌘+click to toggle.

Drag a selected entity to move it.

+

Drag a handle to resize it.

diff --git a/packages/examples/js-vanilla/src/main.ts b/packages/examples/js-vanilla/src/main.ts index 43bf097..56bb4e3 100644 --- a/packages/examples/js-vanilla/src/main.ts +++ b/packages/examples/js-vanilla/src/main.ts @@ -1,4 +1,4 @@ -import { createCore, createEmptyScene } from '@floormap-tools/core'; +import { createCore, createEmptyScene, hitTestResizeHandles } from '@floormap-tools/core'; document.addEventListener('gesturestart', (e) => e.preventDefault(), { passive: false }); document.addEventListener('gesturechange', (e) => e.preventDefault(), { passive: false }); @@ -234,7 +234,10 @@ function renderPanel() { const p3 = document.createElement('p'); p3.className = 'hint'; p3.textContent = 'Drag a selected entity to move it.'; - container.append(p1, p2, p3); + const p4 = document.createElement('p'); + p4.className = 'hint'; + p4.textContent = 'Drag a handle to resize it.'; + container.append(p1, p2, p3, p4); return; } @@ -282,11 +285,27 @@ updateZoomDisplay(); // ── Hover cursor ───────────────────────────────────────────────────────────── +const RESIZE_CURSORS: Record = { + n: 'ns-resize', s: 'ns-resize', + e: 'ew-resize', w: 'ew-resize', + ne: 'nesw-resize', sw: 'nesw-resize', + nw: 'nwse-resize', se: 'nwse-resize', +}; + svgEl.addEventListener('pointermove', (e) => { if (e.buttons !== 0) return; const rect = svgEl.getBoundingClientRect(); - const world = core.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - svgEl.style.cursor = core.hitTest(world) ? 'grab' : ''; + const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + const selectedEntities = [...core.selection] + .map((id) => core.scene.entities.get(id)) + .filter((en): en is NonNullable => en != null); + const handleHit = hitTestResizeHandles(selectedEntities, (p) => core.worldToScreen(p), screen); + if (handleHit) { + svgEl.style.cursor = RESIZE_CURSORS[handleHit.direction]; + } else { + const world = core.screenToWorld(screen); + svgEl.style.cursor = core.hitTest(world) ? 'grab' : ''; + } }); // ── Resize handling ─────────────────────────────────────────────────────────── diff --git a/packages/examples/react/src/App.tsx b/packages/examples/react/src/App.tsx index 1f5ec84..6c52c6b 100644 --- a/packages/examples/react/src/App.tsx +++ b/packages/examples/react/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { FloormapCanvas, useFloormapCore, useSelection, useViewport } from '@floormap-tools/react'; -import { addEntity, createEmptyScene } from '@floormap-tools/core'; +import { addEntity, createEmptyScene, hitTestResizeHandles } from '@floormap-tools/core'; import type { Entity, EntityId, LayerId } from '@floormap-tools/core'; // ── Types ────────────────────────────────────────────────────────────────────── @@ -120,6 +120,29 @@ export function App() { const [snapEnabled, setSnapEnabled] = useState(false); const [snapSize, setSnapSize] = useState(40); + const [hoverCursor, setHoverCursor] = useState(''); + + const RESIZE_CURSORS: Record = { + n: 'ns-resize', s: 'ns-resize', + e: 'ew-resize', w: 'ew-resize', + ne: 'nesw-resize', sw: 'nesw-resize', + nw: 'nwse-resize', se: 'nwse-resize', + }; + + function handleCanvasPointerMove(e: React.PointerEvent) { + if (e.buttons !== 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + const selectedEntities = [...core.selection] + .map((id) => core.scene.entities.get(id)) + .filter((en): en is NonNullable => en != null); + const handleHit = hitTestResizeHandles(selectedEntities, (p) => core.worldToScreen(p), screen); + if (handleHit) { + setHoverCursor(RESIZE_CURSORS[handleHit.direction]); + } else { + setHoverCursor(core.hitTest(core.screenToWorld(screen)) ? 'grab' : ''); + } + } useEffect(() => { core.fitToScene(48); @@ -202,14 +225,20 @@ export function App() {
- +
setHoverCursor('')} + > + +