Skip to content

Commit 71ccc2a

Browse files
authored
fix(tldraw): bound selection edge and rotate handle hit areas (tldraw#8926)
In order to stop edge resize handles from stealing hits near the corners (tldraw#8840), this PR bounds the edge and rotate handle hit geometry in `SelectionForegroundOverlayUtil`. Edge handles were modeled as unfilled `Edge2d` lines, which the `OverlayManager` inflates by the global `hitTestMargin` (8/zoom on each side). That made the edge hit band wider than the corner box (≈6.75/zoom half-extent), so the edge won hit tests right at the corner. Edges are now bounded filled `Polygon2d` rectangles matching the pre-overlay dimensions (`targetSize` per side, ≈4.5/zoom), restoring the corner-wins-near-corners behavior. The rotate handle radius is also set to exactly half the corner box width. ### Change type - [x] `bugfix` ### Test plan 1. Select a shape and move the pointer just outside a corner — the corner resize cursor should win over the edge cursor. 2. Hover the middle of an edge — the edge resize handle should still be reachable. 3. Hover a rotate corner — its circular hit area should be half the corner box width. - [x] Unit tests ### Release notes - Fix selection edge resize handles overlapping corner handles, which made corners hard to grab on small shapes. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +15 / -14 | | Tests | +7 / -7 | Closes tldraw#8840 Relates to tldraw#8839
1 parent 4170e5b commit 71ccc2a

2 files changed

Lines changed: 22 additions & 21 deletions

File tree

packages/tldraw/src/lib/overlays/SelectionForegroundOverlayUtil.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
Box,
33
Circle2d,
4-
Edge2d,
54
Geometry2d,
65
HALF_PI,
76
Mat,
@@ -245,10 +244,12 @@ export class SelectionForegroundOverlayUtil extends OverlayUtil<TLSelectionForeg
245244
transform: Mat
246245
): Geometry2d {
247246
if (handle === 'top' || handle === 'bottom' || handle === 'left' || handle === 'right') {
248-
const edge = this._getEdgeLocalPoints(handle, state.width, state.height)
249-
return new Edge2d({
250-
start: Mat.applyToPoint(transform, edge.start),
251-
end: Mat.applyToPoint(transform, edge.end),
247+
const rect = this._getEdgeLocalRect(handle, state)
248+
return new Polygon2d({
249+
points: this._localRectToPoints(rect.x, rect.y, rect.w, rect.h).map((p) =>
250+
Mat.applyToPoint(transform, p)
251+
),
252+
isFilled: true,
252253
})
253254
}
254255

@@ -269,7 +270,7 @@ export class SelectionForegroundOverlayUtil extends OverlayUtil<TLSelectionForeg
269270
): Geometry2d {
270271
const cornerSize = Math.max(state.targetSizeX, state.targetSizeY) * 1.5
271272
const center = this._getRotateHandleLocalCenter(handle, state.width, state.height, cornerSize)
272-
const radius = (state.targetSize * 3) / 2
273+
const radius = cornerSize
273274
return new Circle2d({
274275
x: center.x - radius,
275276
y: center.y - radius,
@@ -622,20 +623,20 @@ export class SelectionForegroundOverlayUtil extends OverlayUtil<TLSelectionForeg
622623
}
623624
}
624625

625-
private _getEdgeLocalPoints(
626+
private _getEdgeLocalRect(
626627
edge: SelectionEdge,
627-
width: number,
628-
height: number
629-
): { start: Vec; end: Vec } {
628+
state: SelectionState
629+
): { x: number; y: number; w: number; h: number } {
630+
const { width, height, targetSizeX, targetSizeY } = state
630631
switch (edge) {
631632
case 'top':
632-
return { start: new Vec(0, 0), end: new Vec(width, 0) }
633+
return { x: 0, y: -targetSizeY, w: width, h: targetSizeY * 2 }
633634
case 'right':
634-
return { start: new Vec(width, 0), end: new Vec(width, height) }
635+
return { x: width - targetSizeX, y: 0, w: targetSizeX * 2, h: height }
635636
case 'bottom':
636-
return { start: new Vec(0, height), end: new Vec(width, height) }
637+
return { x: 0, y: height - targetSizeY, w: width, h: targetSizeY * 2 }
637638
case 'left':
638-
return { start: new Vec(0, 0), end: new Vec(0, height) }
639+
return { x: -targetSizeX, y: 0, w: targetSizeX * 2, h: height }
639640
}
640641
}
641642

packages/tldraw/src/test/overlays/OverlayManager.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,16 @@ describe('OverlayManager', () => {
9898
expect(miss).toBeNull()
9999
})
100100

101-
it('respects margin parameter for unfilled geometries (edge handles)', () => {
101+
it('hits the bounded edge handle hit area regardless of margin', () => {
102102
editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }])
103103
editor.select(ids.box1)
104-
// Point near the top edge but slightly outside
105-
const nearTop = { x: 50, y: -5 }
106-
// With zero margin, should miss
107-
expect(editor.overlays.getOverlayAtPoint(nearTop, 0)).toBeNull()
108-
// With margin, should hit the top edge overlay
109-
const hit = editor.overlays.getOverlayAtPoint(nearTop, 10)
104+
// Edge handles are filled polygons of half-thickness targetSizeY (~4.5px at zoom 1);
105+
// a point inside that band hits with margin 0, and a point outside misses.
106+
const insideTopEdge = { x: 50, y: -3 }
107+
const outsideTopEdge = { x: 50, y: -20 }
108+
const hit = editor.overlays.getOverlayAtPoint(insideTopEdge, 0)
110109
expect(hit?.id).toBe('selection_fg:top')
110+
expect(editor.overlays.getOverlayAtPoint(outsideTopEdge, 0)).toBeNull()
111111
})
112112

113113
it('returns first matching overlay when multiple overlap (corner)', () => {

0 commit comments

Comments
 (0)