@edv4h/usketch-plugin-tool-select 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -1,99 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { deltaToLocal, normalizeAngle, safeRotation, snapAngle, unrotatePoint, } from "@edv4h/usketch-shared";
3
- import { createBatchUpdateShapesCommand, createDeleteWithChildrenCommand, createMoveShapesCommand, createUpdateShapeCommand, getChildShapes, getTopLevelAncestor, } from "@edv4h/usketch-store";
2
+ import { safeRotation } from "@edv4h/usketch-shared";
3
+ import { createDeleteWithChildrenCommand } from "@edv4h/usketch-store";
4
+ import { findHandleAtScreenPoint, findMultiHandleAtScreenPoint, findRotationHandleAtScreenPoint, findShapeAtPoint, getCursorForHandle, getMultiSelectionBounds, getRotatedCursorForHandle, startDragSession, startMarqueeSession, startResizeSession, startRotateSession, trackHover, } from "@edv4h/usketch-tool-helpers";
4
5
  import { clearMovingSelectionListeners, setMovingSelection } from "./drag-state.js";
5
6
  import { clearDropTargetListeners, setDropTargetId } from "./drop-target-state.js";
6
7
  import { clearEditingGroupListeners, getEditingGroupId, setEditingGroupId, } from "./group-edit-state.js";
7
8
  import { clearHoveredShapeListeners, setHoveredShapeId } from "./hover-state.js";
8
9
  import { clearMarqueeListeners, setMarquee, setMarqueeMode } from "./marquee-state.js";
9
- import { applyFlip, computeMultiResizeUpdates, computeRawBounds, computeRelativeProps, findHandleAtScreenPoint, findMultiHandleAtScreenPoint, findRotationHandleAtScreenPoint, fixAnchorDrift, getAnchorEdges, getCursorForHandle, getMultiSelectionBounds, getRotatedCursorForHandle, } from "./resize-utils.js";
10
10
  import { SelectionOverlay } from "./selection-overlay.js";
11
- // ── Hit test helpers ──
12
- function findShapeAtPoint(ctx, point) {
13
- const shapes = ctx.store.getShapes();
14
- const editingGroupId = getEditingGroupId();
15
- // Iterate in reverse insertion order (top-most shape first)
16
- // but prioritize non-container shapes over containers (island/frame)
17
- const CONTAINER_TYPES = new Set(["island", "frame"]);
18
- const entries = [...shapes.entries()].reverse();
19
- // First pass: find non-container shapes
20
- let containerHit = null;
21
- for (const [id, data] of entries) {
22
- const def = ctx.shapes.get(data.type);
23
- if (!def?.hitTest(data, point))
24
- continue;
25
- // If inside a group editing session, only hit children of that group
26
- if (editingGroupId) {
27
- if (data.parentId === editingGroupId)
28
- return id;
29
- continue;
30
- }
31
- // If this shape has a parent, check the parent type:
32
- // - Frame/Island children are directly selectable (containers)
33
- // - Group children resolve to the top-level group ancestor
34
- if (typeof data.parentId === "string") {
35
- const parent = ctx.store.getShape(data.parentId);
36
- if (parent?.type === "frame" || parent?.type === "island") {
37
- return id;
38
- }
39
- const ancestor = getTopLevelAncestor(ctx.store, id);
40
- if (ancestor)
41
- return ancestor.id;
42
- continue;
43
- }
44
- // Defer containers — prefer their children first
45
- if (CONTAINER_TYPES.has(data.type)) {
46
- if (!containerHit)
47
- containerHit = id;
48
- continue;
49
- }
50
- return id;
51
- }
52
- // No non-container shape found, return the container itself
53
- return containerHit;
54
- }
55
- function findShapesInRect(ctx, rect, mode) {
56
- const test = mode === "contain" ? boxContains : boxesIntersect;
57
- const shapes = ctx.store.getShapes();
58
- const editingGroupId = getEditingGroupId();
59
- const ids = new Set();
60
- for (const [id, data] of shapes) {
61
- const def = ctx.shapes.get(data.type);
62
- const bounds = def
63
- ? def.getBounds(data)
64
- : { x: data.x, y: data.y, width: data.width, height: data.height };
65
- if (!test(rect, bounds))
66
- continue;
67
- // If in group edit mode, only select children of that group
68
- if (editingGroupId) {
69
- if (data.parentId === editingGroupId)
70
- ids.add(id);
71
- continue;
72
- }
73
- // Frame children are directly selectable; group children resolve to ancestor
74
- if (typeof data.parentId === "string") {
75
- const parent = ctx.store.getShape(data.parentId);
76
- if (parent?.type === "frame") {
77
- ids.add(id);
78
- }
79
- else {
80
- const ancestor = getTopLevelAncestor(ctx.store, id);
81
- if (ancestor)
82
- ids.add(ancestor.id);
83
- }
84
- continue;
85
- }
86
- ids.add(id);
87
- }
88
- return [...ids];
89
- }
90
- function boxesIntersect(a, b) {
91
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
92
- }
93
- /** Returns true if `a` fully contains `b` */
94
- function boxContains(a, b) {
95
- return (a.x <= b.x && a.y <= b.y && a.x + a.width >= b.x + b.width && a.y + a.height >= b.y + b.height);
96
- }
11
+ // Hit-test helpers used to live here; they were extracted to
12
+ // `@edv4h/usketch-tool-helpers` (Issue #576) so other tools can reuse
13
+ // the same shape-vs-container precedence rules.
97
14
  // ── Icon ──
