@mapples/dnd 1.1.2 → 1.2.1

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.
Files changed (37) hide show
  1. package/dist/components/DnDRootView.d.ts +1 -1
  2. package/dist/components/DnDRootView.d.ts.map +1 -1
  3. package/dist/components/DnDRootView.js +86 -13
  4. package/dist/components/DnDRootView.js.map +1 -1
  5. package/dist/components/DropArea.d.ts.map +1 -1
  6. package/dist/components/DropArea.js +6 -3
  7. package/dist/components/DropArea.js.map +1 -1
  8. package/dist/context/DropContext.d.ts +8 -0
  9. package/dist/context/DropContext.d.ts.map +1 -1
  10. package/dist/context/DropContext.js +8 -0
  11. package/dist/context/DropContext.js.map +1 -1
  12. package/dist/context/FrameSpaceContext.d.ts +27 -0
  13. package/dist/context/FrameSpaceContext.d.ts.map +1 -0
  14. package/dist/context/FrameSpaceContext.js +15 -0
  15. package/dist/context/FrameSpaceContext.js.map +1 -0
  16. package/dist/context/index.d.ts +2 -0
  17. package/dist/context/index.d.ts.map +1 -1
  18. package/dist/context/index.js +2 -0
  19. package/dist/context/index.js.map +1 -1
  20. package/dist/hooks/useDrag.d.ts.map +1 -1
  21. package/dist/hooks/useDrag.js +12 -0
  22. package/dist/hooks/useDrag.js.map +1 -1
  23. package/dist/hooks/useDrop.d.ts.map +1 -1
  24. package/dist/hooks/useDrop.js +74 -9
  25. package/dist/hooks/useDrop.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/monitor/Monitor.d.ts +107 -22
  31. package/dist/monitor/Monitor.d.ts.map +1 -1
  32. package/dist/monitor/Monitor.js +378 -111
  33. package/dist/monitor/Monitor.js.map +1 -1
  34. package/dist/types/index.d.ts +86 -0
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/index.js.map +1 -1
  37. package/package.json +1 -1
@@ -22,8 +22,25 @@ export class Monitor {
22
22
  dragElements = new Map();
23
23
  /** Tree structure of registered drop elements */
24
24
  dropElementsTree = new Map();
25
+ /**
26
+ * Flat index of every registered drop element and its parent id. Kept in
27
+ * lockstep with the tree by register/unregister/update — the only writers —
28
+ * so id lookups and frame-key resolution are O(1)/O(depth) instead of a
29
+ * full recursive tree walk per layout event.
30
+ */
31
+ dropNodesById = new Map();
32
+ dropParentById = new Map();
33
+ /** Pending coalesced force-layout broadcast (one per animation frame). */
34
+ forceLayoutUpdatePending = false;
35
+ /** Registered frame coordinate spaces by key */
36
+ frames = new Map();
25
37
  /** ID of the currently active drag target */
26
38
  dragTargetId = null;
39
+ /** Active move-drag source (an existing canvas element being moved) */
40
+ moveSource = null;
41
+ /** Pointer coords at drag start / most recent drag tick (drop space) */
42
+ dragStartCoords = null;
43
+ lastDragCoords = null;
27
44
  /** Currently active drop target */
28
45
  dropTarget = null;
29
46
  /** Index position within the current drop target */
@@ -40,6 +57,42 @@ export class Monitor {
40
57
  this.trackOutside = true;
41
58
  }
42
59
  }
