@@ -220,6 +249,7 @@ export function App() {
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.
>
) : (
selectedEntities.map((entity) => {
diff --git a/packages/svg/src/__tests__/handlers.test.ts b/packages/svg/src/__tests__/handlers.test.ts
index 09ddff3..102f93a 100644
--- a/packages/svg/src/__tests__/handlers.test.ts
+++ b/packages/svg/src/__tests__/handlers.test.ts
@@ -258,3 +258,92 @@ describe('Svg renderer - entity drag', () => {
expect(e1.bounds.x).toBeCloseTo(127);
});
});
+
+describe('Svg renderer - resize handles', () => {
+ let svg: SVGSVGElement;
+
+ beforeEach(() => {
+ svg = makesvg();
+ });
+
+ it('dragging the east handle extends entity width', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+
+ // e1: x=100, y=100, w=100, h=100 — east handle at world(200,150) = screen(200,150)
+ drag(svg, 200, 150, 250, 150);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.x).toBeCloseTo(100); // x unchanged
+ expect(e1.bounds.y).toBeCloseTo(100); // y unchanged
+ expect(e1.bounds.width).toBeCloseTo(150); // width grew by 50
+ expect(e1.bounds.height).toBeCloseTo(100); // height unchanged
+ });
+
+ it('dragging the west handle moves x and adjusts width', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+
+ // west handle at world(100,150) = screen(100,150), drag left 30px
+ drag(svg, 100, 150, 70, 150);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.x).toBeCloseTo(70);
+ expect(e1.bounds.width).toBeCloseTo(130);
+ });
+
+ it('dragging the south handle extends entity height', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+
+ // south handle at world(150,200) = screen(150,200), drag down 40px
+ drag(svg, 150, 200, 150, 240);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.height).toBeCloseTo(140);
+ expect(e1.bounds.y).toBeCloseTo(100);
+ });
+
+ it('dragging the nw corner moves x,y and adjusts width,height', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+
+ // nw handle at world(100,100) = screen(100,100), drag to (80,80)
+ drag(svg, 100, 100, 80, 80);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.x).toBeCloseTo(80);
+ expect(e1.bounds.y).toBeCloseTo(80);
+ expect(e1.bounds.width).toBeCloseTo(120);
+ expect(e1.bounds.height).toBeCloseTo(120);
+ });
+
+ it('resize within click threshold is not triggered', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+
+ // Move only 2px from east handle — below default 3px threshold
+ drag(svg, 200, 150, 202, 150);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.width).toBe(100); // unchanged
+ });
+
+ it('resize with snapToGrid snaps the resized edge', () => {
+ const core = makeCore();
+ core.setSelection(['e1' as any], 'replace');
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {}, snapToGrid: 40 });
+
+ // east handle at 200, drag +15 → right edge = 215 → snap to 40 → 200, width = 100 (no change)
+ // drag +25 → right edge = 225 → snap to 40 → 240, width = 140
+ drag(svg, 200, 150, 225, 150);
+
+ const e1 = core.scene.entities.get('e1' as any)!;
+ expect(e1.bounds.width).toBe(140);
+ });
+});
diff --git a/packages/svg/src/__tests__/resize-handles.test.ts b/packages/svg/src/__tests__/resize-handles.test.ts
new file mode 100644
index 0000000..e5e52ba
--- /dev/null
+++ b/packages/svg/src/__tests__/resize-handles.test.ts
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach } from 'vitest';
+import { mountSvgRenderer } from '../renderer';
+import { createCore } from '@floormap-tools/core';
+
+function makeSetup() {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') as SVGSVGElement;
+ document.body.appendChild(svg);
+ (svg as any).getBoundingClientRect = () => ({ left: 0, top: 0, width: 800, height: 600 });
+
+ const core = createCore({
+ scene: {
+ size: { width: 1000, height: 1000 },
+ layers: ['L' as any],
+ entities: new Map(),
+ },
+ viewport: { zoom: 1, pan: { x: 0, y: 0 }, screenSize: { width: 800, height: 600 } },
+ });
+
+ core.add({
+ id: 'e1' as any,
+ layer: 'L' as any,
+ bounds: { x: 100, y: 100, width: 100, height: 100 },
+ selectable: true,
+ });
+
+ core.add({
+ id: 'e2' as any,
+ layer: 'L' as any,
+ bounds: { x: 300, y: 300, width: 80, height: 60 },
+ selectable: true,
+ });
+
+ return { svg, core };
+}
+
+function getOverlaysG(svg: SVGSVGElement) {
+ return svg.querySelector
("g[data-fm='overlays']")!;
+}
+
+describe('paintResizeHandles', () => {
+ let svg: SVGSVGElement;
+ let core: ReturnType;
+
+ beforeEach(() => {
+ ({ svg, core } = makeSetup());
+ });
+
+ it('overlaysG is empty when there is no selection', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ expect(getOverlaysG(svg).children.length).toBe(0);
+ });
+
+ it('shows 8 handles per selected entity', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any], 'replace');
+
+ const rects = getOverlaysG(svg).querySelectorAll('rect');
+ expect(rects.length).toBe(8);
+ });
+
+ it('shows 16 handles when two entities are selected', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any, 'e2' as any], 'replace');
+
+ const rects = getOverlaysG(svg).querySelectorAll('rect');
+ expect(rects.length).toBe(16);
+ });
+
+ it('clears handles when selection is emptied', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any], 'replace');
+ core.setSelection([], 'replace');
+
+ expect(getOverlaysG(svg).children.length).toBe(0);
+ });
+
+ it('handles have pointer-events=none', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any], 'replace');
+
+ const rects = getOverlaysG(svg).querySelectorAll('rect');
+ rects.forEach((rect) => {
+ expect(rect.getAttribute('pointer-events')).toBe('none');
+ });
+ });
+
+ it('handles update when entity bounds change', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any], 'replace');
+
+ const rectsBefore = [...getOverlaysG(svg).querySelectorAll('rect')].map((r) => r.getAttribute('x'));
+ core.update('e1' as any, { bounds: { x: 200, y: 200, width: 100, height: 100 } });
+ const rectsAfter = [...getOverlaysG(svg).querySelectorAll('rect')].map((r) => r.getAttribute('x'));
+
+ expect(rectsBefore).not.toEqual(rectsAfter);
+ });
+
+ it('handles reposition when viewport zooms', () => {
+ mountSvgRenderer(core, { mount: svg, drawEntity: () => {} });
+ core.setSelection(['e1' as any], 'replace');
+
+ const widthBefore = Number(getOverlaysG(svg).querySelector('rect')!.getAttribute('width'));
+ core.zoomAt({ x: 400, y: 300 }, 1);
+ const widthAfter = Number(getOverlaysG(svg).querySelector('rect')!.getAttribute('width'));
+
+ // Handle world-space size decreases as zoom increases (to keep screen-space size constant)
+ expect(widthAfter).toBeLessThan(widthBefore);
+ });
+});
diff --git a/packages/svg/src/handlers.ts b/packages/svg/src/handlers.ts
index a18b91a..9923084 100644
--- a/packages/svg/src/handlers.ts
+++ b/packages/svg/src/handlers.ts
@@ -1,5 +1,5 @@
-import { FloormapCore, EntityId, snapToGrid } from '@floormap-tools/core';
-import type { Bounds } from '@floormap-tools/core';
+import { FloormapCore, EntityId, snapToGrid, hitTestResizeHandles, applyResizeDelta } from '@floormap-tools/core';
+import type { Bounds, ResizeDirection } from '@floormap-tools/core';
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,
@@ -30,6 +30,10 @@ export function attachHandlers(
hitEntityId: null as EntityId | null,
entitySnapshots: new Map(),
entityDragWorldStart: { x: 0, y: 0 },
+ // resize state
+ resizeHandle: null as { entityId: EntityId; direction: ResizeDirection } | null,
+ resizeSnapshot: null as Bounds | null,
+ resizeDragWorldStart: { x: 0, y: 0 },
};
// Tracks active pointer positions for pinch-to-zoom
@@ -73,9 +77,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';
}
@@ -100,11 +116,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;
+ 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) {
// Stay pending until movement exceeds click threshold
- const dx = curr.x - state.downPos.x;
- const dy = curr.y - state.downPos.y;
- const threshold2 = options.clickThresholdPx * options.clickThresholdPx;
if (dx * dx + dy * dy <= threshold2) {
state.lastPos = curr;
return;
@@ -127,6 +151,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 = {
@@ -190,8 +233,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)
@@ -222,6 +267,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/svg/src/index.ts b/packages/svg/src/index.ts
index ae90c5e..34ed5ea 100644
--- a/packages/svg/src/index.ts
+++ b/packages/svg/src/index.ts
@@ -38,3 +38,5 @@ export { mountSvgRenderer } from './renderer';
export type { SvgRendererOptions as Options, SvgRenderer as Renderer };
export type { SelectionOverlayStyle } from './selection-overlay';
export type { GridOptions } from './grid';
+export { paintResizeHandles } from './resize-handles';
+export type { ResizeHandleStyle } from './resize-handles';
diff --git a/packages/svg/src/renderer.ts b/packages/svg/src/renderer.ts
index 5868a14..453e6ff 100644
--- a/packages/svg/src/renderer.ts
+++ b/packages/svg/src/renderer.ts
@@ -3,6 +3,7 @@ import type { SvgRenderer, SvgRendererOptions } from './index';
import { setupSvgRoot, getGroups, clearChildren } from './dom';
import { attachHandlers } from './handlers';
import { paintSelectionOverlay } from './selection-overlay';
+import { paintResizeHandles } from './resize-handles';
import { paintGrid } from './grid';
export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions): SvgRenderer {
@@ -32,16 +33,19 @@ export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions):
applyViewportTransform(core, groups.viewportG);
paint(core, groups.objectsG, drawEntity);
if (selectionOverlay !== false) paintSelectionOverlay(core, groups.selectionG, selectionOverlay);
+ paintResizeHandles(core, groups.overlaysG);
if (grid !== false) paintGrid(core, groups.gridG, grid);
const offViewport = core.on('viewport:change', () => {
applyViewportTransform(core, groups.viewportG);
+ paintResizeHandles(core, groups.overlaysG);
if (grid !== false) paintGrid(core, groups.gridG, grid);
});
const offEntities = core.on('entities:changed', () => {
paint(core, groups.objectsG, drawEntity);
if (selectionOverlay !== false) paintSelectionOverlay(core, groups.selectionG, selectionOverlay);
+ paintResizeHandles(core, groups.overlaysG);
});
const offSelection = core.on('selection:change', () => {
@@ -51,6 +55,7 @@ export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions):
} else {
clearChildren(groups.selectionG);
}
+ paintResizeHandles(core, groups.overlaysG);
});
const detach = attachHandlers(mount, core, {
@@ -71,6 +76,7 @@ export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions):
rerender() {
paint(core, groups.objectsG, drawEntity);
if (selectionOverlay !== false) paintSelectionOverlay(core, groups.selectionG, selectionOverlay);
+ paintResizeHandles(core, groups.overlaysG);
if (grid !== false) paintGrid(core, groups.gridG, grid);
},
destroy() {
diff --git a/packages/svg/src/resize-handles.ts b/packages/svg/src/resize-handles.ts
new file mode 100644
index 0000000..b56425d
--- /dev/null
+++ b/packages/svg/src/resize-handles.ts
@@ -0,0 +1,64 @@
+import type { FloormapCore } from '@floormap-tools/core';
+import { clearChildren } from './dom';
+
+export type ResizeHandleStyle = {
+ size?: number;
+ fill?: string;
+ stroke?: string;
+ strokeWidth?: number;
+};
+
+const NS = 'http://www.w3.org/2000/svg';
+
+const DIRECTIONS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const;
+
+type Dir = typeof DIRECTIONS[number];
+
+function handleCenter(bounds: { x: number; y: number; width: number; height: number }, 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 function paintResizeHandles(
+ core: FloormapCore,
+ overlaysG: SVGGElement,
+ style: ResizeHandleStyle = {},
+): void {
+ const { size = 8, fill = '#fff', stroke = '#2563eb', strokeWidth = 1.5 } = style;
+
+ clearChildren(overlaysG);
+
+ if (core.selection.size === 0) return;
+
+ const { zoom } = core.viewport;
+ const halfWorld = size / zoom / 2;
+
+ for (const id of core.selection) {
+ const entity = core.scene.entities.get(id);
+ if (!entity) continue;
+
+ for (const dir of DIRECTIONS) {
+ const center = handleCenter(entity.bounds, dir);
+ const rect = document.createElementNS(NS, 'rect');
+ rect.setAttribute('x', String(center.x - halfWorld));
+ rect.setAttribute('y', String(center.y - halfWorld));
+ rect.setAttribute('width', String(halfWorld * 2));
+ rect.setAttribute('height', String(halfWorld * 2));
+ rect.setAttribute('fill', fill);
+ rect.setAttribute('stroke', stroke);
+ rect.setAttribute('stroke-width', String(strokeWidth));
+ rect.setAttribute('vector-effect', 'non-scaling-stroke');
+ rect.setAttribute('pointer-events', 'none');
+ overlaysG.appendChild(rect);
+ }
+ }
+}