98
15
  function SelectIcon() {
99
16
  return (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", children: _jsx("path", { d: "M6 2L6 16L10 12L14 16L16 14L12 10L16 6Z", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round" }) }));
@@ -147,7 +64,7 @@ export const selectToolPlugin = {
147
64
  function onPointerDown(toolCtx, event) {
148
65
  disableTextSelection();
149
66
  const viewport = toolCtx.store.getViewport();
150
- // 0. Check rotation handle hit first (outside bounding box, no conflict with resize)
67
+ // 0. Rotation handle (outside bbox, no conflict with resize)
151
68
  const rotationHit = findRotationHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
152
69
  if (rotationHit) {
153
70
  const shape = toolCtx.store.getShape(rotationHit);
@@ -155,27 +72,21 @@ export const selectToolPlugin = {
155
72
  const cx = shape.x + shape.width / 2;
156
73
  const cy = shape.y + shape.height / 2;
157
74
  const startAngle = Math.atan2(event.worldPoint.y - cy, event.worldPoint.x - cx) * (180 / Math.PI);
158
- // Collect child snapshots for container rotation
159
- const childSnapshots = new Map();
160
- if (shape.type === "frame" || shape.type === "group" || shape.type === "island") {
161
- const children = getChildShapes(toolCtx.store, rotationHit);
162
- for (const child of children) {
163
- childSnapshots.set(child.id, { ...child });
164
- }
165
- }
166
75
  setOverrideCursor("grabbing");
167
76
  dragState = {
168
- mode: "rotate",
169
- shapeId: rotationHit,
170
- startAngle,
171
- startRotation: safeRotation(shape.rotation),
172
- center: { x: cx, y: cy },
173
- childSnapshots,
77
+ kind: "rotate",
78
+ session: startRotateSession({
79
+ ctx: toolCtx,
80
+ shapeId: rotationHit,
81
+ center: { x: cx, y: cy },
82
+ startAngle,
83
+ startRotation: safeRotation(shape.rotation),
84
+ }),
174
85
  };
175
86
  return;
176
87
  }
177
88
  }
178
- // 1. Check resize handle hit first (single selection only)
89
+ // 1. Single-shape resize handle
179
90
  const handleHit = findHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
180
91
  if (handleHit) {
181
92
  const shape = toolCtx.store.getShape(handleHit.shapeId);
@@ -185,16 +96,20 @@ export const selectToolPlugin = {
185
96
  ? getRotatedCursorForHandle(handleHit.handle, shapeRotation)
186
97
  : getCursorForHandle(handleHit.handle));
187
98
  dragState = {
188
- mode: "resize",
189
- shapeId: handleHit.shapeId,
190
- handle: handleHit.handle,
191
- startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
192
- startData: { ...shape },
99
+ kind: "resize",
100
+ rotation: shapeRotation,
101
+ session: startResizeSession({
102
+ kind: "single",
103
+ ctx: toolCtx,
104
+ shapeId: handleHit.shapeId,
105
+ handle: handleHit.handle,
106
+ startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
107
+ }),
193
108
  };
194
109
  return;
195
110
  }
196
111
  }
197
- // 1b. Check multi-selection resize handle hit
112
+ // 1b. Multi-selection resize handle
198
113
  const selection = toolCtx.store.getSelection();
199
114
  if (selection.size > 1) {
200
115
  const groupBounds = getMultiSelectionBounds(toolCtx.store, toolCtx.shapes, selection);
@@ -202,35 +117,16 @@ export const selectToolPlugin = {
202
117
  const multiHandle = findMultiHandleAtScreenPoint(event.screenPoint, groupBounds, viewport);
203
118
  if (multiHandle) {
204
119
  setOverrideCursor(getCursorForHandle(multiHandle));
205
- const startShapeData = new Map();
206
- const originalFullShapes = new Map();
207
- for (const id of selection) {
208
- const shape = toolCtx.store.getShape(id);
209
- if (shape) {
210
- originalFullShapes.set(id, { ...shape });
211
- const def = toolCtx.shapes.get(shape.type);
212
- const minSize = def?.minSize ?? { width: 1, height: 1 };
213
- const rel = computeRelativeProps({ x: shape.x, y: shape.y, width: shape.width, height: shape.height }, groupBounds);
214
- startShapeData.set(id, {
215
- x: shape.x,
216
- y: shape.y,
217
- width: shape.width,
218
- height: shape.height,
219
- minWidth: minSize.width,
220
- minHeight: minSize.height,
221
- ...rel,
222
- });
223
- }
224
- }
225
120
  dragState = {
226
- mode: "multi-resize",
227
- handle: multiHandle,
228
- startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
229
- startGroupBounds: groupBounds,
230
- startShapeData,
231
- originalShapeData: new Map(startShapeData),
232
- originalFullShapes,
233
- applyBoundsBase: new Map(originalFullShapes),
121
+ kind: "multi-resize",
122
+ session: startResizeSession({
123
+ kind: "multi",
124
+ ctx: toolCtx,
125
+ selection,
126
+ handle: multiHandle,
127
+ startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
128
+ groupBounds,
129
+ }),
234
130
  };
235
131
  return;
236
132
  }
@@ -238,11 +134,12 @@ export const selectToolPlugin = {
238
134
  }
239
135
  // 2. Double-click detection for group enter/exit
240
136
  const now = Date.now();
241
- const rawHitId = findShapeAtPoint(toolCtx, event.worldPoint);
137
+ const rawHitId = findShapeAtPoint(toolCtx, event.worldPoint, {
138
+ editingGroupId: getEditingGroupId(),
139
+ });
242
140
  if (rawHitId && now - lastClickTime < 400 && lastClickId === rawHitId) {
243
141
  const shape = toolCtx.store.getShape(rawHitId);
244
142
  if (shape?.type === "group" || shape?.type === "frame") {
245
- // Enter group editing mode
246
143
  setEditingGroupId(rawHitId);
247
144
  toolCtx.store.clearSelection();
248
145
  lastClickTime = 0;
@@ -263,7 +160,6 @@ export const selectToolPlugin = {
263
160
  const hitId = rawHitId;
264
161
  if (hitId) {
265
162
  if (event.shiftKey) {
266
- // Toggle shape in selection
267
163
  if (selection.has(hitId)) {
268
164
  toolCtx.store.removeFromSelection(hitId);
269
165
  }
@@ -271,45 +167,24 @@ export const selectToolPlugin = {
271
167
  toolCtx.store.addToSelection(hitId);
272
168
  }
273
169
  }
274
- else {
275
- // If clicking on an already-selected shape, keep selection (for multi-drag)
276
- if (!selection.has(hitId)) {
277
- toolCtx.store.setSelection([hitId]);
278
- }
170
+ else if (!selection.has(hitId)) {
171
+ toolCtx.store.setSelection([hitId]);
279
172
  }
280
- // Start drag-move for all selected shapes (including children of groups/frames)
281
173
  const currentSelection = toolCtx.store.getSelection();
282
- const startShapeSnapshots = new Map();
283
- for (const id of currentSelection) {
284
- const shape = toolCtx.store.getShape(id);
285
- if (shape) {
286
- startShapeSnapshots.set(id, { ...shape });
287
- // Include all descendants of groups/frames/islands so they move together
288
- if (shape.type === "group" || shape.type === "frame" || shape.type === "island") {
289
- const queue = [id];
290
- while (queue.length > 0) {
291
- const parentId = queue.pop();
292
- const children = getChildShapes(toolCtx.store, parentId);
293
- for (const child of children) {
294
- if (!startShapeSnapshots.has(child.id)) {
295
- startShapeSnapshots.set(child.id, { ...child });
296
- // Recurse into nested containers
297
- if (child.type === "group" ||
298
- child.type === "frame" ||
299
- child.type === "island") {
300
- queue.push(child.id);
301
- }
302
- }
303
- }
304
- }
305
- }
306
- }
307
- }
308
174
  setMovingSelection(true);
309
- dragState = {
310
- mode: "move",
175
+ const session = startDragSession({
176
+ ctx: toolCtx,
311
177
  startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
312
- startShapeSnapshots,
178
+ shapeIds: currentSelection,
179
+ });
180
+ // Drop-target hit test must exclude every shape the session
181
+ // touches — including descendants of dragged containers — or
182
+ // the container's own children would falsely register as drop
183
+ // targets.
184
+ dragState = {
185
+ kind: "move",
186
+ session,
187
+ movingIds: session.movingShapeIds,
313
188
  };
314
189
  }
315
190
  else {
@@ -318,338 +193,55 @@ export const selectToolPlugin = {
318
193
  toolCtx.store.clearSelection();
319
194
  }
320
195
  dragState = {
321
- mode: "marquee",
322
- startWorldPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
196
+ kind: "marquee",
323
197
  shiftKey: event.shiftKey,
198
+ session: startMarqueeSession({
199
+ ctx: toolCtx,
200
+ startWorldPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
201
+ editingGroupId: getEditingGroupId(),
202
+ }),
324
203
  };
325
204
  }
326
205
  }
327
206
  function onPointerMove(toolCtx, event) {
328
207
  if (!dragState) {
329
- // Hover: check for rotation handle first
330
- const viewport = toolCtx.store.getViewport();
331
- const rotHover = findRotationHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
332
- if (rotHover) {
333
- setOverrideCursor("grab");
334
- return;
335
- }
336
- // Hover: check for resize handle and update cursor
337
- const handleHit = findHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
338
- if (handleHit) {
339
- const hoverShape = toolCtx.store.getShape(handleHit.shapeId);
340
- const hoverRotation = safeRotation(hoverShape?.rotation);
341
- setOverrideCursor(hoverRotation
342
- ? getRotatedCursorForHandle(handleHit.handle, hoverRotation)
343
- : getCursorForHandle(handleHit.handle));
344
- return;
345
- }
346
- // Check multi-selection handles
347
- const hoverSelection = toolCtx.store.getSelection();
348
- if (hoverSelection.size > 1) {
349
- const groupBounds = getMultiSelectionBounds(toolCtx.store, toolCtx.shapes, hoverSelection);
350
- if (groupBounds) {
351
- const multiHandle = findMultiHandleAtScreenPoint(event.screenPoint, groupBounds, viewport);
352
- if (multiHandle) {
353
- setOverrideCursor(getCursorForHandle(multiHandle));
354
- return;
355
- }
356
- }
357
- }
358
- setOverrideCursor("");
359
- // Hover indicator: detect shape under cursor
360
- const hoverHitId = findShapeAtPoint(toolCtx, event.worldPoint);
361
- setHoveredShapeId(hoverHitId);
208
+ // Hover precedence (rotation handle → resize handle → multi-handle
209
+ // → shape body) is handled by the helper. The cursor is mirrored
210
+ // onto our `<style>` injection so it overrides any per-shape cursor.
211
+ const result = trackHover(toolCtx, event, {
212
+ editingGroupId: getEditingGroupId(),
213
+ });
214
+ setOverrideCursor(result.cursor);
215
+ setHoveredShapeId(result.handleHit || result.rotationHit ? null : result.hoveredShapeId);
362
216
  return;
363
217
  }
364
- if (dragState.mode === "rotate") {
365
- const currentAngle = Math.atan2(event.worldPoint.y - dragState.center.y, event.worldPoint.x - dragState.center.x) *
366
- (180 / Math.PI);
367
- // Wrap angle diff to [-180, 180] to avoid ±360° jumps at atan2 boundary
368
- let angleDiff = currentAngle - dragState.startAngle;
369
- angleDiff = ((angleDiff + 540) % 360) - 180;
370
- let newRotation = dragState.startRotation + angleDiff;
371
- if (event.shiftKey) {
372
- newRotation = snapAngle(newRotation, 15);
373
- }
374
- newRotation = normalizeAngle(newRotation);
375
- toolCtx.store.updateShape(dragState.shapeId, { rotation: newRotation });
376
- // Rotate child shapes around the container center
377
- const deltaRad = ((newRotation - dragState.startRotation) * Math.PI) / 180;
378
- const { center } = dragState;
379
- for (const [childId, snap] of dragState.childSnapshots) {
380
- const childCx = snap.x + snap.width / 2;
381
- const childCy = snap.y + snap.height / 2;
382
- const rx = childCx - center.x;
383
- const ry = childCy - center.y;
384
- const cos = Math.cos(deltaRad);
385
- const sin = Math.sin(deltaRad);
386
- const newCx = center.x + rx * cos - ry * sin;
387
- const newCy = center.y + rx * sin + ry * cos;
388
- const childRotation = normalizeAngle(safeRotation(snap.rotation) + newRotation - dragState.startRotation);
389
- toolCtx.store.updateShape(childId, {
390
- x: newCx - snap.width / 2,
391
- y: newCy - snap.height / 2,
392
- rotation: childRotation,
393
- });
394
- }
218
+ if (dragState.kind === "rotate") {
219
+ dragState.session.update(event);
395
220
  return;
396
221
  }
397
- if (dragState.mode === "marquee") {
398
- // Alt key toggles between intersect and contain mode
399
- const mode = event.altKey ? "contain" : "intersect";
400
- setMarqueeMode(mode);
401
- // Compute world-space marquee (used for both hit testing and rendering)
402
- const wx = Math.min(dragState.startWorldPoint.x, event.worldPoint.x);
403
- const wy = Math.min(dragState.startWorldPoint.y, event.worldPoint.y);
404
- const ww = Math.abs(event.worldPoint.x - dragState.startWorldPoint.x);
405
- const wh = Math.abs(event.worldPoint.y - dragState.startWorldPoint.y);
406
- const worldRect = { x: wx, y: wy, width: ww, height: wh };
407
- const hitIds = findShapesInRect(toolCtx, worldRect, mode);
408
- setMarquee(worldRect, hitIds);
222
+ if (dragState.kind === "marquee") {
223
+ const u = dragState.session.update(event);
224
+ setMarqueeMode(u.mode);
225
+ setMarquee(u.rect, [...u.hitIds]);
409
226
  return;
410
227
  }
411
- if (dragState.mode === "resize") {
412
- const def = toolCtx.shapes.get(dragState.startData.type);
413
- if (!def)
414
- return;
415
- const rotation = safeRotation(dragState.startData.rotation);
416
- const worldDelta = {
417
- x: event.worldPoint.x - dragState.startPoint.x,
418
- y: event.worldPoint.y - dragState.startPoint.y,
419
- };
420
- // Transform delta to local (un-rotated) space for rotated shapes
421
- const delta = rotation ? deltaToLocal(worldDelta, rotation) : worldDelta;
422
- // Flip detection: use unclamped (raw) bounds so minSize doesn't
423
- // prevent the pointer from crossing the anchor edge.
424
- const rawBounds = computeRawBounds(dragState.startData, dragState.handle, delta);
425
- // Un-rotate the world point for flip detection in local space
426
- let worldPointForFlip = event.worldPoint;
427
- if (rotation) {
428
- // Use rawBounds center (not startData) since resize changes x/y/width/height
429
- const center = {
430
- x: rawBounds.x + rawBounds.width / 2,
431
- y: rawBounds.y + rawBounds.height / 2,
432
- };
433
- worldPointForFlip = unrotatePoint(event.worldPoint, center, (rotation * Math.PI) / 180);
434
- }
435
- const flip = applyFlip(dragState.handle, rawBounds, worldPointForFlip);
436
- if (flip.flipped) {
437
- const currentShape = toolCtx.store.getShape(dragState.shapeId);
438
- if (currentShape) {
439
- const anchor = getAnchorEdges(dragState.handle, rawBounds);
440
- // Reset to zero-size shape at anchor position.
441
- // This ensures the flipped handle's anchor is at the
442
- // pointer's crossing point, preventing immediate re-flip.
443
- const flippedData = { ...currentShape };
444
- if (flip.flippedX && anchor.x !== undefined) {
445
- flippedData.x = anchor.x;
446
- flippedData.width = 0;
447
- }
448
- if (flip.flippedY && anchor.y !== undefined) {
449
- flippedData.y = anchor.y;
450
- flippedData.height = 0;
451
- }
452
- dragState = {
453
- ...dragState,
454
- handle: flip.handle,
455
- startData: flippedData,
456
- startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
457
- };
458
- // Update store — def.resize will expand from zero on next frame
459
- toolCtx.store.updateShape(dragState.shapeId, {
460
- x: flippedData.x,
461
- y: flippedData.y,
462
- });
463
- setOverrideCursor(rotation
464
- ? getRotatedCursorForHandle(flip.handle, rotation)
465
- : getCursorForHandle(flip.handle));
466
- }
467
- // Skip resize this frame — next frame will use the new
468
- // startData/startPoint with the flipped handle.
469
- return;
470
- }
471
- const resized = def.resize(dragState.startData, dragState.handle, delta);
472
- // Fix anchor drift from minSize clamping
473
- const fixed = fixAnchorDrift(dragState.handle, dragState.startData, resized);
474
- resized.x = fixed.x;
475
- resized.y = fixed.y;
476
- const updates = {};
477
- for (const key of Object.keys(resized)) {
478
- if (key === "id" || key === "type" || key === "style")
479
- continue;
480
- const resizedValue = resized[key];
481
- const startValue = dragState.startData[key];
482
- if (resizedValue !== startValue) {
483
- updates[key] = resizedValue;
484
- }
485
- }
486
- if (Object.keys(updates).length > 0) {
487
- toolCtx.store.updateShape(dragState.shapeId, updates);
228
+ if (dragState.kind === "resize" || dragState.kind === "multi-resize") {
229
+ const u = dragState.session.update(event);
230
+ if (u.flippedHandle) {
231
+ setOverrideCursor(dragState.kind === "resize" && dragState.rotation
232
+ ? getRotatedCursorForHandle(u.flippedHandle, dragState.rotation)
233
+ : getCursorForHandle(u.flippedHandle));
488
234
  }
489
235
  return;
490
236
  }
491
- if (dragState.mode === "multi-resize") {
492
- const delta = {
493
- x: event.worldPoint.x - dragState.startPoint.x,
494
- y: event.worldPoint.y - dragState.startPoint.y,
495
- };
496
- // Flip detection on the group bounding box
497
- const rawGroupBounds = computeRawBounds(dragState.startGroupBounds, dragState.handle, delta);
498
- const flip = applyFlip(dragState.handle, rawGroupBounds, event.worldPoint);
499
- if (flip.flipped) {
500
- const anchor = getAnchorEdges(dragState.handle, rawGroupBounds);
501
- // Reset group bounds to zero-size at anchor position (same approach as single resize)
502
- const flippedGroupBounds = { ...dragState.startGroupBounds };
503
- if (flip.flippedX && anchor.x !== undefined) {
504
- flippedGroupBounds.x = anchor.x;
505
- flippedGroupBounds.width = 0;
506
- }
507
- if (flip.flippedY && anchor.y !== undefined) {
508
- flippedGroupBounds.y = anchor.y;
509
- flippedGroupBounds.height = 0;
510
- }
511
- // Mirror relative ratios and reset only the flipped axis
512
- const flippedShapeData = new Map();
513
- for (const [id, data] of dragState.startShapeData) {
514
- const newRel = { ...data };
515
- if (flip.flippedX) {
516
- newRel.relX = 1 - data.relX - data.relWidth;
517
- newRel.x = flippedGroupBounds.x;
518
- newRel.width = 0;
519
- }
520
- if (flip.flippedY) {
521
- newRel.relY = 1 - data.relY - data.relHeight;
522
- newRel.y = flippedGroupBounds.y;
523
- newRel.height = 0;
524
- }
525
- flippedShapeData.set(id, newRel);
526
- }
527
- // Temporarily disable snap during flip-reset to prevent
528
- // snap from altering the zero-size anchor positioning
529
- toolCtx.events.emit("snap:configure", { enabled: false });
530
- for (const [id, data] of flippedShapeData) {
531
- const update = {};
532
- if (flip.flippedX) {
533
- update.x = data.x;
534
- update.width = 0;
535
- }
536
- if (flip.flippedY) {
537
- update.y = data.y;
538
- update.height = 0;
539
- }
540
- toolCtx.store.updateShape(id, update);
541
- }
542
- toolCtx.events.emit("snap:configure", { enabled: true });
543
- // Snapshot current shapes as new base for applyBounds
544
- const flippedApplyBoundsBase = new Map();
545
- for (const id of flippedShapeData.keys()) {
546
- const current = toolCtx.store.getShape(id);
547
- if (current)
548
- flippedApplyBoundsBase.set(id, { ...current });
549
- }
550
- dragState = {
551
- ...dragState,
552
- handle: flip.handle,
553
- startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
554
- startGroupBounds: flippedGroupBounds,
555
- startShapeData: flippedShapeData,
556
- applyBoundsBase: flippedApplyBoundsBase,
557
- // originalShapeData/originalFullShapes preserved for undo
558
- };
559
- setOverrideCursor(getCursorForHandle(flip.handle));
560
- return;
561
- }
562
- const multiUpdates = computeMultiResizeUpdates(dragState.handle, dragState.startGroupBounds, delta, dragState.startShapeData);
563
- for (const [id, upd] of multiUpdates) {
564
- const baseShape = dragState.applyBoundsBase.get(id);
565
- const def = baseShape ? toolCtx.shapes.get(baseShape.type) : undefined;
566
- if (def?.applyBounds && baseShape) {
567
- // Pass {x,y,width,height} first so snap plugin can adjust
568
- toolCtx.store.updateShape(id, upd);
569
- // Read back snapped bounds and derive geometry
570
- const snapped = toolCtx.store.getShape(id);
571
- if (snapped) {
572
- const snappedBounds = {
573
- x: snapped.x,
574
- y: snapped.y,
575
- width: snapped.width,
576
- height: snapped.height,
577
- };
578
- const geom = def.applyBounds(baseShape, snappedBounds);
579
- const { x: _x, y: _y, width: _w, height: _h, ...rest } = geom;
580
- if (Object.keys(rest).length > 0) {
581
- toolCtx.store.updateShape(id, rest);
582
- }
583
- }
584
- }
585
- else {
586
- toolCtx.store.updateShape(id, upd);
587
- }
588
- }
589
- return;
590
- }
591
- // mode === "move"
592
- const dx = event.worldPoint.x - dragState.startPoint.x;
593
- const dy = event.worldPoint.y - dragState.startPoint.y;
594
- const movingIds = new Set(dragState.startShapeSnapshots.keys());
595
- const currentSelection = toolCtx.store.getSelection();
596
- // Phase 1: Update selected (top-level) shapes first so snap adjusts them.
597
- // Track the snap delta so children can follow the same offset.
598
- let snapDx = 0;
599
- let snapDy = 0;
600
- for (const [id, snapshot] of dragState.startShapeSnapshots) {
601
- if (!currentSelection.has(id))
602
- continue;
603
- toolCtx.store.updateShape(id, {
604
- x: snapshot.x + dx,
605
- y: snapshot.y + dy,
606
- });
607
- const snapped = toolCtx.store.getShape(id);
608
- if (snapped) {
609
- snapDx = snapped.x - (snapshot.x + dx);
610
- snapDy = snapped.y - (snapshot.y + dy);
611
- }
612
- const def = toolCtx.shapes.get(snapshot.type);
613
- if (def?.move) {
614
- if (snapped) {
615
- const snappedDx = snapped.x - snapshot.x;
616
- const snappedDy = snapped.y - snapshot.y;
617
- const geom = def.move(snapshot, snappedDx, snappedDy);
618
- const { x: _x, y: _y, ...rest } = geom;
619
- if (Object.keys(rest).length > 0) {
620
- toolCtx.store.updateShape(id, rest);
621
- }
622
- }
623
- }
624
- }
625
- // Phase 2: Update child shapes with the same snap delta as their parent.
626
- for (const [id, snapshot] of dragState.startShapeSnapshots) {
627
- if (currentSelection.has(id))
628
- continue;
629
- toolCtx.store.updateShape(id, {
630
- x: snapshot.x + dx + snapDx,
631
- y: snapshot.y + dy + snapDy,
632
- });
633
- const def = toolCtx.shapes.get(snapshot.type);
634
- if (def?.move) {
635
- const snapped = toolCtx.store.getShape(id);
636
- if (snapped) {
637
- const snappedDx = snapped.x - snapshot.x;
638
- const snappedDy = snapped.y - snapshot.y;
639
- const geom = def.move(snapshot, snappedDx, snappedDy);
640
- const { x: _x, y: _y, ...rest } = geom;
641
- if (Object.keys(rest).length > 0) {
642
- toolCtx.store.updateShape(id, rest);
643
- }
644
- }
645
- }
646
- }
647
- // Drop target: find frame/group under the cursor (excluding dragged shapes)
237
+ // kind === "move"
238
+ dragState.session.update(event);
239
+ // Drop target: find frame/group under the cursor (excluding dragged shapes).
648
240
  const allShapes = toolCtx.store.getShapes();
649
241
  let dropTarget = null;
650
242
  const entries = [...allShapes.entries()].reverse();
651
243
  for (const [id, shape] of entries) {
652
- if (movingIds.has(id))
244
+ if (dragState.movingIds.has(id))
653
245
  continue;
654
246
  if (shape.type !== "frame" && shape.type !== "group")
655
247
  continue;
@@ -672,180 +264,47 @@ export const selectToolPlugin = {
672
264
  updateDropTarget(null);
673
265
  if (!dragState)
674
266
  return;
675
- if (dragState.mode === "rotate") {
267
+ if (dragState.kind === "rotate") {
676
268
  setOverrideCursor("");
677
- const currentShape = toolCtx.store.getShape(dragState.shapeId);
678
- if (currentShape) {
679
- const parentFrom = { rotation: dragState.startRotation };
680
- const parentTo = { rotation: safeRotation(currentShape.rotation) };
681
- const hasMoved = parentFrom.rotation !== parentTo.rotation;
682
- if (hasMoved) {
683
- // Build before/after snapshots for parent + children
684
- const beforeSnapshots = new Map();
685
- const afterSnapshots = new Map();
686
- const shapeId = dragState.shapeId;
687
- beforeSnapshots.set(shapeId, { rotation: parentFrom.rotation });
688
- afterSnapshots.set(shapeId, { rotation: parentTo.rotation });
689
- for (const [childId, snap] of dragState.childSnapshots) {
690
- const current = toolCtx.store.getShape(childId);
691
- if (current) {
692
- beforeSnapshots.set(childId, {
693
- x: snap.x,
694
- y: snap.y,
695
- rotation: safeRotation(snap.rotation),
696
- });
697
- afterSnapshots.set(childId, {
698
- x: current.x,
699
- y: current.y,
700
- rotation: safeRotation(current.rotation),
701
- });
702
- }
703
- }
704
- queueMicrotask(() => {
705
- // Revert to before state
706
- for (const [id, props] of beforeSnapshots) {
707
- toolCtx.store.updateShape(id, props);
708
- }
709
- // Execute undoable command
710
- toolCtx.commands.execute({
711
- execute: () => {
712
- for (const [id, props] of afterSnapshots) {
713
- toolCtx.store.updateShape(id, props);
714
- }
715
- },
716
- undo: () => {
717
- for (const [id, props] of beforeSnapshots) {
718
- toolCtx.store.updateShape(id, props);
719
- }
720
- },
721
- });
722
- });
723
- }
269
+ const result = dragState.session.commit();
270
+ if (result) {
271
+ queueMicrotask(() => toolCtx.commands.execute(result.command));
724
272
  }
725
273
  dragState = null;
726
274
  return;
727
275
  }
728
- if (dragState.mode === "marquee") {
276
+ if (dragState.kind === "marquee") {
729
277
  setMarquee(null);
730
- // Calculate marquee rect in world coordinates
731
- const wx1 = _event.worldPoint.x;
732
- const wy1 = _event.worldPoint.y;
733
- const sx = dragState.startWorldPoint.x;
734
- const sy = dragState.startWorldPoint.y;
735
- const mx = Math.min(sx, wx1);
736
- const my = Math.min(sy, wy1);
737
- const mw = Math.abs(wx1 - sx);
738
- const mh = Math.abs(wy1 - sy);
739
- // Skip tiny marquees (accidental clicks)
740
- if (mw < 2 && mh < 2) {
741
- dragState = null;
742
- return;
743
- }
744
- const mode = _event.altKey ? "contain" : "intersect";
745
- const marqueeBox = { x: mx, y: my, width: mw, height: mh };
746
- const hitIds = findShapesInRect(toolCtx, marqueeBox, mode);
747
- if (dragState.shiftKey) {
748
- for (const id of hitIds) {
749
- toolCtx.store.addToSelection(id);
278
+ const result = dragState.session.commit();
279
+ if (result) {
280
+ if (dragState.shiftKey) {
281
+ for (const id of result.selection)
282
+ toolCtx.store.addToSelection(id);
750
283
  }
751
- }
752
- else {
753
- toolCtx.store.setSelection(hitIds);
754
- }
755
- dragState = null;
756
- return;
757
- }
758
- if (dragState.mode === "resize") {
759
- setOverrideCursor("");
760
- const currentShape = toolCtx.store.getShape(dragState.shapeId);
761
- if (currentShape) {
762
- // Build from/to diffs for undo
763
- const from = {};
764
- const to = {};
765
- for (const key of Object.keys(currentShape)) {
766
- if (key === "id" || key === "type" || key === "style")
767
- continue;
768
- const currentValue = currentShape[key];
769
- const startValue = dragState.startData[key];
770
- if (currentValue !== startValue) {
771
- from[key] = startValue;
772
- to[key] = currentValue;
773
- }
774
- }
775
- if (Object.keys(to).length > 0) {
776
- // Defer reset+execute to after canvas:pointerup disables snap
777
- const shapeId = dragState.shapeId;
778
- queueMicrotask(() => {
779
- toolCtx.store.updateShape(shapeId, from);
780
- toolCtx.commands.execute(createUpdateShapeCommand(toolCtx.store, shapeId, from, to));
781
- });
284
+ else {
285
+ toolCtx.store.setSelection([...result.selection]);
782
286
  }
783
287
  }
784
288
  dragState = null;
785
289
  return;
786
290
  }
787
- if (dragState.mode === "multi-resize") {
291
+ if (dragState.kind === "resize" || dragState.kind === "multi-resize") {
788
292
  setOverrideCursor("");
789
- const batchUpdates = [];
790
- for (const [id, origFullShape] of dragState.originalFullShapes) {
791
- const currentShape = toolCtx.store.getShape(id);
792
- if (!currentShape)
793
- continue;
794
- const from = {};
795
- const to = {};
796
- for (const key of Object.keys(currentShape)) {
797
- if (key === "id" || key === "type" || key === "style")
798
- continue;
799
- const currentValue = currentShape[key];
800
- const origValue = origFullShape[key];
801
- if (currentValue !== origValue) {
802
- from[key] = origValue;
803
- to[key] = currentValue;
804
- }
805
- }
806
- if (Object.keys(to).length > 0) {
807
- batchUpdates.push({ id, from, to });
808
- }
809
- }
810
- if (batchUpdates.length > 0) {
811
- // Defer reset+execute to after canvas:pointerup disables snap
812
- const storeRef = toolCtx.store;
813
- queueMicrotask(() => {
814
- for (const { id, from } of batchUpdates) {
815
- storeRef.updateShape(id, from);
816
- }
817
- toolCtx.commands.execute(createBatchUpdateShapesCommand(storeRef, batchUpdates));
818
- });
293
+ const result = dragState.session.commit();
294
+ if (result) {
295
+ queueMicrotask(() => toolCtx.commands.execute(result.command));
819
296
  }
820
297
  dragState = null;
821
298
  return;
822
299
  }
823
- // mode === "move"
300
+ // kind === "move"
824
301
  setMovingSelection(false);
825
- // Build before/after snapshots for undoable command
826
- const beforeSnapshots = dragState.startShapeSnapshots;
827
- const afterSnapshots = new Map();
828
- let hasMoved = false;
829
- for (const [id, before] of beforeSnapshots) {
830
- const current = toolCtx.store.getShape(id);
831
- if (current) {
832
- afterSnapshots.set(id, { ...current });
833
- if (Math.abs(current.x - before.x) > 0.5 || Math.abs(current.y - before.y) > 0.5) {
834
- hasMoved = true;
835
- }
836
- }
837
- }
838
- if (hasMoved) {
839
- // Collect the user-selected shapes (not their implicitly-moved children)
840
- const userSelectedIds = [...toolCtx.store.getSelection()];
841
- // Defer reset+execute to after canvas:pointerup disables snap,
842
- // then notify plugins for auto-parenting
302
+ const userSelectedIds = [...toolCtx.store.getSelection()];
303
+ const result = dragState.session.commit();
304
+ if (result) {
843
305
  queueMicrotask(() => {
844
- for (const [id, before] of beforeSnapshots) {
845
- toolCtx.store.updateShape(id, before);
846
- }
847
- toolCtx.commands.execute(createMoveShapesCommand(toolCtx.store, beforeSnapshots, afterSnapshots));
848
- // Notify after the move command is committed so reparent won't be undone
306
+ toolCtx.commands.execute(result.command);
307
+ // Notify after the move command is committed so reparent won't be undone.
849
308
  toolCtx.events.emit("shapes:move-end", { shapeIds: userSelectedIds });
850
309
  });
851
310
  }
@@ -871,9 +330,12 @@ export const selectToolPlugin = {
871
330
  onPointerUp,
872
331
  onDeactivate,
873
332
  });
874
- // ── Selection overlay layer ──
875
- ctx.layers.register({
876
- id: "selection-overlay",
333
+ // ── Selection foreground (default) ──
334
+ // Registered at priority 0 so any app option or third-party plugin
335
+ // (recommended: 50) replaces it. See guides/selection-foreground.
336
+ const unregisterSelectionForeground = ctx.ui.registerSelectionForeground({
337
+ id: "tool-select-default",
338
+ priority: 0,
877
339
  order: 80,
878
340
  fixed: true,
879
341
  render: (renderCtx) => (_jsx(SelectionOverlay, { store: ctx.store, shapes: ctx.shapes, viewport: renderCtx.viewport })),
@@ -895,7 +357,7 @@ export const selectToolPlugin = {
895
357
  clearHoveredShapeListeners();
896
358
  clearDropTargetListeners();
897
359
  styleEl.remove();
898
- ctx.layers.unregister("selection-overlay");
360
+ unregisterSelectionForeground();
899
361
  };
900
362
  },
901
363
  };