60
+ /**
61
+ * Registers (or replaces) a frame coordinate space. Drop elements whose
62
+ * config carries the same `frameKey` are stored and hit-tested in
63
+ * coordinates relative to this frame's origin.
64
+ */
65
+ registerFrame(frameKey, entry) {
66
+ this.frames.set(frameKey, entry);
67
+ }
68
+ /**
69
+ * Moves a frame's world origin. O(1): no element re-measures needed since
70
+ * frame content layouts are origin-relative.
71
+ */
72
+ updateFrameOrigin(frameKey, origin) {
73
+ const frame = this.frames.get(frameKey);
74
+ if (!frame)
75
+ return;
76
+ this.frames.set(frameKey, { ...frame, origin });
77
+ }
78
+ /** Removes a frame coordinate space. */
79
+ unregisterFrame(frameKey) {
80
+ this.frames.delete(frameKey);
81
+ }
82
+ /** Gets a registered frame space by key. */
83
+ getFrame(frameKey) {
84
+ return this.frames.get(frameKey);
85
+ }
86
+ /**
87
+ * Converts world coordinates into a frame's local space.
88
+ * Falls through unchanged when the frame isn't registered.
89
+ */
90
+ toFrameLocal(coords, frameKey) {
91
+ const frame = this.frames.get(frameKey);
92
+ if (!frame)
93
+ return coords;
94
+ return { x: coords.x - frame.origin.x, y: coords.y - frame.origin.y };
95
+ }
43
96
  /**
44
97
  * Gets the ID of the currently active drag target.
45
98
  * @returns The drag target ID or null if no drag is active
@@ -56,11 +109,20 @@ export class Monitor {
56
109
  }
57
110
  /**
58
111
  * Triggers a force layout update event.
59
- * This causes all registered elements to recalculate their layout information.
112
+ * This causes all registered elements to recalculate their layout
113
+ * information. Every registered element answers with a forced layout read,
114
+ * so calls are coalesced to at most one broadcast per animation frame —
115
+ * scroll handlers and settle timers may call this at input rate.
60
116
  */
