diff --git a/.changeset/strict-board-cross-swaps.md b/.changeset/strict-board-cross-swaps.md new file mode 100644 index 000000000..a9885a704 --- /dev/null +++ b/.changeset/strict-board-cross-swaps.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Make Board's `collisionMode="swap"` source-aware: cross-board drops now insert only at an empty anchor, downscale without moving destination widgets, and cancel invalid transfers. diff --git a/src/components/layout/Board/Board.docs.mdx b/src/components/layout/Board/Board.docs.mdx index 44fb2f78f..eb94cf4c4 100644 --- a/src/components/layout/Board/Board.docs.mdx +++ b/src/components/layout/Board/Board.docs.mdx @@ -69,7 +69,7 @@ clipped by an ancestor's `overflow: hidden`. - **`compact`** `'vertical' | 'horizontal' | 'free' | null` (default: `'vertical'`) — Compaction behavior. `'vertical'`/`'horizontal'` reflow widgets to remove gaps; `'free'` places each widget exactly where dropped and never pushes its neighbours (blocked from occupied cells unless `allowOverlap`); `null` disables compaction but still resolves collisions the legacy react-grid-layout way. - **`allowOverlap`** `boolean` (default: `false`) — Allow widgets to overlap (stack). In `'free'` mode this is what distinguishes stacking from blocking a drop onto an occupied cell. - **`preventCollision`** `boolean` (default: `false`) — Block movement into occupied cells instead of pushing. -- **`collisionMode`** `'revert' | 'downscale' | 'swap'` (default: `'revert'`) — How to resolve a drop the grid would otherwise refuse. Only applies where a collision *blocks* a move — `compact="free"` (which prevents collisions) or an explicit `preventCollision` — and never under `allowOverlap`, where nothing is blocked. `'revert'` snaps the widget back. `'downscale'` shrinks it into the free space at the drop cell, growing rightward and downward, floored at its `minW`/`minH` (below that it reverts). `'swap'` trades places with **one** widget — the one the drop covers most — which takes the cell the drag began at, each keeping as much of its own size as fits there (so a widget displaced next to a neighbour may shrink). It never displaces more than that one widget. A drop straddling two widgets trades with one of them rather than refusing, so the swap never blinks away as you sweep across a boundary; ties go to the widget earlier in reading order. Dragging back retraces the original arrangement exactly. It falls back to `'downscale'`, then `'revert'`. No mode ever *grows* a widget: a drag only ever preserves or reduces size. Applies to single-widget drags. Arrow keys honour it too but never resize anything — each press is a gesture of its own, so a press that shrank a widget would have nothing to restore from, and only an exchange that fits outright is allowed. A multi-widget selection still only moves where it fits outright, and a resize is still blocked by a collision. +- **`collisionMode`** `'revert' | 'downscale' | 'swap'` (default: `'revert'`) — How to resolve a drop the grid would otherwise refuse. Only applies where a collision *blocks* a move — `compact="free"` (which prevents collisions) or an explicit `preventCollision` — and never under `allowOverlap`, where nothing is blocked. `'revert'` snaps the widget back. `'downscale'` shrinks it into the free space at the drop cell, growing rightward and downward, floored at its `minW`/`minH` (below that it reverts). Within one board, `'swap'` trades places with **one** widget — the one the drop covers most — which takes the cell the drag began at, each keeping as much of its own size as fits there. It never displaces more than that one widget. A drop straddling two widgets trades with one of them rather than refusing, so the swap never blinks away as you sweep across a boundary; ties go to the widget earlier in reading order. Dragging back retraces the original arrangement exactly. For a widget arriving from another board (including a nested board entering its parent), the destination board's `'swap'` mode becomes strict insertion: the requested anchor cell must be empty, the incoming widget keeps its size when possible or downscales into the available room to its right and below, and existing destination widgets are neither exchanged nor reflowed. Releasing over an occupied anchor or where `minW`/`minH` cannot fit cancels the transfer and restores both boards; it does not reuse the last valid preview or search for another slot. No mode ever *grows* a widget. Applies to single-widget drags. Arrow keys honour it too but never resize anything — each press is a gesture of its own, so a press that shrank a widget would have nothing to restore from, and only an exchange that fits outright is allowed. A multi-widget selection still only moves where it fits outright, and a resize is still blocked by a collision. - **`isDraggable`** `boolean` (default: `true`) — Enable dragging for all widgets. - **`isResizable`** `boolean` (default: `true`) — Enable resizing for all widgets. - **`isDroppable`** `boolean` (default: `true`) — Whether the board accepts widgets dropped from other boards. @@ -276,10 +276,11 @@ so it lines up with a control centred on the opposite corner. ### Resolving a blocked drop On a free grid a drop onto occupied cells is refused. `collisionMode` resolves it -instead: `'downscale'` shrinks the widget into whatever room is at the drop cell, -`'swap'` trades places with the widget already there. Neither ever grows a widget. -One board per mode below — drag the wide widget onto the row below in the first, -and drop a widget onto one of its neighbours in the second. +instead: `'downscale'` shrinks the widget into whatever room is at the drop cell. +Within one board, `'swap'` trades places with the widget already there. Across +boards, the same mode inserts only at an empty anchor and downscales without +moving destination widgets; an invalid release cancels the transfer. None of +these paths ever grows a widget. The examples below show all three behaviors. diff --git a/src/components/layout/Board/Board.stories.tsx b/src/components/layout/Board/Board.stories.tsx index 03db96d2b..32b8582f4 100644 --- a/src/components/layout/Board/Board.stories.tsx +++ b/src/components/layout/Board/Board.stories.tsx @@ -59,7 +59,7 @@ export default { control: { type: 'radio' }, options: ['revert', 'downscale', 'swap'], description: - 'How to resolve a drop the grid would otherwise refuse. Only applies where a collision blocks a move (`compact="free"`, or `preventCollision`).', + 'How to resolve a drop the grid would otherwise refuse. Only applies where a collision blocks a move (`compact="free"`, or `preventCollision`). `swap` exchanges widgets within a board; across boards it inserts only at an empty anchor and downscales without moving destination widgets.', table: { defaultValue: { summary: 'revert' } }, }, resizeGripPlacement: { @@ -581,6 +581,58 @@ const CollisionModesTemplate: StoryFn = () => ( + + + + collisionMode="swap" across boards — drag the + incoming widget into empty space on the target; dropping on the blocker + cancels the transfer + + + + + + + + + + + + + + + + ); @@ -592,7 +644,7 @@ CollisionModes.parameters = { docs: { description: { story: - 'A `compact="free"` board refuses a drop onto occupied cells; `collisionMode` resolves it instead. **Downscale** — drag the 4-column widget onto the middle row and it shrinks to the 3 columns free beside the blocker, instead of snapping back. **Swap** — drop one widget onto another and they trade places: the dragged widget takes the other\'s cell, the displaced one takes the cell the drag began at, and each keeps as much of its own size as fits there. Exactly one widget is ever displaced, a drop straddling two of them trades with the one it covers most, and dragging back retraces the original arrangement. Neither mode ever grows a widget. The default, `"revert"`, is what every other story on this page shows: the widget snaps back.', + 'A `compact="free"` board refuses a drop onto occupied cells; `collisionMode` resolves it instead. **Downscale** — drag the 4-column widget onto the middle row and it shrinks to the 3 columns free beside the blocker, instead of snapping back. **Swap within one board** — drop one widget onto another and they trade places: the dragged widget takes the other\'s cell, the displaced one takes the cell the drag began at, and each keeps as much of its own size as fits there. Exactly one widget is ever displaced, a drop straddling two of them trades with the one it covers most, and dragging back retraces the original arrangement. **Swap across boards** — the incoming widget can only use an empty anchor, downscales into the room to its right and below, and never moves a destination widget; an occupied anchor cancels the transfer. Neither path ever grows a widget. The default, `"revert"`, is what every other story on this page shows: the widget snaps back.', }, }, }; diff --git a/src/components/layout/Board/Board.test.tsx b/src/components/layout/Board/Board.test.tsx index 278848ac2..91b436c96 100644 --- a/src/components/layout/Board/Board.test.tsx +++ b/src/components/layout/Board/Board.test.tsx @@ -2338,6 +2338,280 @@ describe('Board', () => { expect(rectOf('a')).toBe('0,2 4x1'); }); + describe('cross-board swap', () => { + function setupCrossBoardSwap( + incoming: LayoutItem, + targetLayout: LayoutItem[], + ) { + const onSourceLayoutChange = vi.fn(); + const onTargetLayoutChange = vi.fn(); + const onWidgetTransfer = vi.fn(); + const onDragStop = vi.fn(); + + render( + + + + Incoming + + + + {targetLayout.map((it) => ( + + {it.i} + + ))} + + , + ); + + const widget = screen.getByTestId('Incoming'); + const sourceContent = widget.parentElement as HTMLElement; + const firstTarget = targetLayout[0]; + const targetContent = firstTarget + ? (screen.getByTestId(`Target-${firstTarget.i}`) + .parentElement as HTMLElement) + : (document.querySelector( + '[data-board-id="target"] [data-qa="BoardContent"]', + ) as HTMLElement); + + sourceContent.getBoundingClientRect = () => mockRect(0, 0, 600, 600); + targetContent.getBoundingClientRect = () => mockRect(600, 0, 600, 600); + widget.getBoundingClientRect = () => + mockRect(0, 0, incoming.w * 100, incoming.h * 100); + + const start = () => + fireEvent(widget, pointerEvent('pointerdown', 0, 0)); + const moveTo = (x: number, y: number) => + fireEvent( + window, + pointerEvent('pointermove', 600 + x * 100, y * 100), + ); + const endAt = (x: number, y: number) => + fireEvent(window, pointerEvent('pointerup', 600 + x * 100, y * 100)); + const dragTo = (x: number, y: number) => { + start(); + moveTo(x, y); + endAt(x, y); + }; + + return { + start, + moveTo, + endAt, + dragTo, + onSourceLayoutChange, + onTargetLayoutChange, + onWidgetTransfer, + onDragStop, + }; + } + + it('inserts at full size when the requested target space is empty', () => { + const targetLayout = [{ i: 'b', x: 4, y: 0, w: 2, h: 1 }]; + const { dragTo, onTargetLayoutChange, onWidgetTransfer } = + setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, targetLayout); + + dragTo(0, 0); + + const committed = onTargetLayoutChange.mock + .calls[0]![0] as LayoutItem[]; + expect(committed).toEqual([ + expect.objectContaining(targetLayout[0]), + expect.objectContaining({ i: 'a', x: 0, y: 0, w: 2, h: 1 }), + ]); + expect(onWidgetTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + fromBoardId: 'source', + toBoardId: 'target', + item: expect.objectContaining({ x: 0, y: 0, w: 2, h: 1 }), + }), + ); + }); + + it('downscales at an empty anchor without moving target widgets', () => { + const targetLayout = [{ i: 'b', x: 3, y: 0, w: 3, h: 1 }]; + const { dragTo, onTargetLayoutChange, onWidgetTransfer } = + setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 4, h: 1 }, targetLayout); + + dragTo(0, 0); + + const committed = onTargetLayoutChange.mock + .calls[0]![0] as LayoutItem[]; + expect(committed.find((it) => it.i === 'a')).toEqual( + expect.objectContaining({ x: 0, y: 0, w: 3, h: 1 }), + ); + expect(committed.find((it) => it.i === 'b')).toEqual( + expect.objectContaining(targetLayout[0]), + ); + expect(onWidgetTransfer.mock.calls[0]![0].item).toEqual( + expect.objectContaining({ x: 0, y: 0, w: 3, h: 1 }), + ); + }); + + it('cancels the transfer when the anchor cell is occupied', () => { + const { + dragTo, + onSourceLayoutChange, + onTargetLayoutChange, + onWidgetTransfer, + onDragStop, + } = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, [ + { i: 'b', x: 0, y: 0, w: 2, h: 1 }, + ]); + + dragTo(0, 0); + + expect(onSourceLayoutChange).not.toHaveBeenCalled(); + expect(onTargetLayoutChange).not.toHaveBeenCalled(); + expect(onWidgetTransfer).not.toHaveBeenCalled(); + expect(onDragStop).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ i: 'a', x: 0, y: 0 }), + layout: [expect.objectContaining({ i: 'a', x: 0, y: 0 })], + }), + ); + }); + + it('cancels when the available space is below the minimum size', () => { + const { + dragTo, + onSourceLayoutChange, + onTargetLayoutChange, + onWidgetTransfer, + } = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 4, h: 1, minW: 4 }, [ + { i: 'b', x: 3, y: 0, w: 3, h: 1 }, + ]); + + dragTo(0, 0); + + expect(onSourceLayoutChange).not.toHaveBeenCalled(); + expect(onTargetLayoutChange).not.toHaveBeenCalled(); + expect(onWidgetTransfer).not.toHaveBeenCalled(); + }); + + it('cancels instead of committing the last valid preview', () => { + const { + start, + moveTo, + endAt, + onSourceLayoutChange, + onTargetLayoutChange, + onWidgetTransfer, + } = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, [ + { i: 'b', x: 2, y: 0, w: 2, h: 1 }, + ]); + + start(); + moveTo(0, 0); + expect(screen.getAllByTestId('BoardPlaceholder')).toHaveLength(1); + moveTo(2, 0); + expect( + screen.queryByTestId('BoardPlaceholder'), + ).not.toBeInTheDocument(); + endAt(2, 0); + + expect(onSourceLayoutChange).not.toHaveBeenCalled(); + expect(onTargetLayoutChange).not.toHaveBeenCalled(); + expect(onWidgetTransfer).not.toHaveBeenCalled(); + }); + + it('uses the same insertion rule when dragging from a nested board to its parent', () => { + const onParentLayoutChange = vi.fn(); + const onChildLayoutChange = vi.fn(); + const onWidgetTransfer = vi.fn(); + + render( + + + + + + A + + + + + , + ); + + const widget = screen.getByTestId('NestedIncoming'); + const childContent = widget.parentElement as HTMLElement; + const container = screen.getByTestId('Container'); + const parentContent = container.parentElement as HTMLElement; + childContent.getBoundingClientRect = () => mockRect(0, 0, 300, 200); + parentContent.getBoundingClientRect = () => mockRect(0, 0, 600, 400); + container.getBoundingClientRect = () => mockRect(0, 0, 300, 200); + widget.getBoundingClientRect = () => mockRect(0, 0, 100, 100); + + fireEvent(widget, pointerEvent('pointerdown', 0, 0)); + fireEvent(window, pointerEvent('pointermove', 400, 0)); + fireEvent(window, pointerEvent('pointerup', 400, 0)); + + const committed = onParentLayoutChange.mock + .calls[0]![0] as LayoutItem[]; + expect(committed.find((it) => it.i === 'container')).toEqual( + expect.objectContaining({ x: 0, y: 0, w: 3, h: 2 }), + ); + expect(committed.find((it) => it.i === 'a')).toEqual( + expect.objectContaining({ x: 4, y: 0, w: 1, h: 1 }), + ); + expect(onChildLayoutChange).toHaveBeenCalledWith([]); + expect(onWidgetTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + widgetId: 'a', + fromBoardId: 'child', + toBoardId: 'parent', + }), + ); + }); + }); + describe('keyboard', () => { function setupKeyboardBoard( collisionMode: 'downscale' | 'swap', diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index 58dfc1e3e..3b269ecbd 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -281,11 +281,14 @@ export interface CubeBoardProps * collision blocks a move - `compact="free"` (which prevents collisions) or an * explicit `preventCollision` - and never under `allowOverlap`. `'revert'` * snaps the widget back, `'downscale'` shrinks it into the free space at the - * drop cell, `'swap'` trades places with one widget - the one the drop covers - * most - which takes the cell the drag began at (falling back to `'downscale'`, - * then `'revert'`). No mode ever grows a widget, `'swap'` never displaces more - * than that one widget, and a drop that spans two widgets trades with one of - * them rather than refusing, so the swap never blinks away mid-drag. + * drop cell. Within this board, `'swap'` trades places with one widget - the one + * the drop covers most - which takes the cell the drag began at (falling back to + * `'downscale'`, then `'revert'`). For a widget arriving from another board, + * `'swap'` never exchanges or reflows destination widgets: the anchor cell must + * be empty, the incoming widget downscales into the room to its right/below, and + * an invalid landing cancels the transfer. No mode ever grows a widget, an + * in-board swap never displaces more than one widget, and a drop that spans two + * widgets trades with one of them rather than blinking away mid-drag. * * Applies to single-widget drags. Arrow keys honour it too, but never resize * anything: each press is a gesture of its own, so a press that shrank a widget diff --git a/src/components/layout/Board/grid-core/collision-modes.test.ts b/src/components/layout/Board/grid-core/collision-modes.test.ts index 5769d66f4..aa2233b50 100644 --- a/src/components/layout/Board/grid-core/collision-modes.test.ts +++ b/src/components/layout/Board/grid-core/collision-modes.test.ts @@ -376,6 +376,17 @@ describe('collisionMode: swap', () => { expect(rects(next)).toEqual({ a: '0,0 2x2', b: '2,0 2x2' }); }); + it('downscales at an empty anchor when exchange is disabled', () => { + // This is the cross-board `swap` path: the incoming widget has no target + // slot to trade, so it may occupy the empty anchor and shrink before the + // blocker, but the blocker itself never moves. + const layout = [item('a', 0, 4, 4, 2), item('b', 3, 0, 3, 2)]; + const next = place(layout, 'a', 0, 0, 'swap', { allowExchange: false }); + + expect(rects(next)).toEqual({ a: '0,0 3x2', b: '3,0 3x2' }); + expect(isOverlapFree(next)).toBe(true); + }); + it('reverts when the displaced widget cannot fit the vacated slot', () => { // `b` needs 4 columns and `a` only frees 2, with everything else taken. const layout = [ diff --git a/src/components/layout/Board/grid-core/collision-modes.ts b/src/components/layout/Board/grid-core/collision-modes.ts index 403b2314e..b469573c6 100644 --- a/src/components/layout/Board/grid-core/collision-modes.ts +++ b/src/components/layout/Board/grid-core/collision-modes.ts @@ -252,10 +252,13 @@ function exchangeWith( * or `undefined` for `'revert'` (and for an unset mode), which keeps the engine * on its original path with no added work. * - * `'swap'` is an escalation ladder, tried in order: exchange with one of the - * widgets under the drop, else downscale at the drop cell, else revert. A dense - * grid stays draggable that way - refusing everything that is not a clean + * For an in-board drag, `'swap'` is an escalation ladder: exchange with one of + * the widgets under the drop, else downscale at the drop cell, else revert. A + * dense grid stays draggable that way - refusing everything that is not a clean * one-to-one trade would make the mode feel broken exactly where it is needed. + * Cross-board callers pass `allowExchange: false`, which turns the same mode into + * empty-anchor downscaling; the registry cancels the transfer if that resolution + * cannot occupy the requested cell. */ export function createCollisionResolver( mode: CollisionMode | undefined, diff --git a/src/components/layout/Board/grid-core/types.ts b/src/components/layout/Board/grid-core/types.ts index f2f24124f..94ea7102b 100644 --- a/src/components/layout/Board/grid-core/types.ts +++ b/src/components/layout/Board/grid-core/types.ts @@ -140,10 +140,12 @@ export type CompactType = 'horizontal' | 'vertical' | 'wrap' | null; * - `'revert'` - snap the item back to where it was (the engine's own default). * - `'downscale'` - shrink the item into the free space at the cell it was * dropped on, growing rightward and downward. Never upscales. - * - `'swap'` - exchange with one widget: the dragged widget takes the cell of the - * widget the drop covers most, and that widget takes the cell the dragged one - * came from, each keeping as much of its own size as fits there. Never displaces - * more than that one widget. Else downscale, else revert. + * - `'swap'` - inside one board, exchange with one widget: the dragged widget + * takes the cell of the widget the drop covers most, and that widget takes the + * cell the dragged one came from, each keeping as much of its own size as fits + * there. Across boards, insert only at an empty anchor and downscale without + * exchanging or reflowing destination widgets; an invalid landing cancels the + * transfer. Never displaces more than one widget. Else downscale, else revert. * * Only consulted where a move would otherwise be refused, so it has no effect in * the compacting modes (a collision pushes neighbours there) or under diff --git a/src/components/layout/Board/use-board-registry.ts b/src/components/layout/Board/use-board-registry.ts index 882ab8417..4dfee714a 100644 --- a/src/components/layout/Board/use-board-registry.ts +++ b/src/components/layout/Board/use-board-registry.ts @@ -187,6 +187,13 @@ export function useBoardRegistry( const previewRef = useRef<{ boardId: string; working: LayoutItem[] } | null>( null, ); + // Whether the latest cross-board preview actually landed at the requested + // cell. A collision resolver may decline a placement, in which case + // `moveElement` restores its synthetic origin; that must not be mistaken for a + // valid target preview and committed somewhere the pointer never selected. + const targetLandingRef = useRef<{ boardId: string; valid: boolean } | null>( + null, + ); // Board content rects captured at drag start. Reading geometry from here (not // live getBoundingClientRect) means the live-reflow preview can't move the // rects that selection/landing depend on -> no feedback loop. @@ -440,6 +447,7 @@ export function useBoardRegistry( affectedRef.current = new Set([boardId]); sourceSnapshotRef.current = cloneLayout(entry.getLayout()); previewRef.current = null; + targetLandingRef.current = null; lastLandingRef.current = { x: item.x, y: item.y }; // Start tracking the live cursor for the ancestor-handoff gate. Keyboard // drags have no pointer, so the gate falls back to the widget anchor. @@ -905,6 +913,15 @@ export function useBoardRegistry( (target: BoardEntry, item: LayoutItem, x: number, y: number) => { const pp = target.getPositionParams(); const compactor = target.getCompactor(); + // `swap` is deliberately source-aware. Inside one board it exchanges two + // widgets; across boards there is no slot on the destination to give back, + // so it becomes strict empty-anchor insertion with downscaling. The strict + // path also skips target compaction: inserting one widget must not reflow or + // push any widget already owned by the destination. + const strictIncomingSwap = + compactor.preventCollision === true && + !compactor.allowOverlap && + target.getCollisionMode() === 'swap'; const carried = previewRef.current?.boardId === target.id @@ -962,7 +979,10 @@ export function useBoardRegistry( : undefined, }, ); - const compacted = [...compactor.compact(moved, pp.cols)]; + const compacted = strictIncomingSwap + ? cloneLayout(moved) + : [...compactor.compact(moved, pp.cols)]; + const landed = getLayoutItem(compacted, item.i); // Skip a frame that would *newly* stack widgets on the target (see the same // guard in `moveWithinBoard`): keep the last valid preview instead of // committing an overlap the no-op compactor cannot resolve. The baseline is @@ -972,18 +992,41 @@ export function useBoardRegistry( !compactor.allowOverlap && hasNewOverlap(overlappingPairs(base), compacted) ) { + if (strictIncomingSwap) { + const snapshot = targetSnapshotsRef.current.get(target.id); + if (snapshot) target.applyLayout(cloneLayout(snapshot), false); + target.setPlaceholders([]); + previewRef.current = null; + targetLandingRef.current = { boardId: target.id, valid: false }; + } return; } + + // A prevented collision restores the incoming item to the synthetic origin + // used to make `moveElement` active. For a strict cross-board swap, only the + // exact requested anchor is a valid insertion; an occupied anchor or a fit + // below minW/minH therefore clears the preview and restores the untouched + // target snapshot. + if (strictIncomingSwap && (!landed || landed.x !== x || landed.y !== y)) { + const snapshot = targetSnapshotsRef.current.get(target.id); + if (snapshot) target.applyLayout(cloneLayout(snapshot), false); + target.setPlaceholders([]); + previewRef.current = null; + targetLandingRef.current = { boardId: target.id, valid: false }; + return; + } + previewRef.current = { boardId: target.id, working: compacted }; + targetLandingRef.current = { boardId: target.id, valid: true }; - const landed = getLayoutItem(compacted, item.i) ?? { ...item, x, y }; + const previewItem = landed ?? { ...item, x, y }; // Apply only the other widgets so the dragged item is never rendered as a // host on the target board. target.applyLayout( compacted.filter((l) => l.i !== item.i), false, ); - target.setPlaceholders([{ ...landed }]); + target.setPlaceholders([{ ...previewItem }]); }, [], ); @@ -1070,6 +1113,7 @@ export function useBoardRegistry( if (snap) prev?.applyLayout(cloneLayout(snap), false); prev?.setPlaceholders([]); previewRef.current = null; + targetLandingRef.current = null; } if (isGroup) { // The group moves by the delta the grabbed widget travelled from its @@ -1105,6 +1149,7 @@ export function useBoardRegistry( // Drop the carried working layout so the newly entered target seeds a // fresh preview from its own clean snapshot. previewRef.current = null; + targetLandingRef.current = null; // Snapshot the newly entered target once, as the stable base for its // reflow preview. @@ -1142,17 +1187,12 @@ export function useBoardRegistry( } } else { const landing = lastLandingRef.current ?? { x: ds.item.x, y: ds.item.y }; - - // Remove the item from the source board and compact. - if (source) { - const sp = source.getPositionParams(); - const sc = source.getCompactor(); - const remaining = source.getLayout().filter((l) => l.i !== ds.itemId); - source.applyLayout([...sc.compact(remaining, sp.cols)], true); - } - const tp = target!.getPositionParams(); const tc = target!.getCompactor(); + const strictIncomingSwap = + tc.preventCollision === true && + !tc.allowOverlap && + target!.getCollisionMode() === 'swap'; // Prefer committing the exact arrangement the user was previewing (item // already placed with the neighbours reflowed around it via continuity). @@ -1162,92 +1202,126 @@ export function useBoardRegistry( ? previewRef.current.working : null; - let finalLayout: LayoutItem[]; - if (carried) { - finalLayout = [...tc.compact(cloneLayout(carried), tp.cols)]; + const hasValidStrictLanding = + targetLandingRef.current?.boardId === target!.id && + targetLandingRef.current.valid && + carried != null; + + // A strict incoming swap is allowed to commit only the exact valid preview + // shown under the pointer. An occupied anchor, a min-size failure, or moving + // from a valid cell onto an invalid one cancels the transfer completely: + // both boards return to their gesture-start snapshots and no controlled + // layout or transfer callback fires. + if (strictIncomingSwap && !hasValidStrictLanding) { + if (source) { + source.applyLayout(cloneLayout(sourceSnapshotRef.current), false); + } + const targetSnapshot = targetSnapshotsRef.current.get(target!.id); + if (targetSnapshot) { + target!.applyLayout(cloneLayout(targetSnapshot), false); + } } else { - // No preview frame ran (e.g. a teleport drop). Seed the item just above - // (or left of) its landing cell so `moveElement` actively places it - // rather than no-opping and letting compaction sink it to the bottom. - const newItem: LayoutItem = - tc.type === 'horizontal' - ? { ...ds.item, x: Math.max(0, landing.x) - 1, y: landing.y } - : { ...ds.item, x: landing.x, y: Math.max(0, landing.y) - 1 }; - const base = [ - ...cloneLayout( - ( - targetSnapshotsRef.current.get(target!.id) ?? target!.getLayout() - ).filter((l) => l.i !== ds.itemId), - ), - newItem, - ]; - const moved = moveElement( - base, - newItem, - landing.x, - landing.y, - true, - tc.preventCollision, - tc.type, - tp.cols, - tc.allowOverlap, - { - // Cross-board, so no exchange (see `previewOnTarget`). `newItem` is - // built from the drag-start item, so its size is already the one to - // measure against. - resolveCollision: createCollisionResolver( - target!.getCollisionMode(), - { - cols: tp.cols, - maxRows: tp.maxRows, - desired: { w: ds.item.w, h: ds.item.h }, - allowExchange: false, - }, + // Remove the item from the source board and compact only after the target + // landing is known to be committable. + if (source) { + const sp = source.getPositionParams(); + const sc = source.getCompactor(); + const remaining = source.getLayout().filter((l) => l.i !== ds.itemId); + source.applyLayout([...sc.compact(remaining, sp.cols)], true); + } + + let finalLayout: LayoutItem[]; + if (carried) { + // The strict path already produced an overlap-free exact placement and + // must not compact the destination widgets around it. + finalLayout = strictIncomingSwap + ? cloneLayout(carried) + : [...tc.compact(cloneLayout(carried), tp.cols)]; + } else { + // No preview frame ran (e.g. a teleport drop). Seed the item just above + // (or left of) its landing cell so `moveElement` actively places it + // rather than no-opping and letting compaction sink it to the bottom. + const newItem: LayoutItem = + tc.type === 'horizontal' + ? { ...ds.item, x: Math.max(0, landing.x) - 1, y: landing.y } + : { ...ds.item, x: landing.x, y: Math.max(0, landing.y) - 1 }; + const base = [ + ...cloneLayout( + ( + targetSnapshotsRef.current.get(target!.id) ?? + target!.getLayout() + ).filter((l) => l.i !== ds.itemId), ), - }, - ); - finalLayout = [...tc.compact(moved, tp.cols)]; - } - // Never commit a drop that *creates* a stack. When the compactor cannot - // resolve overlaps (`compact={null}` / `preventCollision`) a teleport drop - // into an occupied region would otherwise land the item on top of another - // widget; place it in the first free slot instead so the pointer path - // matches the keyboard path. Overlaps the target already had do not trigger - // the reshuffle (they are preserved as-is). `allowOverlap` opts out. - const targetOthers = cloneLayout( - ( - targetSnapshotsRef.current.get(target!.id) ?? target!.getLayout() - ).filter((l) => l.i !== ds.itemId), - ); - if ( - !tc.allowOverlap && - hasNewOverlap(overlappingPairs(targetOthers), finalLayout) - ) { - finalLayout = [ - ...targetOthers, - placeInFreeSlot( - targetOthers, - { ...ds.item, x: landing.x, y: landing.y }, + newItem, + ]; + const moved = moveElement( + base, + newItem, + landing.x, + landing.y, + true, + tc.preventCollision, + tc.type, tp.cols, - tp.maxRows, - ), - ]; + tc.allowOverlap, + { + // Cross-board, so no exchange (see `previewOnTarget`). `newItem` is + // built from the drag-start item, so its size is already the one to + // measure against. + resolveCollision: createCollisionResolver( + target!.getCollisionMode(), + { + cols: tp.cols, + maxRows: tp.maxRows, + desired: { w: ds.item.w, h: ds.item.h }, + allowExchange: false, + }, + ), + }, + ); + finalLayout = [...tc.compact(moved, tp.cols)]; + } + // Never commit a drop that *creates* a stack. When the compactor cannot + // resolve overlaps (`compact={null}` / `preventCollision`) a teleport drop + // into an occupied region would otherwise land the item on top of another + // widget; place it in the first free slot instead so the pointer path + // matches the keyboard path. Overlaps the target already had do not trigger + // the reshuffle (they are preserved as-is). `allowOverlap` opts out. + const targetOthers = cloneLayout( + ( + targetSnapshotsRef.current.get(target!.id) ?? target!.getLayout() + ).filter((l) => l.i !== ds.itemId), + ); + if ( + !tc.allowOverlap && + hasNewOverlap(overlappingPairs(targetOthers), finalLayout) + ) { + finalLayout = [ + ...targetOthers, + placeInFreeSlot( + targetOthers, + { ...ds.item, x: landing.x, y: landing.y }, + tp.cols, + tp.maxRows, + ), + ]; + } + target!.applyLayout(finalLayout, true); + + // Signal the transfer so a controlled app can move the widget's + // declaration into the destination container (positions are already + // reported via each board's onLayoutChange). + onTransferRef.current?.({ + widgetId: ds.itemId, + fromBoardId: ds.sourceBoardId, + toBoardId: target!.id, + item: getLayoutItem(finalLayout, ds.itemId) ?? { + ...ds.item, + x: landing.x, + y: landing.y, + }, + }); } - target!.applyLayout(finalLayout, true); - - // Signal the transfer so a controlled app can move the widget's - // declaration into the destination container (positions are already - // reported via each board's onLayoutChange). - onTransferRef.current?.({ - widgetId: ds.itemId, - fromBoardId: ds.sourceBoardId, - toBoardId: target!.id, - item: getLayoutItem(finalLayout, ds.itemId) ?? { - ...ds.item, - x: landing.x, - y: landing.y, - }, - }); } const ids = new Set(affectedRef.current); @@ -1261,6 +1335,7 @@ export function useBoardRegistry( frozenRectsRef.current.clear(); nestedInDraggedRef.current = new Set(); previewRef.current = null; + targetLandingRef.current = null; lastLandingRef.current = null; setDragState(null); });