61
117
  triggerForceLayoutUpdate() {
62
- this.events.next({
63
- type: DnDEventType.LayoutForceUpdate,
118
+ if (this.forceLayoutUpdatePending)
119
+ return;
120
+ this.forceLayoutUpdatePending = true;
121
+ requestAnimationFrame(() => {
122
+ this.forceLayoutUpdatePending = false;
123
+ this.events.next({
124
+ type: DnDEventType.LayoutForceUpdate,
125
+ });
64
126
  });
65
127
  }
66
128
  /**
@@ -144,18 +206,63 @@ export class Monitor {
144
206
  }
145
207
  /**
146
208
  * Handles the start of a drag operation.
147
- * @param dragCoords - The coordinates where the drag started
209
+ * @param dragCoords - Coordinates in drop-target space (world space when a
210
+ * pointer transform is installed), or `null` when the press began outside
211
+ * that space entirely. A palette press is exactly that case — the palette
212
+ * lives beside the canvas, not on it — so a null here must never abort the
213
+ * drag; it only rules out grabbing an existing element to move.
214
+ * @param windowCoords - Raw window coordinates for drag-*source* hit
215
+ * testing (palette items live outside any frame); defaults to `dragCoords`
148
216
  */
149
- onDragStart(dragCoords) {
150
- const dragTargetId = this.getDragTarget(dragCoords);
151
- if (!dragTargetId)
217
+ onDragStart(dragCoords, windowCoords) {
218
+ this.dragStartCoords = dragCoords;
219
+ this.lastDragCoords = dragCoords;
220
+ const sourceCoords = windowCoords ?? dragCoords;
221
+ if (!sourceCoords)
222
+ return;
223
+ const dragTargetId = this.getDragTarget(sourceCoords);
224
+ if (dragTargetId) {
225
+ const dragTarget = this.dragElements.get(dragTargetId);
226
+ this.setDragTargetId(dragTargetId);
227
+ this.events.next({
228
+ type: DnDEventType.DragStart,
229
+ target: this.getDragTargetId(),
230
+ data: dragTarget?.item.data,
231
+ kind: 'palette',
232
+ });
233
+ return;
234
+ }
235
+ // No palette source under the pointer — a drag that starts on an
236
+ // existing canvas element moves it (Figma-style direct manipulation).
237
+ // A press outside the drop space can't be on such an element.
238
+ if (!dragCoords)
239
+ return;
240
+ const dropTargets = this.getDropTargets(dragCoords, this.dropElementsTree);
241
+ const shallowTarget = this.getShallowTarget(dropTargets, dragCoords);
242
+ const componentId = shallowTarget?.data
243
+ ?.id;
244
+ if (!shallowTarget || !componentId)
245
+ return;
246
+ // Root-level elements (page roots) are not movable.
247
+ if (this.dropElementsTree.has(shallowTarget.id))
152
248
  return;
153
- const dragTarget = this.dragElements.get(dragTargetId);
154
- this.setDragTargetId(dragTargetId);
249
+ const hit = shallowTarget.hitCoords ?? dragCoords;
250
+ this.moveSource = {
251
+ dropId: shallowTarget.id,
252
+ componentId,
253
+ frameKey: shallowTarget.frameKey ?? null,
254
+ grabOffset: {
255
+ x: hit.x - shallowTarget.layout.x,
256
+ y: hit.y - shallowTarget.layout.y,
257
+ },
258
+ };
155
259
  this.events.next({
156
260
  type: DnDEventType.DragStart,
157
- target: this.getDragTargetId(),
158
- data: dragTarget?.item.data,
261
+ dropTarget: shallowTarget,
262
+ data: shallowTarget.data,
263
+ kind: 'move',
264
+ moveSource: this.moveSource,
265
+ frameKey: shallowTarget.frameKey ?? null,
159
266
  });
160
267
  }
161
268
  /**
@@ -164,21 +271,124 @@ export class Monitor {
164
271
  */
165
272
  onDrag(dragCoords) {
166
273
  const dragTargetId = this.getDragTargetId();
167
- if (!dragTargetId)
274
+ if (!dragTargetId && !this.moveSource)
168
275
  return;
169
- const dropTargets = this.getDropTargets(dragCoords, this.dropElementsTree);
276
+ this.lastDragCoords = dragCoords;
277
+ // While moving an element, its own subtree is not a valid drop target.
278
+ const dropTargets = this.getDropTargets(dragCoords, this.dropElementsTree, null, this.moveSource?.dropId ?? null);
170
279
  const shallowTarget = this.getShallowTarget(dropTargets, dragCoords);
171
- if (!shallowTarget?.canDrop)
172
- return;
173
- this.setDropTarget(shallowTarget);
174
- const dropTargetIndex = this.getDropTargetIndexData(dragCoords, shallowTarget);
175
- this.setDropTargetIndex(dropTargetIndex);
280
+ // Leaving every droppable area clears the pending drop rather than
281
+ // keeping the last one: the pointer is the only thing that decides where
282
+ // a drop lands, so hovering out must be indistinguishable from never
283
+ // having hovered in. The Drag event still fires (with a null target) so
284
+ // overlays drop their highlight instead of stranding it on the element
285
+ // the pointer last passed over.
286
+ const target = shallowTarget?.canDrop ? shallowTarget : null;
287
+ this.setDropTarget(target);
288
+ this.setDropTargetIndex(target
289
+ ? this.getDropTargetIndexData(target.hitCoords ?? dragCoords, target)
290
+ : null);
176
291
  this.events.next({
177
292
  type: DnDEventType.Drag,
178
293
  target: dragTargetId,
179
294
  dropTarget: this.getDropTarget(),
180
295
  dropTargetIndex: this.getDropTargetIndex(),
181
296
  dragCoords: dragCoords,
297
+ frameKey: target?.frameKey ?? null,
298
+ kind: this.moveSource ? 'move' : 'palette',
299
+ moveSource: this.moveSource,
300
+ });
301
+ }
302
+ /**
303
+ * The pointer left the drop coordinate space entirely — reported by the
304
+ * host when its `pointerTransform` cannot place the pointer (e.g. it moved
305
+ * off the canvas viewport onto surrounding chrome). Equivalent to hovering
306
+ * empty space: the pending drop clears, so a release here cancels.
307
+ */
308
+ onDragOutside() {
309
+ const dragTargetId = this.getDragTargetId();
310
+ if (!dragTargetId && !this.moveSource)
311
+ return;
312
+ // Already cleared — don't republish on every pointer move outside.
313
+ if (!this.getDropTarget() && this.getDropTargetIndex() === null)
314
+ return;
315
+ this.setDropTarget(null);
316
+ this.setDropTargetIndex(null);
317
+ this.events.next({
318
+ type: DnDEventType.Drag,
319
+ target: dragTargetId,
320
+ dropTarget: null,
321
+ dropTargetIndex: null,
322
+ dragCoords: this.lastDragCoords,
323
+ frameKey: null,
324
+ kind: this.moveSource ? 'move' : 'palette',
325
+ moveSource: this.moveSource,
326
+ });
327
+ }
328
+ /**
329
+ * Hit-tests a point in drop space with no drag registered. Drags that start
330
+ * outside the app — files dragged in from the OS — never register a drag
331
+ * element, so they cannot go through {@link onDrag}; this gives them the
332
+ * same target and insert-index resolution the in-app drag path uses.
333
+ * @param dropCoords - The coordinates to hit-test, in drop space
334
+ */
335
+ hitTest(dropCoords) {
336
+ const dropTargets = this.getDropTargets(dropCoords, this.dropElementsTree);
337
+ const shallowTarget = this.getShallowTarget(dropTargets, dropCoords);
338
+ const dropTarget = shallowTarget?.canDrop ? shallowTarget : null;
339
+ return {
340
+ dropTarget,
341
+ dropTargetIndex: dropTarget
342
+ ? this.getDropTargetIndexData(dropTarget.hitCoords ?? dropCoords, dropTarget)
343
+ : null,
344
+ };
345
+ }
346
+ /**
347
+ * Publishes an external drag's current position, so the drop chrome
348
+ * (hover highlight, insert line) tracks a file dragged in from the OS
349
+ * exactly as it tracks an in-app drag. Pass `null` coordinates while the
350
+ * pointer sits outside the drop space — that clears the pending drop the
351
+ * same way hovering off a droppable area does mid-drag.
352
+ * @param dropCoords - The pointer position in drop space, or null
353
+ */
354
+ onExternalDrag(dropCoords) {
355
+ const { dropTarget, dropTargetIndex } = dropCoords
356
+ ? this.hitTest(dropCoords)
357
+ : { dropTarget: null, dropTargetIndex: null };
358
+ // Nothing to say when the drag is still over empty space it was already
359
+ // over — pointer moves arrive at input rate.
360
+ if (!dropTarget && !this.getDropTarget())
361
+ return;
362
+ this.lastDragCoords = dropCoords;
363
+ this.setDropTarget(dropTarget);
364
+ this.setDropTargetIndex(dropTargetIndex);
365
+ this.events.next({
366
+ type: DnDEventType.Drag,
367
+ target: null,
368
+ dropTarget,
369
+ dropTargetIndex,
370
+ dragCoords: dropCoords,
371
+ frameKey: dropTarget?.frameKey ?? null,
372
+ kind: 'external',
373
+ });
374
+ }
375
+ /**
376
+ * Ends an external drag — the files were dropped, or the drag left the
377
+ * window. Always `cancelled`: whatever the drop produces is inserted by
378
+ * the host once its uploads finish, never by the DnD path.
379
+ */
380
+ onExternalDragEnd() {
381
+ this.lastDragCoords = null;
382
+ this.setDropTarget(null);
383
+ this.setDropTargetIndex(null);
384
+ this.events.next({
385
+ type: DnDEventType.DragEnd,
386
+ target: null,
387
+ dropTarget: null,
388
+ dropTargetIndex: null,
389
+ frameKey: null,
390
+ kind: 'external',
391
+ cancelled: true,
182
392
  });
183
393
  }
184
394
  /**
@@ -186,30 +396,45 @@ export class Monitor {
186
396
  * @param dragCoords - The coordinates where the selection occurred
187
397
  */
188
398
  onSelect(dragCoords) {
189
- if (!this.isInsideFirstRootElement(dragCoords) && !this.trackOutside)
399
+ if (!this.isInsideAnyRootElement(dragCoords) && !this.trackOutside)
190
400
  return;
191
401
  const dropTargets = this.getDropTargets(dragCoords, this.dropElementsTree);
192
402
  const shallowTarget = this.getShallowTarget(dropTargets, dragCoords);
193
403
  this.events.next({
194
404
  type: DnDEventType.Select,
195
405
  dropTarget: shallowTarget,
406
+ frameKey: shallowTarget?.frameKey ?? null,
196
407
  });
197
408
  }
198
409
  /**
199
- * Checks if the given coordinates are inside the first root element.
200
- * @param dragCoords - The coordinates to check
201
- * @returns True if inside the first root element
410
+ * Checks if the given coordinates are inside any root drop element,
411
+ * converting into frame-local space for frame-rooted elements.
202
412
  * @private
203
413
  */
204
- isInsideFirstRootElement(dragCoords) {
205
- const firstRootElement = this.dropElementsTree.entries().next().value;
206
- if (!firstRootElement)
207
- return false;
208
- const [, firstRootTarget] = firstRootElement;
209
- return (firstRootTarget.layout.x <= dragCoords.x &&
210
- firstRootTarget.layout.y <= dragCoords.y &&
211
- firstRootTarget.layout.x + firstRootTarget.layout.width >= dragCoords.x &&
212
- firstRootTarget.layout.y + firstRootTarget.layout.height >= dragCoords.y);
414
+ isInsideAnyRootElement(dragCoords) {
415
+ for (const [, target] of this.dropElementsTree) {
416
+ const coords = target.frameKey
417
+ ? this.toFrameLocal(dragCoords, target.frameKey)
418
+ : dragCoords;
419
+ if (target.layout.x <= coords.x &&
420
+ target.layout.y <= coords.y &&
421
+ target.layout.x + target.layout.width >= coords.x &&
422
+ target.layout.y + target.layout.height >= coords.y) {
423
+ return true;
424
+ }
425
+ }
426
+ return false;
427
+ }
428
+ /**
429
+ * Looks up a registered drop target (with its current layout and frame
430
+ * key) by matching data properties — e.g. `{ id: componentId }`.
431
+ *
432
+ * @param frameKey - Restrict the search to one frame's subtree. Required
433
+ * whenever ids are only unique per frame (every page root shares the id
434
+ * `root`, so an unscoped lookup can return another frame's element).
435
+ */
436
+ getDropTargetByData(dataQuery, frameKey) {
437
+ return this.findDropTargetByData(dataQuery, this.dropElementsTree, null, frameKey ?? null);
213
438
  }
214
439
  /**
215
440
  * Handles external selection by data query.
@@ -220,21 +445,47 @@ export class Monitor {
220
445
  this.events.next({
221
446
  type: DnDEventType.Select,
222
447
  dropTarget: dropTarget,
448
+ frameKey: dropTarget?.frameKey ?? null,
223
449
  });
224
450
  }
225
451
  /**
226
- * Handles the end of a drag operation.
452
+ * Handles the end of a drag operation. Releasing the pointer outside every
453
+ * droppable area cancels the drag: `DragEnd` still fires — overlays, the
454
+ * drag ghost and `isDragging` all reset on it — but carries a null
455
+ * `dropTarget` and `cancelled: true`, so nothing is inserted or moved.
227
456
  */
228
457
  onDragEnd() {
458
+ const dragTargetId = this.getDragTargetId();
459
+ const moveSource = this.moveSource;
229
460
  const dropTarget = this.getDropTarget();
230
- if (!dropTarget)
461
+ // Idempotent: a pan that never became a drag (canvas panning, or a
462
+ // gesture cancelled before activation) must not emit a phantom DragEnd.
463
+ if (!dragTargetId && !moveSource) {
464
+ this.resetDragState();
231
465
  return;
466
+ }
232
467
  this.events.next({
233
468
  type: DnDEventType.DragEnd,
234
- target: this.getDragTargetId(),
469
+ target: dragTargetId,
235
470
  dropTarget: dropTarget,
236
- dropTargetIndex: this.getDropTargetIndex(),
471
+ dropTargetIndex: dropTarget ? this.getDropTargetIndex() : null,
472
+ frameKey: dropTarget?.frameKey ?? null,
473
+ kind: moveSource ? 'move' : 'palette',
474
+ moveSource,
475
+ dragCoords: this.lastDragCoords,
476
+ dragStartCoords: this.dragStartCoords,
477
+ cancelled: !dropTarget,
237
478
  });
479
+ this.resetDragState();
480
+ }
481
+ /**
482
+ * Clears every piece of per-drag state.
483
+ * @private
484
+ */
485
+ resetDragState() {
486
+ this.moveSource = null;
487
+ this.dragStartCoords = null;
488
+ this.lastDragCoords = null;
238
489
  this.setDragTargetId(null);
239
490
  this.setDropTarget(null);
240
491
  this.setDropTargetIndex(null);
@@ -250,7 +501,7 @@ export class Monitor {
250
501
  registerDropElement(layout, parent, config, data) {
251
502
  this.dropElementIds++;
252
503
  if (!parent) {
253
- this.dropElementsTree.set(this.dropElementIds, {
504
+ const item = {
254
505
  layout: {
255
506
  ...layout,
256
507
  index: config?.index || 0,
@@ -259,17 +510,21 @@ export class Monitor {
259
510
  },
260
511
  canDrop: config?.canDrop,
261
512
  data,
262
- });
513
+ frameKey: config?.frameKey,
514
+ };
515
+ this.dropElementsTree.set(this.dropElementIds, item);
516
+ this.dropNodesById.set(this.dropElementIds, item);
517
+ this.dropParentById.set(this.dropElementIds, null);
263
518
  }
264
519
  else {
265
- const parentLayout = this.findDropTarget(parent, this.dropElementsTree);
520
+ const parentLayout = this.dropNodesById.get(parent);
266
521
  if (!parentLayout) {
267
522
  throw new Error('Parent element not found');
268
523
  }
269
524
  if (!parentLayout.children) {
270
525
  parentLayout.children = new Map();
271
526
  }
272
- parentLayout.children.set(this.dropElementIds, {
527
+ const item = {
273
528
  layout: {
274
529
  ...layout,
275
530
  index: config?.index || 0,
@@ -278,7 +533,10 @@ export class Monitor {
278
533
  },
279
534
  canDrop: config?.canDrop,
280
535
  data,
281
- });
536
+ };
537
+ parentLayout.children.set(this.dropElementIds, item);
538
+ this.dropNodesById.set(this.dropElementIds, item);
539
+ this.dropParentById.set(this.dropElementIds, parent);
282
540
  }
283
541
  return this.dropElementIds;
284
542
  }
@@ -290,8 +548,14 @@ export class Monitor {
290
548
  if (this.dropElementsTree.has(id)) {
291
549
  this.dropElementsTree.delete(id);
292
550
  }
293
- const parent = this.findDropTargetParent(id, this.dropElementsTree);
294
- parent?.children?.delete(id);
551
+ else {
552
+ const parentId = this.dropParentById.get(id);
553
+ if (parentId != null) {
554
+ this.dropNodesById.get(parentId)?.children?.delete(id);
555
+ }
556
+ }
557
+ this.dropNodesById.delete(id);
558
+ this.dropParentById.delete(id);
295
559
  }
296
560
  /**
297
561
  * Updates the layout and configuration for a registered drop element.
@@ -301,27 +565,34 @@ export class Monitor {
301
565
  * @param data - Optional new data
302
566
  */
303
567
  updateDropElement(id, layout, config, data) {
304
- const target = this.findDropTarget(id, this.dropElementsTree);
568
+ const target = this.dropNodesById.get(id);
305
569
  if (target) {
570
+ const nextIndex = config?.index || target.layout.index;
571
+ const nextZIndex = config?.zIndex || target.layout.zIndex;
572
+ const nextFlexDirection = config?.flexDirection || target.layout.flexDirection;
573
+ const changed = target.layout.x !== layout.x ||
574
+ target.layout.y !== layout.y ||
575
+ target.layout.width !== layout.width ||
576
+ target.layout.height !== layout.height ||
577
+ target.layout.index !== nextIndex ||
578
+ target.layout.zIndex !== nextZIndex ||
579
+ target.layout.flexDirection !== nextFlexDirection;
306
580
  target.layout.x = layout.x;
307
581
  target.layout.y = layout.y;
308
582
  target.layout.width = layout.width;
309
583
  target.layout.height = layout.height;
310
- target.layout.index = config?.index || target.layout.index;
311
- target.layout.zIndex = config?.zIndex || target.layout.zIndex;
312
- target.layout.flexDirection =
313
- config?.flexDirection || target.layout.flexDirection;
584
+ target.layout.index = nextIndex;
585
+ target.layout.zIndex = nextZIndex;
586
+ target.layout.flexDirection = nextFlexDirection;
314
587
  target.data = data || target.data;
315
588
  target.canDrop = config?.canDrop || target.canDrop;
316
- if (this.dropElementsTree.has(id)) {
317
- this.dropElementsTree.set(id, target);
318
- }
319
- else {
320
- const parent = this.findDropTargetParent(id, this.dropElementsTree);
321
- if (parent) {
322
- parent.children?.set(id, target);
323
- }
589
+ // Layout listeners (selection overlays, highlight rects) only react to
590
+ // geometry/order. Re-measures land here at input rate and are usually
591
+ // no-ops — broadcasting those floods every subscriber for nothing.
592
+ if (!changed) {
593
+ return;
324
594
  }
595
+ const frameKey = this.findFrameKeyFor(id);
325
596
  this.events.next({
326
597
  type: DnDEventType.Layout,
327
598
  target: id,
@@ -331,31 +602,26 @@ export class Monitor {
331
602
  layout: target.layout,
332
603
  canDrop: target.canDrop,
333
604
  data: target.data,
605
+ frameKey,
334
606
  },
607
+ frameKey,
335
608
  });
336
609
  }
337
610
  }
338
611
  /**
339
- * Finds a drop target by ID in the tree structure.
340
- * @param id - The ID to search for
341
- * @param tree - The tree to search in
342
- * @returns The drop item or null if not found
612
+ * Resolves the frame space an element (or any of its ancestors) belongs to
613
+ * by walking the parent chain — O(depth).
343
614
  * @private
344
615
  */
345
- findDropTarget(id, tree) {
346
- if (!tree) {
347
- return null;
348
- }
349
- if (tree.has(id)) {
350
- return tree.get(id) || null;
351
- }
352
- for (const [, value] of tree) {
353
- if (value.children) {
354
- const target = this.findDropTarget(id, value.children);
355
- if (target) {
356
- return target;
357
- }
358
- }
616
+ findFrameKeyFor(id) {
617
+ let current = id;
618
+ while (current != null) {
619
+ const node = this.dropNodesById.get(current);
620
+ if (!node)
621
+ return null;
622
+ if (node.frameKey)
623
+ return node.frameKey;
624
+ current = this.dropParentById.get(current);
359
625
  }
360
626
  return null;
361
627
  }
@@ -366,14 +632,23 @@ export class Monitor {
366
632
  * @returns The drop target or null if not found
367
633
  * @private
368
634
  */
369
- findDropTargetByData(dataQuery, tree) {
635
+ findDropTargetByData(dataQuery, tree, frameKey, requiredFrameKey) {
370
636
  if (!tree) {
371
637
  return null;
372
638
  }
373
639
  let index = 0;
374
640
  for (const [id, target] of tree) {
375
641
  index++;
376
- if (target.data && this.partialMatch(target.data, dataQuery)) {
642
+ const targetFrameKey = target.frameKey ?? frameKey ?? null;
643
+ // Prune subtrees belonging to other frames when a frame is required.
644
+ if (requiredFrameKey &&
645
+ targetFrameKey &&
646
+ targetFrameKey !== requiredFrameKey) {
647
+ continue;
648
+ }
649
+ if (target.data &&
650
+ this.partialMatch(target.data, dataQuery) &&
651
+ (!requiredFrameKey || targetFrameKey === requiredFrameKey)) {
377
652
  return {
378
653
  id,
379
654
  layout: target.layout,
@@ -381,10 +656,11 @@ export class Monitor {
381
656
  canDrop: target.canDrop,
382
657
  children: target.children,
383
658
  data: target.data,
659
+ frameKey: targetFrameKey,
384
660
  };
385
661
  }
386
662
  if (target.children) {
387
- const childResult = this.findDropTargetByData(dataQuery, target.children);
663
+ const childResult = this.findDropTargetByData(dataQuery, target.children, targetFrameKey, requiredFrameKey);
388
664
  if (childResult) {
389
665
  return childResult;
390
666
  }
@@ -427,30 +703,6 @@ export class Monitor {
427
703
  }
428
704
  return true;
429
705
  }
430
- /**
431
- * Finds the parent of a drop target by ID.
432
- * @param id - The ID of the child drop target
433
- * @param tree - The tree to search in
434
- * @returns The parent drop item or null if not found
435
- * @private
436
- */
437
- findDropTargetParent(id, tree) {
438
- if (!tree) {
439
- return null;
440
- }
441
- for (const [, value] of tree) {
442
- if (value.children && value.children.has(id)) {
443
- return value;
444
- }
445
- else {
446
- const parent = this.findDropTargetParent(id, value.children);
447
- if (parent) {
448
- return parent;
449
- }
450
- }
451
- }
452
- return null;
453
- }
454
706
  /**
455
707
  * Gets all drop targets that contain the given coordinates.
456
708
  * @param dropCoords - The coordinates to check
@@ -458,7 +710,7 @@ export class Monitor {
458
710
  * @returns Array of drop targets or null if none found
459
711
  * @private
460
712
  */
461
- getDropTargets(dropCoords, tree) {
713
+ getDropTargets(dropCoords, tree, frameKey, excludeId) {
462
714
  const targets = [];
463
715
  if (!tree) {
464
716
  return null;
@@ -466,10 +718,21 @@ export class Monitor {
466
718
  let index = 0;
467
719
  for (const [id, target] of tree) {
468
720
  index++;
469
- if (target.layout.x <= dropCoords.x &&
470
- target.layout.y <= dropCoords.y &&
471
- target.layout.x + target.layout.width >= dropCoords.x &&
472
- target.layout.y + target.layout.height >= dropCoords.y) {
721
+ // Skip the excluded subtree entirely (a moved element can't be its
722
+ // own drop target, nor can any of its descendants).
723
+ if (excludeId != null && id === excludeId) {
724
+ continue;
725
+ }
726
+ // Entering a frame-rooted subtree: convert the pointer into that
727
+ // frame's local space once; all descendant layouts share it.
728
+ const targetFrameKey = target.frameKey ?? frameKey ?? null;
729
+ const coords = target.frameKey
730
+ ? this.toFrameLocal(dropCoords, target.frameKey)
731
+ : dropCoords;
732
+ if (target.layout.x <= coords.x &&
733
+ target.layout.y <= coords.y &&
734
+ target.layout.x + target.layout.width >= coords.x &&
735
+ target.layout.y + target.layout.height >= coords.y) {
473
736
  targets.push({
474
737
  id,
475
738
  layout: target.layout,
@@ -477,9 +740,11 @@ export class Monitor {
477
740
  canDrop: target.canDrop,
478
741
  children: target.children,
479
742
  data: target.data,
743
+ frameKey: targetFrameKey,
744
+ hitCoords: coords,
480
745
  });
481
746
  }
482
- const childTargets = this.getDropTargets(dropCoords, target.children);
747
+ const childTargets = this.getDropTargets(coords, target.children, targetFrameKey, excludeId);
483
748
  if (childTargets) {
484
749
  targets.push(...childTargets);
485
750
  }
@@ -502,12 +767,14 @@ export class Monitor {
502
767
  if (!lastTarget) {
503
768
  return null;
504
769
  }
505
- if (lastTarget.layout.y + this.padding > dragCoords.y ||
770
+ // Compare in the target's own coordinate space (frame-local inside
771
+ // frames); `hitCoords` was attached during hit testing.
772
+ const coords = lastTarget.hitCoords ?? dragCoords;
773
+ if (lastTarget.layout.y + this.padding > coords.y ||
506
774
  lastTarget.layout.y + lastTarget.layout.height - this.padding <
507
- dragCoords.y ||
508
- lastTarget.layout.x + this.padding > dragCoords.x ||
509
- lastTarget.layout.x + lastTarget.layout.width - this.padding <
510
- dragCoords.x) {
775
+ coords.y ||
776
+ lastTarget.layout.x + this.padding > coords.x ||
777
+ lastTarget.layout.x + lastTarget.layout.width - this.padding < coords.x) {
511
778
  return sortedTargets[sortedTargets.length - 2] || lastTarget;
512
779
  }
513
780
  return lastTarget;