sadpandajoe commented on code in PR #45090:
URL: https://github.com/apache/superset/pull/45090#discussion_r4404743014


##########
superset-frontend/src/features/canvas/CanvasGridSurface.test.tsx:
##########
@@ -0,0 +1,1634 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ReactNode, useContext, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { fireEvent, render, screen } from 'spec/helpers/testing-library';
+import CanvasGridSurface from './CanvasGridSurface';
+import { CanvasWidgetChromeContext } from './widgetChrome';
+import { CanvasDragProvider } from './useCanvasDrag';
+import { RESIZE_CORNERS } from './gridGeometry';
+import type { GridPlacement, SpanConstraints } from './types';
+
+// jsdom lays nothing out, so the grid is told how wide it is: 1000px over 24
+// columns with 16px gaps gives a 42.33px column pitch and a 56px row pitch.
+const WIDTH = 1000;
+const COL_PITCH = 632 / 24 + 16;
+const ROW_PITCH = 56;
+
+const placements: Record<string, GridPlacement> = {
+  kpi: { col: 1, row: 1, colSpan: 6, rowSpan: 4 },
+  // Well below `kpi`, so the ordinary drags in these tests land on empty
+  // space. Dropping squarely onto a widget is a swap, which the tests for
+  // that arrange deliberately.
+  trend: { col: 1, row: 9, colSpan: 24, rowSpan: 8 },
+};
+
+const surface = (
+  live: Record<string, GridPlacement>,
+  editable: boolean,
+  onPlace: jest.Mock,
+  constraints: Record<string, SpanConstraints>,
+  // Separate from `live`: a node can be a child of the grid without having a
+  // resolved placement, and it must stay mounted when it does.
+  childIds: string[] = Object.keys(live),
+  renderNode: (nodeId: string) => ReactNode = nodeId => (
+    <div>{`widget ${nodeId}`}</div>
+  ),
+  depth = 0,
+  onSwap: jest.Mock = jest.fn(),
+) => (
+  // With a provider, as a canvas always has one: the grids register with it,
+  // and it is what knows a drag is running.
+  <CanvasDragProvider>
+    <CanvasGridSurface
+      parentId="root"
+      onMove={jest.fn()}
+      onSwap={onSwap}
+      childIds={childIds}
+      columns={24}
+      gap={16}
+      rowUnit={40}
+      placements={live}
+      layouts={live}
+      constraints={constraints}
+      editable={editable}
+      depth={depth}
+      onPlace={onPlace}
+      renderNode={renderNode}
+    />
+  </CanvasDragProvider>
+);
+
+const renderSurface = ({
+  editable = true,
+  onPlace = jest.fn(),
+  constraints = {} as Record<string, SpanConstraints>,
+  live = placements,
+  childIds = Object.keys(placements),
+  renderNode,
+  depth = 0,
+  onSwap = jest.fn(),
+}: {
+  editable?: boolean;
+  onPlace?: jest.Mock;
+  onSwap?: jest.Mock;
+  constraints?: Record<string, SpanConstraints>;
+  live?: Record<string, GridPlacement>;
+  childIds?: string[];
+  renderNode?: (nodeId: string) => ReactNode;
+  depth?: number;
+} = {}) => {
+  const { rerender, container } = render(
+    surface(
+      live,
+      editable,
+      onPlace,
+      constraints,
+      childIds,
+      renderNode,
+      depth,
+      onSwap,
+    ),
+  );
+  return Object.assign(onPlace, {
+    container,
+    onSwap,
+    /** Simulate this grid's membership changing, e.g. a node reparented. */
+    replaceChildren: (next: string[]) =>
+      rerender(
+        surface(live, editable, onPlace, constraints, next, renderNode, depth),
+      ),
+    /** Simulate a write landing while a gesture is still live. */
+    replace: (next: Record<string, GridPlacement>) =>
+      rerender(
+        surface(next, editable, onPlace, constraints, childIds, renderNode),
+      ),
+    /** Simulate editing being switched off, e.g. a publish starting. */
+    setEditable: (next: boolean) =>
+      rerender(
+        surface(live, next, onPlace, constraints, childIds, renderNode, depth),
+      ),
+  });
+};
+
+/**
+ * jsdom's `PointerEvent` carries none of the properties a gesture needs, so
+ * the pointer events are built on `MouseEvent`, which does support
+ * coordinates and buttons, with the pointer id added on top.
+ */
+class TestPointerEvent extends MouseEvent {
+  pointerId: number;
+
+  constructor(
+    type: string,
+    init: MouseEventInit & { pointerId?: number } = {},
+  ) {
+    super(type, { bubbles: true, cancelable: true, ...init });
+    this.pointerId = init.pointerId ?? 1;
+  }
+}
+
+const pointer = (
+  handle: HTMLElement,
+  type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
+  init: MouseEventInit & { pointerId?: number } = {},
+) => fireEvent(handle, new TestPointerEvent(type, { pointerId: 1, ...init }));
+
+/** A whole gesture on `handle`, from grab to release. */
+const drag = (handle: HTMLElement, dx: number, dy: number) => {
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: dx, clientY: dy });
+  pointer(handle, 'pointerup', { clientX: dx, clientY: dy });
+};
+
+const handles = (name: 'Move widget' | 'Resize widget') =>
+  screen.getAllByRole('button', {
+    name: name === 'Move widget' ? name : /^Resize widget from/,
+  });
+
+/**
+ * Resize handles for one corner only. Each widget has all four, so a test
+ * that means "the bottom-right one" has to say so.
+ */
+const corners = (container: HTMLElement, corner = 'se') =>
+  Array.from(
+    container.querySelectorAll(`[data-corner="${corner}"]`),
+  ) as HTMLElement[];
+
+/**
+ * jsdom never scrolls or lays anything out, so scrolling is simulated by
+ * moving the grid's rect, which is what it measures.
+ *
+ * Any scrolling ancestor moves that rect -- the window, or the app shell's
+ * content box, which is what scrolls while the chat panel is open. Reading the
+ * window's own offset would miss the second, and Canvas opens that panel by
+ * default.
+ */
+let contentOffsetY = 0;
+const scrollTo = (y: number) => {
+  contentOffsetY = y;
+};
+
+beforeEach(() => {
+  contentOffsetY = 0;
+});
+
+beforeAll(() => {
+  Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
+    configurable: true,
+    value(this: HTMLElement) {
+      // Only the grid reports a moving origin; everything else stays at zero,
+      // which is what jsdom would return anyway.
+      const scrolled =
+        this.dataset.test === 'canvas-grid-surface' ? -contentOffsetY : 0;
+      return {
+        x: 0,
+        y: scrolled,
+        left: 0,
+        top: scrolled,
+        right: WIDTH,
+        bottom: scrolled,
+        width: WIDTH,
+        height: 0,
+        toJSON: () => ({}),
+      } as DOMRect;
+    },
+  });
+  Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
+    configurable: true,
+    get: () => WIDTH,
+  });
+  // jsdom has no pointer capture; the gesture only needs it not to throw.
+  HTMLElement.prototype.setPointerCapture = jest.fn();
+  HTMLElement.prototype.releasePointerCapture = jest.fn();
+});
+
+test('dragging a widget persists the cells it was dropped on', () => {
+  const onPlace = renderSurface();
+
+  drag(handles('Move widget')[0], COL_PITCH * 4, ROW_PITCH * 2);
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 5,
+    row: 3,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('resizing a widget persists the new spans and leaves its origin alone', 
() => {
+  const onPlace = renderSurface();
+
+  drag(corners(onPlace.container)[0], COL_PITCH * 3, ROW_PITCH);
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 1,
+    row: 1,
+    colSpan: 9,
+    rowSpan: 5,
+  });
+});
+
+test('a resize stops at the span limits the widget declares', () => {
+  const onPlace = renderSurface({ constraints: { kpi: { maxColSpan: 8 } } });
+
+  drag(corners(onPlace.container)[0], COL_PITCH * 10, 0);
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 1,
+    row: 1,
+    colSpan: 8,
+    rowSpan: 4,
+  });
+});
+
+test('a gesture too small to reach the next cell changes nothing', () => {
+  const onPlace = renderSurface();
+
+  drag(handles('Move widget')[0], 8, 10);
+
+  expect(onPlace).not.toHaveBeenCalled();
+});
+
+test('a cancelled gesture is not persisted', () => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: COL_PITCH * 4, clientY: 0 });
+  pointer(handle, 'pointercancel');
+
+  expect(onPlace).not.toHaveBeenCalled();
+});
+
+test('a drag shows where the widget would land', () => {
+  renderSurface();
+  const handle = handles('Move widget')[0];
+
+  expect(screen.queryByTestId('canvas-drop-preview')).not.toBeInTheDocument();
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: COL_PITCH * 4, clientY: 0 });
+
+  expect(screen.getByTestId('canvas-drop-preview')).toBeInTheDocument();
+
+  pointer(handle, 'pointerup', { clientX: COL_PITCH * 4, clientY: 0 });
+
+  expect(screen.queryByTestId('canvas-drop-preview')).not.toBeInTheDocument();
+});
+
+test('arrow keys move a widget a cell at a time', () => {
+  const onPlace = renderSurface();
+
+  fireEvent.keyDown(handles('Move widget')[0], { key: 'ArrowRight' });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 2,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('arrow keys on the resize corner resize a cell at a time', () => {
+  const onPlace = renderSurface();
+
+  fireEvent.keyDown(corners(onPlace.container)[0], { key: 'ArrowDown' });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 1,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 5,
+  });
+});
+
+test('a non-primary button does not start a gesture', () => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 2, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: COL_PITCH * 4, clientY: 0 });
+  pointer(handle, 'pointerup', { clientX: COL_PITCH * 4, clientY: 0 });
+
+  expect(onPlace).not.toHaveBeenCalled();
+});
+
+test('a view-only surface renders the widgets with no handles', () => {
+  renderSurface({ editable: false });
+
+  expect(screen.getByText('widget kpi')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'Move widget' }),
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: /^Resize widget from/ }),
+  ).not.toBeInTheDocument();
+});
+
+test('the drop lands where the pointer was released, not where it last moved', 
() => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: COL_PITCH, clientY: 0 });
+  // The pointer travels further before the release reports its own position.
+  pointer(handle, 'pointerup', { clientX: COL_PITCH * 5, clientY: 0 });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 6,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('a cancel from a different pointer leaves the gesture alone', () => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', {
+    button: 0,
+    pointerId: 1,
+    clientX: 0,
+    clientY: 0,
+  });
+  pointer(handle, 'pointermove', {
+    pointerId: 1,
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+  // A second pointer going away must not discard the first one's drag.
+  pointer(handle, 'pointercancel', { pointerId: 2 });
+  pointer(handle, 'pointerup', {
+    pointerId: 1,
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 5,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('a write landing mid-gesture does not move the ground under the pointer', 
() => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  // Grab 'kpi' at row 1 and drag two rows down: it is heading for row 3.
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 2 });
+
+  // Mid-gesture, the server resolves a sibling's drop and pushes 'kpi' to
+  // row 9 — the kind of thing a draft poll or another drag's response does.
+  onPlace.replace({
+    ...placements,
+    kpi: { col: 1, row: 9, colSpan: 6, rowSpan: 4 },
+  });
+
+  pointer(handle, 'pointerup', { clientX: 0, clientY: ROW_PITCH * 2 });
+
+  // The drop is two rows from where the user grabbed it, not from row 9.
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 1,
+    row: 3,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('a click on a handle does not revert a write that landed mid-hold', () => 
{
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  // Pressed and released with no movement, so the gesture resolves back to
+  // where 'kpi' was grabbed.
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+
+  // While the button is held, a draft poll or another editor's resize moves
+  // 'kpi'. Committing the unmoved gesture now would write row 1 back over it.
+  onPlace.replace({
+    ...placements,
+    kpi: { col: 1, row: 9, colSpan: 6, rowSpan: 4 },
+  });
+
+  pointer(handle, 'pointerup', { clientX: 0, clientY: 0 });
+
+  expect(onPlace).not.toHaveBeenCalled();
+});
+
+test('a widget with no resolved placement renders, with no handles', () => {
+  // Still a child of the grid, so it stays mounted; it just has nowhere to be
+  // dragged from, so it gets no handles while its sibling keeps its own.
+  renderSurface({
+    live: { trend: placements.trend },
+    childIds: ['kpi', 'trend'],
+  });
+
+  expect(screen.getByText('widget kpi')).toBeInTheDocument();
+  expect(handles('Move widget')).toHaveLength(1);
+  expect(handles('Resize widget')).toHaveLength(RESIZE_CORNERS.length);
+});
+
+test('a second pointer cannot steal a live gesture', () => {
+  const onPlace = renderSurface();
+  const [first, second] = handles('Move widget');
+
+  // One finger grabs 'kpi'.
+  pointer(first, 'pointerdown', {
+    button: 0,
+    pointerId: 1,
+    clientX: 0,
+    clientY: 0,
+  });
+  // Another touches 'trend's handle: also button 0, so only an explicit guard
+  // stops it replacing the live gesture and stranding the first pointer.
+  pointer(second, 'pointerdown', {
+    button: 0,
+    pointerId: 2,
+    clientX: 0,
+    clientY: 0,
+  });
+
+  // The first finger finishes its drag and is still the one that commits.
+  pointer(first, 'pointermove', {
+    pointerId: 1,
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+  pointer(first, 'pointerup', {
+    pointerId: 1,
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+
+  expect(onPlace).toHaveBeenCalledTimes(1);
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 5,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+/** A widget holding its own state, as filter.select and tabs do. */
+const Stateful = ({ nodeId }: { nodeId: string }) => {
+  const [picked, setPicked] = useState('');
+  return (
+    <div>
+      <button type="button" onClick={() => setPicked('EMEA')}>
+        {`pick ${nodeId}`}
+      </button>
+      <span>{`${nodeId} picked=${picked}`}</span>
+    </div>
+  );
+};
+
+test('a widget keeps its own state when editing is switched off and back on', 
() => {
+  // Swapping the element type around a widget remounts it, losing state the
+  // widget owns: a filter would forget its selection while the charts stayed
+  // filtered. Publish flips `editable`, and flips it back when it fails.
+  const onPlace = renderSurface({
+    renderNode: nodeId => <Stateful nodeId={nodeId} />,
+  });
+
+  fireEvent.click(screen.getByRole('button', { name: 'pick kpi' }));
+  expect(screen.getByText('kpi picked=EMEA')).toBeInTheDocument();
+
+  onPlace.setEditable(false);
+  expect(screen.getByText('kpi picked=EMEA')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'Move widget' }),
+  ).not.toBeInTheDocument();
+
+  onPlace.setEditable(true);
+  expect(screen.getByText('kpi picked=EMEA')).toBeInTheDocument();
+  expect(handles('Move widget')).toHaveLength(2);
+});
+
+test('a gesture is released when editing stops mid-drag', () => {
+  // Its handle unmounts, so no up or cancel event can ever reach it. Left
+  // live, the one-gesture-at-a-time guard would block every later drag.
+  const onPlace = renderSurface();
+  pointer(handles('Move widget')[0], 'pointerdown', {
+    button: 0,
+    clientX: 0,
+    clientY: 0,
+  });
+  pointer(handles('Move widget')[0], 'pointermove', {
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+  expect(screen.getByTestId('canvas-drop-preview')).toBeInTheDocument();
+
+  onPlace.setEditable(false);
+
+  expect(screen.queryByTestId('canvas-drop-preview')).not.toBeInTheDocument();
+
+  // Editing resumes and dragging still works, rather than being dead.
+  onPlace.setEditable(true);
+  drag(handles('Move widget')[0], COL_PITCH * 4, 0);
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 5,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('a gesture is released when its node stops being placeable', () => {
+  const onPlace = renderSurface();
+  pointer(handles('Move widget')[0], 'pointerdown', {
+    button: 0,
+    clientX: 0,
+    clientY: 0,
+  });
+  pointer(handles('Move widget')[0], 'pointermove', {
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+
+  // A draft poll removes 'kpi' from the resolved placements.
+  onPlace.replace({ trend: placements.trend });
+
+  expect(screen.queryByTestId('canvas-drop-preview')).not.toBeInTheDocument();
+  // The surviving widget is still draggable.
+  drag(handles('Move widget')[0], 0, ROW_PITCH);
+  expect(onPlace).toHaveBeenCalledWith('trend', {
+    col: 1,
+    row: 10,
+    colSpan: 24,
+    rowSpan: 8,
+  });
+});
+
+test('a gesture is released when its node moves to another grid', () => {
+  // The node is still placed somewhere on the canvas, so a placements-only
+  // check misses it -- but its handle has unmounted from this grid, so no
+  // up, cancel or lost-capture event can ever reach it. Left live, the
+  // one-gesture guard would block every later drag on this grid.
+  const onPlace = renderSurface();
+  pointer(handles('Move widget')[0], 'pointerdown', {
+    button: 0,
+    clientX: 0,
+    clientY: 0,
+  });
+  pointer(handles('Move widget')[0], 'pointermove', {
+    clientX: COL_PITCH * 4,
+    clientY: 0,
+  });
+  expect(screen.getByTestId('canvas-drop-preview')).toBeInTheDocument();
+
+  // A draft poll moves 'kpi' into another container: still placed, no longer
+  // a child of this grid.
+  onPlace.replaceChildren(['trend']);
+
+  expect(screen.queryByTestId('canvas-drop-preview')).not.toBeInTheDocument();
+  // And this grid still works.
+  drag(handles('Move widget')[0], 0, ROW_PITCH);
+  expect(onPlace).toHaveBeenCalledWith('trend', {
+    col: 1,
+    row: 10,
+    colSpan: 24,
+    rowSpan: 8,
+  });
+});
+
+const gripTop = () => getComputedStyle(handles('Move widget')[0]).top;
+
+test("a root grid's grip lines up with the widget's own controls", () => {
+  // The grip, the More Options button and the north-east resize corner all
+  // sit in the widget's top right, so they have to read as one row. The
+  // controls are a `sizeUnit * 8` square at `top: 0` (see WithQuery's
+  // `.canvas-widget-controls`); the grip matches both, or it floats a few
+  // pixels high next to them.
+  renderSurface();
+  const grip = getComputedStyle(handles('Move widget')[0]);
+
+  expect(grip.top).toBe('0px');
+  expect(grip.height).toBe('32px');
+  expect(grip.width).toBe('32px');
+});
+
+test('a nested grid offsets its handles clear of its container', () => {
+  // A container's box begins where its first child's does and its handles
+  // paint last, so sharing a position would let the container's grip swallow
+  // clicks meant for the child's.
+  renderSurface({ depth: 1 });
+
+  expect(gripTop()).toBe('36px');
+});
+
+test('a nested handle stays inside a short widget', () => {
+  // A one-row widget renders 40px tall. An unclamped depth offset would put
+  // the grip at 36px plus its own box, i.e. over the widget below -- where it
+  // would take presses meant for that one.
+  renderSurface({
+    live: { short: { col: 1, row: 1, colSpan: 24, rowSpan: 1 } },
+    childIds: ['short'],
+    depth: 1,
+  });
+
+  const top = Number.parseInt(gripTop(), 10);
+  expect(top).toBeGreaterThan(0);
+  // Its own box has to fit below the offset, inside the 40px widget.
+  expect(top).toBeLessThanOrEqual(40 - 7 * 4);
+});
+
+test('a hidden handle does not take presses', () => {
+  // Handles are only transparent at rest. Without this they would still win
+  // the hit test over whatever they sit above.
+  renderSurface();
+
+  expect(getComputedStyle(handles('Move widget')[0]).pointerEvents).toBe(
+    'none',
+  );
+});
+
+const cornerBottom = (container: HTMLElement, corner = 'se') =>
+  getComputedStyle(corners(container, corner)[0]).bottom;
+
+test("the root grid's resize corner sits on the widget's own corner", () => {
+  const onPlace = renderSurface();
+
+  expect(cornerBottom(onPlace.container)).toBe('0px');
+});
+
+test("a nested grid lifts its resize corner clear of its container's", () => {
+  // A container's box ends where its last child's does and the container's
+  // corner paints later, so sharing the pixel makes the child's unreachable.
+  const onPlace = renderSurface({ depth: 1 });
+
+  const bottom = Number.parseInt(cornerBottom(onPlace.container), 10);
+  expect(bottom).toBeGreaterThan(0);
+  // Still inside the widget, like the grip offsets above.
+  expect(bottom).toBeLessThan(placements.kpi.rowSpan * 40);
+});
+
+test('a drag follows the content when it scrolls under the pointer', () => {
+  // Pointer coordinates are relative to the viewport, so scrolling while
+  // holding moves the content under a still pointer. Without compensating,
+  // the drag reports no movement and the drop writes nothing. Measured from
+  // the grid's own rect rather than the window's scroll offset, because with
+  // the chat panel open -- which Canvas does by default -- the shell is locked
+  // and it is the content box that scrolls.
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  scrollTo(ROW_PITCH * 2);
+  // Released at the same screen position, two rows further down the content.
+  pointer(handle, 'pointerup', { clientX: 0, clientY: 0 });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 1,
+    row: 3,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+test('each widget wrapper is marked so the innermost one can be detected', () 
=> {
+  // The hover rule narrows to the innermost widget with
+  // `:not(:has(.canvas-interactive:hover))`, which silently stops working if
+  // this class goes away. jsdom cannot evaluate `:has()` against a hover
+  // state, so the class itself is what is pinned here.
+  const { container } = renderSurface();
+
+  expect(container.querySelectorAll('.canvas-interactive')).toHaveLength(2);
+});
+
+const rowOf = (container: HTMLElement, nodeId: string) =>
+  getComputedStyle(
+    container.querySelector(`[data-node-id="${nodeId}"]`) as HTMLElement,
+  ).gridRow;
+
+test('a drop onto one widget exchanges places with it, before the drop', () => 
{
+  // Live reflow: the same rules the write will apply run on every move, so
+  // nothing jumps once it lands. Landing squarely on one widget is a swap --
+  // it takes the slot the dragged one came from.
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+
+  expect(rowOf(onPlace.container, 'trend')).toContain('9');
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  // 'kpi' is 4 rows tall at row 1; eight rows down puts it on 'trend'.
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 8 });
+
+  // 'trend' takes row 1, which 'kpi' has just left.
+  expect(rowOf(onPlace.container, 'trend')).toContain('1/');
+
+  pointer(handle, 'pointerup', { clientX: 0, clientY: ROW_PITCH * 8 });
+
+  // And both are written together, as the exchange they are.
+  expect(onPlace).not.toHaveBeenCalled();

Review Comment:
   This test is titled for an exchange, but it only asserts that `onPlace` was 
not called. If the `onSwap(...)` call in `commit` were deleted, the preview 
assertion above still holds and nothing is written. Could it also assert that 
`onSwap` fires once with `kpi`'s target placement and `trend` moved into 
`kpi`'s original slot?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,1317 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview One grid of a canvas, with drag and resize built in.
+ *
+ * There is no edit mode: a user who may edit gets a drag grip and a resize
+ * corner on each widget, on the canvas as they view it. A user who may not
+ * gets the same grid with no handles. Dragging never covers the widget's own
+ * surface, so a filter or chart underneath stays clickable.
+ *
+ * While a gesture is live the widget follows the pointer and a dashed outline
+ * marks the cells it would take; releasing persists exactly those cells.
+ */
+
+import {
+  PointerEvent as ReactPointerEvent,
+  KeyboardEvent as ReactKeyboardEvent,
+  ReactNode,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  columnWidth,
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  placementAt,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import {
+  insertIndex,
+  previewGrid,
+  resolveGrid,
+  swapPartner,
+  swapped,
+} from './resolveGrid';
+import { useCanvasDrag } from './useCanvasDrag';
+import { CanvasWidgetChromeContext } from './widgetChrome';
+import type { GridLayout, GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Where the grid sat in the viewport when the widget was grabbed.
+   *
+   * Pointer coordinates are relative to the viewport, so scrolling
+   * mid-gesture moves the content under a still pointer and the drag would
+   * report no movement at all. The grid's own position is what this measures
+   * against rather than the page's scroll offset: the canvas does not
+   * necessarily scroll the window -- with the chat panel open the shell is
+   * locked and the content scrolls inside it -- and the grid's rect moves for
+   * any scrolling ancestor, the window included.
+   */
+  startLeft: number;
+  startTop: number;
+  dx: number;
+  dy: number;
+  /**
+   * Where the pointer was last seen. Scrolling moves the content without
+   * moving the pointer, so recomputing then needs the position it held.
+   */
+  lastX: number;
+  lastY: number;
+  /**
+   * Where the widget sat when it was grabbed. The gesture resolves against
+   * this, not against the current placement: a write landing mid-gesture (a
+   * sibling's drop pushing this one down, or a draft poll) would otherwise
+   * move the ground under the pointer and persist the drop relative to a row
+   * the user never aimed at.
+   */
+  origin: GridPlacement;
+  /**
+   * Whether this gesture is actually dragging yet. One begun on a handle is
+   * armed at once; one begun on the widget body waits until the pointer has
+   * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click
+   * reaches the widget underneath instead of nudging it.
+   */
+  armed: boolean;
+  /**
+   * The element the press landed on, so a gesture that arms later can take
+   * the pointer from it. Held rather than looked up when it arms: by then the
+   * pointer may be over something else entirely.
+   */
+  capture: HTMLElement;
+  /**
+   * Where inside the widget it was grabbed, in pixels. A drop into another
+   * grid lands the widget relative to this, so it keeps its hold on the
+   * pointer instead of jumping its own corner under it.
+   */
+  grabX: number;
+  grabY: number;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+  /** The grid's height when it was grabbed; see `Grid`'s `heldHeight`. */
+  heldHeight: number;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/**
+ * Presses already claimed by a grid, so one that bubbles through nested grids
+ * starts exactly one gesture: the innermost grid's.
+ */
+const claimedPresses = new WeakSet<Event>();
+
+/**
+ * Controls that handle press-and-drag themselves, so a press on one must not
+ * also move the widget: a filter's slider, a form control, a link, a handle
+ * for reordering something inside a widget. `data-canvas-no-drag` lets a
+ * widget opt anything else out.
+ */
+const OWN_GESTURE = [
+  'input',
+  'textarea',
+  'select',
+  'button',
+  'a[href]',
+  '[role="slider"]',
+  '[role="button"]',
+  '[role="scrollbar"]',
+  '[contenteditable=""]',
+  '[contenteditable="true"]',
+  '[draggable="true"]',
+  '[data-canvas-no-drag]',
+].join(',');
+
+/**
+ * Whether a press landed on a scrollbar rather than inside the content.
+ *
+ * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a
+ * widget tall enough to scroll -- a table, usually -- would otherwise be
+ * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop
+ * short of the scrollbar, so a press past either is on it; the comparison is
+ * skipped for an element that isn't overflowing, which has no scrollbar to
+ * press. A left-hand scrollbar (RTL) is not detected.
+ */
+const onScrollbar = (
+  target: Element,
+  clientX: number,
+  clientY: number,
+): boolean => {
+  const box = target.getBoundingClientRect();
+  return (
+    (target.scrollHeight > target.clientHeight &&
+      clientX > box.left + target.clientWidth) ||
+    (target.scrollWidth > target.clientWidth &&
+      clientY > box.top + target.clientHeight)
+  );
+};
+
+/** Whether something inside the widget owns this press, so the grid doesn't. 
*/
+const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => {
+  const { target } = event;
+  if (!(target instanceof Element)) return false;
+  const owner = target.closest(OWN_GESTURE);
+  // Bounded to this widget: a control that happens to sit above the grid in
+  // the page isn't what the press landed on.
+  return (
+    (owner !== null && event.currentTarget.contains(owner)) ||
+    onScrollbar(target, event.clientX, event.clientY)
+  );
+};
+
+/**
+ * A gesture's movement across the grid, scrolling included.
+ *
+ * `left`/`top` are where the grid sits in the viewport now. Content scrolling
+ * under a still pointer moves the grid's rect, and the widget has travelled by
+ * that much relative to the grid even though the pointer has not moved.
+ */
+const travelled = (
+  gesture: Gesture,
+  clientX: number,
+  clientY: number,
+  left: number,
+  top: number,
+) => ({
+  dx: clientX - gesture.startX - (left - gesture.startLeft),
+  dy: clientY - gesture.startY - (top - gesture.startTop),
+});
+
+const Grid = styled.div<GridMetrics & { heldHeight?: number }>`
+  display: grid;
+  grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr));
+  grid-auto-rows: ${({ rowUnit }) => rowUnit}px;
+  gap: ${({ gap }) => gap}px;
+  /*
+   * Held at the height it had when the gesture started, for as long as the
+   * gesture lasts.
+   *
+   * The preview reflows the grid on every move, and dragging a widget up can
+   * leave it needing fewer rows. Shrinking it while the view is scrolled near
+   * the bottom makes the browser clamp the scroll position, which slides the
+   * content under a still pointer -- and since travel is measured against the
+   * grid's own position, that feeds straight back into the preview, which
+   * reflows again. The result is a drag that fights the user, visible as the
+   * page scrolling by itself. A floor costs nothing: the grid may still grow,
+   * which is what dragging past the end needs.
+   */
+  ${({ heldHeight }) =>
+    heldHeight &&
+    `
+    min-height: ${heldHeight}px;
+    /*
+     * And the browser keeps its hands off the scroll position.
+     *
+     * Scroll anchoring adjusts scrollTop when content above the viewport
+     * changes size, to hold the visible part steady. Dragging a widget
+     * upwards reorders everything above it on every move, so the browser
+     * corrects the scroll, which moves the grid, which is what travel is
+     * measured against -- so the preview moves, which reflows again. The
+     * drag runs away on its own, and only once the target is off-screen,
+     * because that is when there is anything above to anchor to.
+     */
+    overflow-anchor: none;
+  `}
+`;
+
+const GridItem = styled.div<{
+  placement?: GridPlacement;
+  dragging?: boolean;
+  offset?: { x: number; y: number };
+}>`
+  ${({ theme, placement, dragging, offset }) => css`
+    min-width: 0;
+    min-height: 0;
+    position: relative;
+    ${
+      placement &&
+      css`
+        grid-column: ${placement.col} / span ${placement.colSpan};
+        grid-row: ${placement.row} / span ${placement.rowSpan};
+      `
+    }
+    ${
+      dragging &&
+      offset &&
+      css`
+        /* Follow the pointer without reflowing the grid under it. */
+        transform: translate(${offset.x}px, ${offset.y}px);
+        z-index: 2;
+        opacity: 0.85;
+        /*
+         * A widget draws no background of its own, so at less than full
+         * opacity the neighbours and the drop outline show through the one
+         * being dragged and it reads as sliding underneath them. The fill and
+         * the shadow are what make it read as lifted.
+         */
+        background: ${theme.colorBgContainer};
+        box-shadow: ${theme.boxShadowSecondary};
+        border-radius: ${theme.borderRadius}px;
+      `
+    }
+  `}
+`;
+
+/**
+ * Where the widget being dragged will land. No error state: the neighbours
+ * are already rendered where they will end up, so an overlap shows as them
+ * moving aside rather than as a warning colour here.
+ */
+const DropPreview = styled.div<{ placement: GridPlacement }>`
+  ${({ theme, placement }) => css`
+    grid-column: ${placement.col} / span ${placement.colSpan};
+    grid-row: ${placement.row} / span ${placement.rowSpan};
+    border: 2px dashed ${theme.colorPrimary};
+    border-radius: ${theme.borderRadius}px;
+    background: ${theme.colorPrimaryBg};
+    pointer-events: none;
+    z-index: 1;
+  `}
+`;
+
+/**
+ * Wraps a widget so its handles can sit on top of it. The handles stay hidden
+ * until the pointer is over the widget or a handle has focus, so a canvas at
+ * rest looks the same whether or not the user may edit it.
+ *
+ * Only the innermost hovered widget shows its handles and its outline:
+ * `:hover` matches every ancestor, so a container would otherwise light up
+ * alongside whichever of its children the pointer is actually over.
+ */
+const Interactive = styled.div<{ showBounds?: boolean }>`
+  ${({ theme, showBounds }) => css`
+    height: 100%;
+    position: relative;
+    border-radius: ${theme.borderRadius}px;
+    transition: box-shadow ${theme.motionDurationMid};
+
+    ${
+      showBounds &&
+      css`
+        /*
+       * Every widget shows its edges while a drag is running, containers
+       * included. A drop target you cannot see is one you cannot aim at, and
+       * revealing a widget only once the pointer is near it tells you where
+       * it is just as you arrive.
+       */
+        box-shadow: 0 0 0 1px ${theme.colorBorderSecondary};
+      `
+    }
+
+    /* Direct children only: hovering a container must not reveal the
+       handles of every widget nested inside it. */
+    & > .canvas-handle {
+      opacity: 0;
+      /*
+       * Transparent is not absent: without this a hidden handle still wins
+       * the hit test over whatever it happens to sit above.
+       */
+      pointer-events: none;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) {
+      /* Borderless widgets (a metric tile, say) otherwise give no clue which
+         widget the handles belong to. */
+      box-shadow: 0 0 0 1px ${theme.colorBorder};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) > .canvas-handle,
+    & > .canvas-handle:focus-visible {
+      opacity: 1;
+      pointer-events: auto;
+    }
+
+    /* A widget's own chrome appears on the same terms as the handles, and
+       stays while its menu is open. */
+    & > .canvas-chrome {
+      opacity: 0;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) > .canvas-chrome,
+    & > .canvas-chrome:focus-within,
+    & > .canvas-chrome:has(.ant-dropdown-open) {
+      opacity: 1;
+    }
+  `}
+`;
+
+/** Step between a container's handles and its children's, in size units. */
+const HANDLE_STEP = 8;
+/** Size units a handle itself needs, so an offset can leave room for it. */
+const HANDLE_BOX = 7;
+
+/**
+ * How far a nested grid's handles sit from the widget's edge.
+ *
+ * Nesting steps the offset so a container's handle never lands exactly on its
+ * child's, but it is clamped to the widget's own rendered box: pushing past
+ * that would put the handle over the *next* widget, where it would take
+ * presses meant for that one.
+ */
+const handleOffset = (
+  sizeUnit: number,
+  depth: number,
+  extent: number,
+): number => {
+  const room = Math.max(sizeUnit, extent - sizeUnit * HANDLE_BOX);
+  return Math.min(sizeUnit * (1 + depth * HANDLE_STEP), room);
+};
+
+/**
+ * Where a widget's top-right controls sit: the grip, and the menu beside it.
+ *
+ * Flush with the top on the root grid, where the resize corners are too.
+ * Nested, it steps down to clear the controls of the container around it --
+ * a container's box begins where its first child's does, and the child's
+ * paint above, so sharing the spot makes the container unreachable.
+ */
+export const handleTop = (
+  sizeUnit: number,
+  depth: number,
+  extent: number,
+): number => (depth === 0 ? 0 : handleOffset(sizeUnit, depth, extent));
+
+/**
+ * The widget itself, kept inside the cells it was given.
+ *
+ * A widget renders at its natural height — a table with every row, say — so
+ * without this it spills past its own box and over whatever is below. The
+ * handles are siblings rather than children, so they stay pinned to the box
+ * while the content scrolls behind them.
+ */
+const Content = styled.div<{ editable: boolean }>`
+  ${({ editable }) => css`
+    height: 100%;
+    overflow: auto;
+    /*
+     * A stacking context of its own, so a widget's internal layering stays
+     * inside it rather than competing with the canvas. An antd table's
+     * sticky header sits at columns * 2 + 3 -- nine for three columns, and
+     * higher with every column it gains -- so it would otherwise paint over
+     * the handles and the drop outline, which sit at 3 and 1 and cannot
+     * outbid a number with no ceiling. Isolated, the header can only reach
+     * the top of this box.
+     */
+    isolation: isolate;
+    ${
+      editable &&
+      css`
+        /*
+       * Dragging across text would otherwise select it and move the widget at
+       * once. In a plan the drag wins -- arranging is what a plan is for --
+       * and the published canvas selects normally.
+       */
+        user-select: none;
+
+        /*
+         * While a widget inside this one is being *moved*, stop clipping. A
+         * child dragged towards the edge of its container is leaving the
+         * container's box, and scrolling it back into view is the opposite of
+         * what the gesture means -- it would cut the widget off at the edge
+         * and grow a scrollbar. Only an ancestor matches: the dragged widget's
+         * own content does not contain its grid cell.
+         *
+         * A resize is not a move: the widget stays inside its container, so
+         * unclipping gains nothing and costs plenty. Dropping the clip makes
+         * every scrolling widget inside render at its full height for the
+         * length of the gesture, which for a table means its rows -- and its
+         * sticky header -- spilling out over whatever is below.
+         */
+        &:has([data-dragging='true']) {
+          overflow: visible;
+        }
+      `
+    }
+  `}
+`;
+
+/**
+ * Where a widget's own controls go: over the widget, outside the box its
+ * content scrolls in.
+ *
+ * Transparent to the pointer, so it does not shadow the widget beneath it;
+ * what it holds takes presses for itself.
+ */
+const Chrome = styled.div<{ offset: number }>`
+  ${({ theme, offset }) => css`
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+
+    .canvas-widget-controls {
+      position: absolute;
+      /*
+       * The same step down the handles take, so the controls stay level with
+       * the grip at every depth. Nested, the grip moves clear of the grip of
+       * the container around it; leaving the menu behind splits what should
+       * read as one row.
+       */
+      top: ${offset}px;
+      /*
+       * Clear of the widget's right edge, where both the resize handle and a
+       * scrollbar live. See WithQuery, which uses the same inset when it
+       * renders in place.
+       */
+      right: ${theme.sizeUnit * 5}px;
+      pointer-events: auto;
+    }
+  `}
+`;
+
+/**
+ * A widget's content, and the node its chrome renders into.
+ *
+ * The two are siblings so chrome escapes the content's scrolling, clipping
+ * and stacking; see `CanvasWidgetChromeContext`.
+ */
+function WidgetBody({
+  editable,
+  offset,
+  children,
+}: {
+  editable: boolean;
+  /** Where the handles sit, so the chrome can sit level with them. */
+  offset: number;
+  children: ReactNode;
+}) {
+  const [chrome, setChrome] = useState<HTMLDivElement | null>(null);
+  return (
+    <>
+      <Content editable={editable}>
+        <CanvasWidgetChromeContext.Provider value={chrome}>
+          {children}
+        </CanvasWidgetChromeContext.Provider>
+      </Content>
+      <Chrome ref={setChrome} className="canvas-chrome" offset={offset} />
+    </>
+  );
+}
+
+const Grip = styled.button<{ depth: number; extent: number }>`
+  ${({ theme, depth, extent }) => css`
+    position: absolute;
+    /*
+     * On the root grid the grip sits flush with the top, where the widget's
+     * own controls and the resize corners already are, so the three read as
+     * one row. Nested, it steps down to clear the grip of the widget inside
+     * it -- a container's box begins where its first child's does, and the
+     * child's grip paints above, so sharing the spot makes the container
+     * ungrabbable.
+     */
+    top: ${handleTop(theme.sizeUnit, depth, extent)}px;
+    /* Deeper handles above shallower ones: the innermost wins a shared spot. 
*/
+    z-index: ${3 + depth};
+    /*
+     * Left of both the north-east resize corner (4 units at the corner) and a
+     * widget's own controls, which step aside to 5 units while a plan is open
+     * and are 8 units wide (see WithQuery's .canvas-widget-controls). These
+     * handles sit above them in the stack, so overlapping would swallow
+     * presses meant for the menu.
+     */
+    right: ${theme.sizeUnit * 14}px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    /* The same box as the controls button beside it, so the two line up. */
+    width: ${theme.sizeUnit * 8}px;
+    height: ${theme.sizeUnit * 8}px;
+    padding: 0;
+    border: none;
+    background: none;
+    color: ${theme.colorTextSecondary};
+    cursor: grab;
+    touch-action: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  `}
+`;
+
+/** Human-readable names for the four corners, for the handle labels. */
+const CORNER_NAMES: Record<ResizeCorner, () => string> = {
+  nw: () => t('top left'),
+  ne: () => t('top right'),
+  sw: () => t('bottom left'),
+  se: () => t('bottom right'),
+};
+
+const ResizeCorner = styled.button<{
+  corner: ResizeCorner;
+  depth: number;
+  extent: number;
+}>`
+  ${({ theme, corner, depth, extent }) => {
+    const west = corner === 'nw' || corner === 'sw';
+    const north = corner === 'nw' || corner === 'ne';
+    /*
+     * Stepped like the grip: a container's box shares its edges with its
+     * first and last child's, and the container's handles paint later, so
+     * sharing the pixel would make the child's corner unreachable.
+     */
+    const inset =
+      depth === 0 ? 0 : handleOffset(theme.sizeUnit, depth - 1, extent);
+    return css`
+      position: absolute;
+      ${west ? 'left' : 'right'}: ${inset}px;
+      ${north ? 'top' : 'bottom'}: ${inset}px;
+      z-index: ${3 + depth};
+      width: ${theme.sizeUnit * 4}px;
+      height: ${theme.sizeUnit * 4}px;
+      padding: 0;
+      border: none;
+      background: transparent;
+      cursor: ${west === north ? 'nwse-resize' : 'nesw-resize'};
+      touch-action: none;
+
+      /* A two-line corner, the conventional resize affordance. */
+      &::after {
+        content: '';
+        position: absolute;
+        ${west ? 'left' : 'right'}: ${theme.sizeUnit / 2}px;
+        ${north ? 'top' : 'bottom'}: ${theme.sizeUnit / 2}px;
+        width: ${theme.sizeUnit * 2}px;
+        height: ${theme.sizeUnit * 2}px;
+        border-${west ? 'left' : 'right'}: 2px solid 
${theme.colorTextTertiary};
+        border-${north ? 'top' : 'bottom'}: 2px solid 
${theme.colorTextTertiary};
+      }
+    `;
+  }}
+`;
+
+/** The grid's content width, kept current as the viewport changes. */
+function useElementWidth(): [(element: HTMLDivElement | null) => void, number] 
{
+  const [element, setElement] = useState<HTMLDivElement | null>(null);
+  const [width, setWidth] = useState(0);
+
+  useEffect(() => {
+    if (!element) return undefined;
+    const measure = () => setWidth(element.clientWidth);
+    measure();
+    if (typeof ResizeObserver === 'undefined') return undefined;
+    const observer = new ResizeObserver(measure);
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [element]);
+
+  return [setElement, width];
+}
+
+export interface CanvasGridSurfaceProps {
+  childIds: string[];
+  columns: number;
+  gap: number;
+  rowUnit: number;
+  placements: Record<string, GridPlacement>;
+  /**
+   * Each child's stored layout. The preview resolves from these, as the
+   * server does: an auto-placed child has no `col`/`row`, and resolving from
+   * the rectangle it currently occupies would pin it there in the preview
+   * while the server still places it freely.
+   */
+  layouts: Record<string, GridLayout>;
+  constraints: Record<string, SpanConstraints>;
+  /** Whether the user may drag and resize these widgets. */
+  editable: boolean;
+  /**
+   * How deeply this grid is nested. A container's box begins where its first
+   * child's does and its handles paint last, so without an offset per level a
+   * container's grip would sit over its child's and swallow the click.
+   */
+  depth: number;
+  /** The container this grid lays out; the root grid's own id for the root. */
+  parentId: string;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  /**
+   * Persist two widgets exchanging places, which is what a drop landing
+   * squarely on one widget does.
+   */
+  onSwap: (
+    nodeId: string,
+    placement: GridPlacement,
+    partnerId: string,
+    partnerPlacement: GridPlacement,
+  ) => void;
+  /**
+   * Persist a widget dragged into this grid from another one. `index` is
+   * where it belongs in this grid's reading order.
+   */
+  onMove: (
+    nodeId: string,
+    parent: string,
+    index: number,
+    placement: GridPlacement,
+  ) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  layouts,
+  constraints,
+  editable,
+  depth,
+  parentId,
+  onPlace,
+  onSwap,
+  onMove,
+  renderNode,
+}: CanvasGridSurfaceProps) {
+  const theme = useTheme();
+  const drag = useCanvasDrag();
+  const [measureGrid, width] = useElementWidth();
+  // Registered so a gesture anywhere on the canvas can find this grid under
+  // the pointer, and so this one can find the others.
+  const [gridNode, setGridNode] = useState<HTMLDivElement | null>(null);
+  // Read by another grid's drag, so it has to be current without this grid
+  // re-registering on every render.
+  const contents = useRef({ childIds, layouts });
+  contents.current = { childIds, layouts };
+  const snapshot = useCallback(() => contents.current, []);
+  useEffect(() => {
+    if (!gridNode) return undefined;
+    return drag.register({
+      parentId,
+      element: gridNode,
+      columns,
+      gap,
+      rowUnit,
+      depth,
+      snapshot,
+    });
+  }, [drag, gridNode, parentId, columns, gap, rowUnit, depth, snapshot]);
+
+  // The grid node, for reading where it sits in the viewport mid-gesture.
+  const grid = useRef<HTMLDivElement | null>(null);
+  const gridRef = useCallback(
+    (node: HTMLDivElement | null) => {
+      grid.current = node;
+      setGridNode(node);
+      measureGrid(node);
+    },
+    [measureGrid],
+  );
+
+  /**
+   * Where the dragged widget would land in another grid, when the pointer is
+   * over one.
+   *
+   * A drag only sends `place` while the widget stays put, so without this a
+   * widget cannot leave its container -- dropping a tile below its group just
+   * grows the group. The destination is whichever grid is under the pointer,
+   * and the landing is computed in *its* units: the origin the gesture
+   * started from means nothing there.
+   */
+  const landingFor = useCallback(
+    (current: Gesture, clientX: number, clientY: number) => {
+      if (current.kind !== 'move') return undefined;
+      const hit = drag.gridAt(clientX, clientY);
+      const over = hit?.grid;
+      if (!hit || !over || over.parentId === parentId) return undefined;

Review Comment:
   Dragging a group can target its own inner grid: `landingFor` only skips the 
grid the widget came from (`over.parentId === parentId`), but `drag.gridAt` 
also returns the grid whose `parentId` is the dragged group itself. Move a 
group down or sideways one cell while the pointer is still inside its original 
footprint and its own inner grid is the innermost match, so release sends 
`move(group, parent=group)`, which the server refuses, and the otherwise valid 
reposition snaps back. Should a grid whose `parentId` is `current.nodeId` (or 
sits inside the dragged subtree) be ignored here, with a test that drags a 
populated group within its own bounds?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,1317 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview One grid of a canvas, with drag and resize built in.
+ *
+ * There is no edit mode: a user who may edit gets a drag grip and a resize
+ * corner on each widget, on the canvas as they view it. A user who may not
+ * gets the same grid with no handles. Dragging never covers the widget's own
+ * surface, so a filter or chart underneath stays clickable.
+ *
+ * While a gesture is live the widget follows the pointer and a dashed outline
+ * marks the cells it would take; releasing persists exactly those cells.
+ */
+
+import {
+  PointerEvent as ReactPointerEvent,
+  KeyboardEvent as ReactKeyboardEvent,
+  ReactNode,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  columnWidth,
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  placementAt,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import {
+  insertIndex,
+  previewGrid,
+  resolveGrid,
+  swapPartner,
+  swapped,
+} from './resolveGrid';
+import { useCanvasDrag } from './useCanvasDrag';
+import { CanvasWidgetChromeContext } from './widgetChrome';
+import type { GridLayout, GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Where the grid sat in the viewport when the widget was grabbed.
+   *
+   * Pointer coordinates are relative to the viewport, so scrolling
+   * mid-gesture moves the content under a still pointer and the drag would
+   * report no movement at all. The grid's own position is what this measures
+   * against rather than the page's scroll offset: the canvas does not
+   * necessarily scroll the window -- with the chat panel open the shell is
+   * locked and the content scrolls inside it -- and the grid's rect moves for
+   * any scrolling ancestor, the window included.
+   */
+  startLeft: number;
+  startTop: number;
+  dx: number;
+  dy: number;
+  /**
+   * Where the pointer was last seen. Scrolling moves the content without
+   * moving the pointer, so recomputing then needs the position it held.
+   */
+  lastX: number;
+  lastY: number;
+  /**
+   * Where the widget sat when it was grabbed. The gesture resolves against
+   * this, not against the current placement: a write landing mid-gesture (a
+   * sibling's drop pushing this one down, or a draft poll) would otherwise
+   * move the ground under the pointer and persist the drop relative to a row
+   * the user never aimed at.
+   */
+  origin: GridPlacement;
+  /**
+   * Whether this gesture is actually dragging yet. One begun on a handle is
+   * armed at once; one begun on the widget body waits until the pointer has
+   * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click
+   * reaches the widget underneath instead of nudging it.
+   */
+  armed: boolean;
+  /**
+   * The element the press landed on, so a gesture that arms later can take
+   * the pointer from it. Held rather than looked up when it arms: by then the
+   * pointer may be over something else entirely.
+   */
+  capture: HTMLElement;
+  /**
+   * Where inside the widget it was grabbed, in pixels. A drop into another
+   * grid lands the widget relative to this, so it keeps its hold on the
+   * pointer instead of jumping its own corner under it.
+   */
+  grabX: number;
+  grabY: number;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+  /** The grid's height when it was grabbed; see `Grid`'s `heldHeight`. */
+  heldHeight: number;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/**
+ * Presses already claimed by a grid, so one that bubbles through nested grids
+ * starts exactly one gesture: the innermost grid's.
+ */
+const claimedPresses = new WeakSet<Event>();
+
+/**
+ * Controls that handle press-and-drag themselves, so a press on one must not
+ * also move the widget: a filter's slider, a form control, a link, a handle
+ * for reordering something inside a widget. `data-canvas-no-drag` lets a
+ * widget opt anything else out.
+ */
+const OWN_GESTURE = [
+  'input',
+  'textarea',
+  'select',
+  'button',
+  'a[href]',
+  '[role="slider"]',
+  '[role="button"]',
+  '[role="scrollbar"]',
+  '[contenteditable=""]',
+  '[contenteditable="true"]',
+  '[draggable="true"]',
+  '[data-canvas-no-drag]',
+].join(',');
+
+/**
+ * Whether a press landed on a scrollbar rather than inside the content.
+ *
+ * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a
+ * widget tall enough to scroll -- a table, usually -- would otherwise be
+ * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop
+ * short of the scrollbar, so a press past either is on it; the comparison is
+ * skipped for an element that isn't overflowing, which has no scrollbar to
+ * press. A left-hand scrollbar (RTL) is not detected.
+ */
+const onScrollbar = (
+  target: Element,
+  clientX: number,
+  clientY: number,
+): boolean => {
+  const box = target.getBoundingClientRect();
+  return (
+    (target.scrollHeight > target.clientHeight &&
+      clientX > box.left + target.clientWidth) ||
+    (target.scrollWidth > target.clientWidth &&
+      clientY > box.top + target.clientHeight)
+  );
+};
+
+/** Whether something inside the widget owns this press, so the grid doesn't. 
*/
+const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => {
+  const { target } = event;
+  if (!(target instanceof Element)) return false;
+  const owner = target.closest(OWN_GESTURE);
+  // Bounded to this widget: a control that happens to sit above the grid in
+  // the page isn't what the press landed on.
+  return (
+    (owner !== null && event.currentTarget.contains(owner)) ||
+    onScrollbar(target, event.clientX, event.clientY)
+  );
+};
+
+/**
+ * A gesture's movement across the grid, scrolling included.
+ *
+ * `left`/`top` are where the grid sits in the viewport now. Content scrolling
+ * under a still pointer moves the grid's rect, and the widget has travelled by
+ * that much relative to the grid even though the pointer has not moved.
+ */
+const travelled = (
+  gesture: Gesture,
+  clientX: number,
+  clientY: number,
+  left: number,
+  top: number,
+) => ({
+  dx: clientX - gesture.startX - (left - gesture.startLeft),
+  dy: clientY - gesture.startY - (top - gesture.startTop),
+});
+
+const Grid = styled.div<GridMetrics & { heldHeight?: number }>`
+  display: grid;
+  grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr));
+  grid-auto-rows: ${({ rowUnit }) => rowUnit}px;
+  gap: ${({ gap }) => gap}px;
+  /*
+   * Held at the height it had when the gesture started, for as long as the
+   * gesture lasts.
+   *
+   * The preview reflows the grid on every move, and dragging a widget up can
+   * leave it needing fewer rows. Shrinking it while the view is scrolled near
+   * the bottom makes the browser clamp the scroll position, which slides the
+   * content under a still pointer -- and since travel is measured against the
+   * grid's own position, that feeds straight back into the preview, which
+   * reflows again. The result is a drag that fights the user, visible as the
+   * page scrolling by itself. A floor costs nothing: the grid may still grow,
+   * which is what dragging past the end needs.
+   */
+  ${({ heldHeight }) =>
+    heldHeight &&
+    `
+    min-height: ${heldHeight}px;
+    /*
+     * And the browser keeps its hands off the scroll position.
+     *
+     * Scroll anchoring adjusts scrollTop when content above the viewport
+     * changes size, to hold the visible part steady. Dragging a widget
+     * upwards reorders everything above it on every move, so the browser
+     * corrects the scroll, which moves the grid, which is what travel is
+     * measured against -- so the preview moves, which reflows again. The
+     * drag runs away on its own, and only once the target is off-screen,
+     * because that is when there is anything above to anchor to.
+     */
+    overflow-anchor: none;
+  `}
+`;
+
+const GridItem = styled.div<{
+  placement?: GridPlacement;
+  dragging?: boolean;
+  offset?: { x: number; y: number };
+}>`
+  ${({ theme, placement, dragging, offset }) => css`
+    min-width: 0;
+    min-height: 0;
+    position: relative;
+    ${
+      placement &&
+      css`
+        grid-column: ${placement.col} / span ${placement.colSpan};
+        grid-row: ${placement.row} / span ${placement.rowSpan};
+      `
+    }
+    ${
+      dragging &&
+      offset &&
+      css`
+        /* Follow the pointer without reflowing the grid under it. */
+        transform: translate(${offset.x}px, ${offset.y}px);
+        z-index: 2;
+        opacity: 0.85;
+        /*
+         * A widget draws no background of its own, so at less than full
+         * opacity the neighbours and the drop outline show through the one
+         * being dragged and it reads as sliding underneath them. The fill and
+         * the shadow are what make it read as lifted.
+         */
+        background: ${theme.colorBgContainer};
+        box-shadow: ${theme.boxShadowSecondary};
+        border-radius: ${theme.borderRadius}px;
+      `
+    }
+  `}
+`;
+
+/**
+ * Where the widget being dragged will land. No error state: the neighbours
+ * are already rendered where they will end up, so an overlap shows as them
+ * moving aside rather than as a warning colour here.
+ */
+const DropPreview = styled.div<{ placement: GridPlacement }>`
+  ${({ theme, placement }) => css`
+    grid-column: ${placement.col} / span ${placement.colSpan};
+    grid-row: ${placement.row} / span ${placement.rowSpan};
+    border: 2px dashed ${theme.colorPrimary};
+    border-radius: ${theme.borderRadius}px;
+    background: ${theme.colorPrimaryBg};
+    pointer-events: none;
+    z-index: 1;
+  `}
+`;
+
+/**
+ * Wraps a widget so its handles can sit on top of it. The handles stay hidden
+ * until the pointer is over the widget or a handle has focus, so a canvas at
+ * rest looks the same whether or not the user may edit it.
+ *
+ * Only the innermost hovered widget shows its handles and its outline:
+ * `:hover` matches every ancestor, so a container would otherwise light up
+ * alongside whichever of its children the pointer is actually over.
+ */
+const Interactive = styled.div<{ showBounds?: boolean }>`
+  ${({ theme, showBounds }) => css`
+    height: 100%;
+    position: relative;
+    border-radius: ${theme.borderRadius}px;
+    transition: box-shadow ${theme.motionDurationMid};
+
+    ${
+      showBounds &&
+      css`
+        /*
+       * Every widget shows its edges while a drag is running, containers
+       * included. A drop target you cannot see is one you cannot aim at, and
+       * revealing a widget only once the pointer is near it tells you where
+       * it is just as you arrive.
+       */
+        box-shadow: 0 0 0 1px ${theme.colorBorderSecondary};
+      `
+    }
+
+    /* Direct children only: hovering a container must not reveal the
+       handles of every widget nested inside it. */
+    & > .canvas-handle {
+      opacity: 0;
+      /*
+       * Transparent is not absent: without this a hidden handle still wins
+       * the hit test over whatever it happens to sit above.
+       */
+      pointer-events: none;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) {
+      /* Borderless widgets (a metric tile, say) otherwise give no clue which
+         widget the handles belong to. */
+      box-shadow: 0 0 0 1px ${theme.colorBorder};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) > .canvas-handle,
+    & > .canvas-handle:focus-visible {
+      opacity: 1;
+      pointer-events: auto;
+    }
+
+    /* A widget's own chrome appears on the same terms as the handles, and
+       stays while its menu is open. */
+    & > .canvas-chrome {
+      opacity: 0;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) > .canvas-chrome,
+    & > .canvas-chrome:focus-within,
+    & > .canvas-chrome:has(.ant-dropdown-open) {
+      opacity: 1;
+    }
+  `}
+`;
+
+/** Step between a container's handles and its children's, in size units. */
+const HANDLE_STEP = 8;
+/** Size units a handle itself needs, so an offset can leave room for it. */
+const HANDLE_BOX = 7;
+
+/**
+ * How far a nested grid's handles sit from the widget's edge.
+ *
+ * Nesting steps the offset so a container's handle never lands exactly on its
+ * child's, but it is clamped to the widget's own rendered box: pushing past
+ * that would put the handle over the *next* widget, where it would take
+ * presses meant for that one.
+ */
+const handleOffset = (
+  sizeUnit: number,
+  depth: number,
+  extent: number,
+): number => {
+  const room = Math.max(sizeUnit, extent - sizeUnit * HANDLE_BOX);
+  return Math.min(sizeUnit * (1 + depth * HANDLE_STEP), room);
+};
+
+/**
+ * Where a widget's top-right controls sit: the grip, and the menu beside it.
+ *
+ * Flush with the top on the root grid, where the resize corners are too.
+ * Nested, it steps down to clear the controls of the container around it --
+ * a container's box begins where its first child's does, and the child's
+ * paint above, so sharing the spot makes the container unreachable.
+ */
+export const handleTop = (
+  sizeUnit: number,
+  depth: number,
+  extent: number,
+): number => (depth === 0 ? 0 : handleOffset(sizeUnit, depth, extent));
+
+/**
+ * The widget itself, kept inside the cells it was given.
+ *
+ * A widget renders at its natural height — a table with every row, say — so
+ * without this it spills past its own box and over whatever is below. The
+ * handles are siblings rather than children, so they stay pinned to the box
+ * while the content scrolls behind them.
+ */
+const Content = styled.div<{ editable: boolean }>`
+  ${({ editable }) => css`
+    height: 100%;
+    overflow: auto;
+    /*
+     * A stacking context of its own, so a widget's internal layering stays
+     * inside it rather than competing with the canvas. An antd table's
+     * sticky header sits at columns * 2 + 3 -- nine for three columns, and
+     * higher with every column it gains -- so it would otherwise paint over
+     * the handles and the drop outline, which sit at 3 and 1 and cannot
+     * outbid a number with no ceiling. Isolated, the header can only reach
+     * the top of this box.
+     */
+    isolation: isolate;
+    ${
+      editable &&
+      css`
+        /*
+       * Dragging across text would otherwise select it and move the widget at
+       * once. In a plan the drag wins -- arranging is what a plan is for --
+       * and the published canvas selects normally.
+       */
+        user-select: none;
+
+        /*
+         * While a widget inside this one is being *moved*, stop clipping. A
+         * child dragged towards the edge of its container is leaving the
+         * container's box, and scrolling it back into view is the opposite of
+         * what the gesture means -- it would cut the widget off at the edge
+         * and grow a scrollbar. Only an ancestor matches: the dragged widget's
+         * own content does not contain its grid cell.
+         *
+         * A resize is not a move: the widget stays inside its container, so
+         * unclipping gains nothing and costs plenty. Dropping the clip makes
+         * every scrolling widget inside render at its full height for the
+         * length of the gesture, which for a table means its rows -- and its
+         * sticky header -- spilling out over whatever is below.
+         */
+        &:has([data-dragging='true']) {
+          overflow: visible;
+        }
+      `
+    }
+  `}
+`;
+
+/**
+ * Where a widget's own controls go: over the widget, outside the box its
+ * content scrolls in.
+ *
+ * Transparent to the pointer, so it does not shadow the widget beneath it;
+ * what it holds takes presses for itself.
+ */
+const Chrome = styled.div<{ offset: number }>`
+  ${({ theme, offset }) => css`
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+
+    .canvas-widget-controls {
+      position: absolute;
+      /*
+       * The same step down the handles take, so the controls stay level with
+       * the grip at every depth. Nested, the grip moves clear of the grip of
+       * the container around it; leaving the menu behind splits what should
+       * read as one row.
+       */
+      top: ${offset}px;
+      /*
+       * Clear of the widget's right edge, where both the resize handle and a
+       * scrollbar live. See WithQuery, which uses the same inset when it
+       * renders in place.
+       */
+      right: ${theme.sizeUnit * 5}px;
+      pointer-events: auto;
+    }
+  `}
+`;
+
+/**
+ * A widget's content, and the node its chrome renders into.
+ *
+ * The two are siblings so chrome escapes the content's scrolling, clipping
+ * and stacking; see `CanvasWidgetChromeContext`.
+ */
+function WidgetBody({
+  editable,
+  offset,
+  children,
+}: {
+  editable: boolean;
+  /** Where the handles sit, so the chrome can sit level with them. */
+  offset: number;
+  children: ReactNode;
+}) {
+  const [chrome, setChrome] = useState<HTMLDivElement | null>(null);
+  return (
+    <>
+      <Content editable={editable}>
+        <CanvasWidgetChromeContext.Provider value={chrome}>
+          {children}
+        </CanvasWidgetChromeContext.Provider>
+      </Content>
+      <Chrome ref={setChrome} className="canvas-chrome" offset={offset} />
+    </>
+  );
+}
+
+const Grip = styled.button<{ depth: number; extent: number }>`
+  ${({ theme, depth, extent }) => css`
+    position: absolute;
+    /*
+     * On the root grid the grip sits flush with the top, where the widget's
+     * own controls and the resize corners already are, so the three read as
+     * one row. Nested, it steps down to clear the grip of the widget inside
+     * it -- a container's box begins where its first child's does, and the
+     * child's grip paints above, so sharing the spot makes the container
+     * ungrabbable.
+     */
+    top: ${handleTop(theme.sizeUnit, depth, extent)}px;
+    /* Deeper handles above shallower ones: the innermost wins a shared spot. 
*/
+    z-index: ${3 + depth};
+    /*
+     * Left of both the north-east resize corner (4 units at the corner) and a
+     * widget's own controls, which step aside to 5 units while a plan is open
+     * and are 8 units wide (see WithQuery's .canvas-widget-controls). These
+     * handles sit above them in the stack, so overlapping would swallow
+     * presses meant for the menu.
+     */
+    right: ${theme.sizeUnit * 14}px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    /* The same box as the controls button beside it, so the two line up. */
+    width: ${theme.sizeUnit * 8}px;
+    height: ${theme.sizeUnit * 8}px;
+    padding: 0;
+    border: none;
+    background: none;
+    color: ${theme.colorTextSecondary};
+    cursor: grab;
+    touch-action: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  `}
+`;
+
+/** Human-readable names for the four corners, for the handle labels. */
+const CORNER_NAMES: Record<ResizeCorner, () => string> = {
+  nw: () => t('top left'),
+  ne: () => t('top right'),
+  sw: () => t('bottom left'),
+  se: () => t('bottom right'),
+};
+
+const ResizeCorner = styled.button<{
+  corner: ResizeCorner;
+  depth: number;
+  extent: number;
+}>`
+  ${({ theme, corner, depth, extent }) => {
+    const west = corner === 'nw' || corner === 'sw';
+    const north = corner === 'nw' || corner === 'ne';
+    /*
+     * Stepped like the grip: a container's box shares its edges with its
+     * first and last child's, and the container's handles paint later, so
+     * sharing the pixel would make the child's corner unreachable.
+     */
+    const inset =
+      depth === 0 ? 0 : handleOffset(theme.sizeUnit, depth - 1, extent);
+    return css`
+      position: absolute;
+      ${west ? 'left' : 'right'}: ${inset}px;
+      ${north ? 'top' : 'bottom'}: ${inset}px;
+      z-index: ${3 + depth};
+      width: ${theme.sizeUnit * 4}px;
+      height: ${theme.sizeUnit * 4}px;
+      padding: 0;
+      border: none;
+      background: transparent;
+      cursor: ${west === north ? 'nwse-resize' : 'nesw-resize'};
+      touch-action: none;
+
+      /* A two-line corner, the conventional resize affordance. */
+      &::after {
+        content: '';
+        position: absolute;
+        ${west ? 'left' : 'right'}: ${theme.sizeUnit / 2}px;
+        ${north ? 'top' : 'bottom'}: ${theme.sizeUnit / 2}px;
+        width: ${theme.sizeUnit * 2}px;
+        height: ${theme.sizeUnit * 2}px;
+        border-${west ? 'left' : 'right'}: 2px solid 
${theme.colorTextTertiary};
+        border-${north ? 'top' : 'bottom'}: 2px solid 
${theme.colorTextTertiary};
+      }
+    `;
+  }}
+`;
+
+/** The grid's content width, kept current as the viewport changes. */
+function useElementWidth(): [(element: HTMLDivElement | null) => void, number] 
{
+  const [element, setElement] = useState<HTMLDivElement | null>(null);
+  const [width, setWidth] = useState(0);
+
+  useEffect(() => {
+    if (!element) return undefined;
+    const measure = () => setWidth(element.clientWidth);
+    measure();
+    if (typeof ResizeObserver === 'undefined') return undefined;
+    const observer = new ResizeObserver(measure);
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [element]);
+
+  return [setElement, width];
+}
+
+export interface CanvasGridSurfaceProps {
+  childIds: string[];
+  columns: number;
+  gap: number;
+  rowUnit: number;
+  placements: Record<string, GridPlacement>;
+  /**
+   * Each child's stored layout. The preview resolves from these, as the
+   * server does: an auto-placed child has no `col`/`row`, and resolving from
+   * the rectangle it currently occupies would pin it there in the preview
+   * while the server still places it freely.
+   */
+  layouts: Record<string, GridLayout>;
+  constraints: Record<string, SpanConstraints>;
+  /** Whether the user may drag and resize these widgets. */
+  editable: boolean;
+  /**
+   * How deeply this grid is nested. A container's box begins where its first
+   * child's does and its handles paint last, so without an offset per level a
+   * container's grip would sit over its child's and swallow the click.
+   */
+  depth: number;
+  /** The container this grid lays out; the root grid's own id for the root. */
+  parentId: string;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  /**
+   * Persist two widgets exchanging places, which is what a drop landing
+   * squarely on one widget does.
+   */
+  onSwap: (
+    nodeId: string,
+    placement: GridPlacement,
+    partnerId: string,
+    partnerPlacement: GridPlacement,
+  ) => void;
+  /**
+   * Persist a widget dragged into this grid from another one. `index` is
+   * where it belongs in this grid's reading order.
+   */
+  onMove: (
+    nodeId: string,
+    parent: string,
+    index: number,
+    placement: GridPlacement,
+  ) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  layouts,
+  constraints,
+  editable,
+  depth,
+  parentId,
+  onPlace,
+  onSwap,
+  onMove,
+  renderNode,
+}: CanvasGridSurfaceProps) {
+  const theme = useTheme();
+  const drag = useCanvasDrag();
+  const [measureGrid, width] = useElementWidth();
+  // Registered so a gesture anywhere on the canvas can find this grid under
+  // the pointer, and so this one can find the others.
+  const [gridNode, setGridNode] = useState<HTMLDivElement | null>(null);
+  // Read by another grid's drag, so it has to be current without this grid
+  // re-registering on every render.
+  const contents = useRef({ childIds, layouts });
+  contents.current = { childIds, layouts };
+  const snapshot = useCallback(() => contents.current, []);
+  useEffect(() => {
+    if (!gridNode) return undefined;
+    return drag.register({
+      parentId,
+      element: gridNode,
+      columns,
+      gap,
+      rowUnit,
+      depth,
+      snapshot,
+    });
+  }, [drag, gridNode, parentId, columns, gap, rowUnit, depth, snapshot]);
+
+  // The grid node, for reading where it sits in the viewport mid-gesture.
+  const grid = useRef<HTMLDivElement | null>(null);
+  const gridRef = useCallback(
+    (node: HTMLDivElement | null) => {
+      grid.current = node;
+      setGridNode(node);
+      measureGrid(node);
+    },
+    [measureGrid],
+  );
+
+  /**
+   * Where the dragged widget would land in another grid, when the pointer is
+   * over one.
+   *
+   * A drag only sends `place` while the widget stays put, so without this a
+   * widget cannot leave its container -- dropping a tile below its group just
+   * grows the group. The destination is whichever grid is under the pointer,
+   * and the landing is computed in *its* units: the origin the gesture
+   * started from means nothing there.
+   */
+  const landingFor = useCallback(
+    (current: Gesture, clientX: number, clientY: number) => {
+      if (current.kind !== 'move') return undefined;
+      const hit = drag.gridAt(clientX, clientY);
+      const over = hit?.grid;
+      if (!hit || !over || over.parentId === parentId) return undefined;
+      const metrics: GridMetrics = {
+        columns: over.columns,
+        gap: over.gap,
+        rowUnit: over.rowUnit,
+      };
+      // Placed against the same box it was found by, so detection and the
+      // drop cannot disagree about where the grid is.
+      const placement = placementAt(
+        clientX,
+        clientY,
+        current.grabX,
+        current.grabY,
+        { left: hit.left, top: hit.top, width: hit.width },
+        metrics,
+        current.origin,
+        columns,
+      );
+      // Its place in the destination's reading order, by the rule the server
+      // applies on the drop.
+      const there = over.snapshot();
+      const resolved = resolveGrid(
+        there.childIds.map(id => there.layouts[id] ?? {}),
+        over.columns,
+      );
+      return {
+        nodeId: current.nodeId,
+        parentId: over.parentId,
+        placement,
+        index: insertIndex(resolved, placement),
+      };
+    },
+    [drag, parentId],
+  );
+
+  /**
+   * Where the grid sits in the viewport. Falls back to the gesture's own
+   * origin when the node has gone, so a vanished grid reports no scrolling
+   * rather than a jump to zero.
+   */
+  const originOf = useCallback((gesture: Gesture) => {
+    const box = grid.current?.getBoundingClientRect();
+    return box
+      ? { left: box.left, top: box.top }
+      : { left: gesture.startLeft, top: gesture.startTop };
+  }, []);
+  const [gesture, setGesture] = useState<Gesture>();
+  // The handlers read the gesture directly, so committing never runs as a
+  // side effect inside a state updater.
+  const live = useRef<Gesture>();
+
+  const update = useCallback((next: Gesture | undefined) => {
+    live.current = next;
+    setGesture(next);
+  }, []);
+
+  /** Where `from` would land given a pointer delta, in whole grid units. */
+  const targetOf = useCallback(
+    (
+      nodeId: string,
+      kind: GestureKind,
+      from: GridPlacement,
+      dx: number,
+      dy: number,
+      corner: ResizeCorner = 'se',
+    ) => {
+      const metrics: GridMetrics = { columns, gap, rowUnit };
+      const delta = width > 0 ? gridDelta(dx, dy, width, metrics) : NO_DELTA;
+      return kind === 'move'
+        ? movedPlacement(from, delta, columns)
+        : resizedPlacement(from, delta, columns, constraints[nodeId], corner);
+    },
+    [width, columns, gap, rowUnit, constraints],
+  );
+
+  /**
+   * Persists `next` for `nodeId`, where `from` is the placement the gesture
+   * resolved against.
+   *
+   * Both no-op checks matter. A gesture that moved nothing resolves back to
+   * `from`, and sending that would write the geometry the widget had when it
+   * was grabbed over anything that landed while the button was held -- a plain
+   * click on a handle would revert a draft poll or another editor's resize.
+   * Comparing with the current placement then drops a gesture that happens to
+   * land exactly where the widget already sits.
+   */
+  const commit = useCallback(
+    (
+      nodeId: string,
+      next: GridPlacement | undefined,
+      from: GridPlacement,
+      kind: GestureKind,
+    ) => {
+      const placement = placements[nodeId];
+      if (!next || !placement) return;
+      if (samePlacement(next, from) || samePlacement(next, placement)) return;
+      // Landing squarely on one widget exchanges places with it: it takes
+      // the slot this one came from. Anything less definite -- empty space,
+      // or straddling two -- still pushes the widgets below it down. Only a
+      // move: growing a widget over its neighbour is not an offer to trade
+      // places with it.
+      const partner =
+        kind === 'move'
+          ? swapPartner(childIds, placements, nodeId, next)
+          : undefined;
+      if (partner) {
+        onSwap(nodeId, next, partner, swapped(placements[partner], from));
+        return;
+      }
+      onPlace(nodeId, next);
+    },
+    [onPlace, onSwap, placements, childIds],
+  );
+
+  const begin =
+    (
+      nodeId: string,
+      kind: GestureKind,
+      armed = true,
+      corner: ResizeCorner = 'se',
+    ) =>
+    (event: ReactPointerEvent<HTMLElement>) => {
+      if (event.button !== 0) return;
+      // React routes events from a portalled child through this handler even
+      // though it renders outside this subtree, so a press inside a modal the
+      // widget opened arrives here looking like a press on the widget. The
+      // DOM knows better than the React tree does.
+      if (
+        !(event.target instanceof Node) ||
+        !event.currentTarget.contains(event.target)
+      ) {
+        return;
+      }
+      // Only a deferred press is checked. A handle press is unambiguous, and
+      // a handle is itself a button, so checking it would reject every one.
+      if (!armed && ownsPress(event)) return;
+      // One gesture at a time. A second finger on another handle also reports
+      // button 0, and letting it replace the live gesture would strand the
+      // first pointer: its move and up events fail the pointerId guards, so
+      // its drag would be dropped without ever being committed.
+      if (live.current) return;
+      const origin = placements[nodeId];
+      if (!origin) return;
+      // A deferred press does not stop propagation -- a click has to reach
+      // the widget -- so it bubbles to this grid and to every grid containing
+      // it, and each would start its own gesture. The innermost runs first
+      // and claims the press; the rest stand down.
+      if (claimedPresses.has(event.nativeEvent)) return;
+      claimedPresses.add(event.nativeEvent);
+      // Pin where every grid is, before any preview starts moving them: a
+      // container has to stay where the user is aiming rather than being
+      // pushed along with the rest of its grid. At the press, because a
+      // handle gesture is already armed by its first move.
+      drag.freeze();
+      // Where the grid sits now, so later moves can tell scrolling apart from
+      // pointer travel.
+      const box = grid.current?.getBoundingClientRect();
+      if (armed) {
+        event.preventDefault();
+        event.stopPropagation();
+        // A handle owns the pointer from the first move. A deferred press
+        // must not, or the release and the click after it would be
+        // retargeted here from whatever was actually pressed.
+        event.currentTarget.setPointerCapture(event.pointerId);
+      }
+      update({
+        nodeId,
+        kind,
+        pointerId: event.pointerId,
+        startX: event.clientX,
+        startY: event.clientY,
+        startLeft: box?.left ?? 0,
+        startTop: box?.top ?? 0,
+        // Measured against where the widget sits in this grid, which is the
+        // only place its origin means anything.
+        grabX:
+          event.clientX -
+          ((box?.left ?? 0) +
+            (origin.col - 1) *
+              (columnWidth(width, { columns, gap, rowUnit }) + gap)),
+        grabY:
+          event.clientY -
+          ((box?.top ?? 0) + (origin.row - 1) * (rowUnit + gap)),
+        dx: 0,
+        dy: 0,
+        lastX: event.clientX,
+        lastY: event.clientY,
+        origin,
+        armed,
+        capture: event.currentTarget,
+        corner,
+        heldHeight: grid.current?.offsetHeight ?? 0,
+      });
+    };
+
+  /**
+   * A live gesture is moved and released from the window, not from the widget
+   * it started on.
+   *
+   * An unarmed body press holds no pointer capture -- it has to let a click
+   * through to the widget -- so its events go wherever the pointer is: over a
+   * neighbour, outside the grid, or onto a child that stops them. None of
+   * those reach the widget that was pressed, so it would never see the
+   * release, and since only one gesture may be live at a time the grid would
+   * then refuse every later drag and resize. The window sees every pointer
+   * event, captured or not, which makes it the one place a gesture can always
+   * be released from. It is also how a fast drag arms at all: the first move
+   * after the press can already be well outside the widget.
+   */
+  const gestureLive = gesture !== undefined;
+  useEffect(() => {
+    if (!gestureLive) return undefined;
+
+    const onPointerMove = (event: PointerEvent) => {
+      const { current } = live;
+      if (!current || current.pointerId !== event.pointerId) return;
+      const at = originOf(current);
+      const moved = travelled(
+        current,
+        event.clientX,
+        event.clientY,
+        at.left,
+        at.top,
+      );
+      /*
+       * A deferred gesture arms only once the *pointer* has really travelled.
+       *
+       * Measured from where the press landed, not from `moved`, which folds
+       * in how far the content has scrolled since. Holding a widget and
+       * wheel-scrolling a few rows without moving the hand would otherwise
+       * put the threshold behind you, so the next pixel of pointer movement
+       * arms a drag the user never began -- and releasing writes it.
+       */
+      const armed =
+        current.armed ||
+        Math.hypot(
+          event.clientX - current.startX,
+          event.clientY - current.startY,
+        ) >= DRAG_THRESHOLD;
+      if (!armed) return;
+      if (!current.armed && current.capture.isConnected) {
+        // It is a drag after all, so take the pointer from here on. Capturing
+        // on the press instead would retarget the release and the click after
+        // it, and the widget's own controls would never fire.
+        current.capture.setPointerCapture(current.pointerId);
+      }
+      update({
+        ...current,
+        ...moved,
+        lastX: event.clientX,
+        lastY: event.clientY,
+        armed,
+      });
+      // Over another grid? Then that grid shows the drop, and this one
+      // reflows as though the widget had already left.
+      drag.setLanding(landingFor(current, event.clientX, event.clientY));
+    };
+
+    /**
+     * Scrolling moves the content under a still pointer, so the widget has
+     * travelled even though no pointer event fires. The release folds that in,
+     * so without this the drop would land somewhere the preview never showed.
+     *
+     * Only an armed gesture follows it: a press held while the user scrolls is
+     * not a drag, and arming on scroll alone would move a widget the user only
+     * meant to scroll past.
+     */
+    const onScroll = () => {
+      const { current } = live;
+      if (!current || !current.armed) return;
+      const at = originOf(current);
+      update({
+        ...current,
+        ...travelled(current, current.lastX, current.lastY, at.left, at.top),
+      });
+    };
+
+    const onUp = (event: PointerEvent) => {
+      const { current } = live;
+      if (!current || current.pointerId !== event.pointerId) return;
+      update(undefined);
+      // Where it landed, from the release event's own position: the pointer
+      // can travel between the last move and the release, and the drop
+      // belongs where the user let go.
+      const landed = current.armed
+        ? landingFor(current, event.clientX, event.clientY)
+        : undefined;
+      drag.setLanding(undefined);
+      drag.release();
+      if (!current.armed) return;
+      if (landed) {
+        // It left its container, which the server takes as a different
+        // operation: the layout alone cannot say where a widget now lives.
+        onMove(landed.nodeId, landed.parentId, landed.index, landed.placement);
+        return;
+      }
+      const at = originOf(current);
+      const { dx, dy } = travelled(
+        current,
+        event.clientX,
+        event.clientY,
+        at.left,
+        at.top,
+      );
+      commit(
+        current.nodeId,
+        targetOf(
+          current.nodeId,
+          current.kind,
+          current.origin,
+          dx,
+          dy,
+          current.corner,
+        ),
+        current.origin,
+        current.kind,
+      );
+    };
+
+    // Only the pointer that owns the gesture may cancel it, so a second
+    // pointer going away elsewhere doesn't discard this one.
+    const onCancel = (event: PointerEvent) => {
+      const { current } = live;
+      if (!current || current.pointerId !== event.pointerId) return;
+      update(undefined);
+      drag.setLanding(undefined);
+      drag.release();
+    };
+
+    // Capture phase, so these run before anything in the document: a widget
+    // that stops `pointerup` for its own reasons would otherwise keep the
+    // release from ever reaching here.
+    window.addEventListener('pointermove', onPointerMove, true);
+    window.addEventListener('pointerup', onUp, true);
+    window.addEventListener('pointercancel', onCancel, true);
+    // Capture phase for `scroll` as well, which does not bubble: a listener
+    // here still sees a scroll on any element on its way down.
+    window.addEventListener('scroll', onScroll, true);
+    return () => {
+      window.removeEventListener('pointermove', onPointerMove, true);
+      window.removeEventListener('pointerup', onUp, true);
+      window.removeEventListener('pointercancel', onCancel, true);
+      window.removeEventListener('scroll', onScroll, true);
+    };
+  }, [gestureLive, commit, targetOf, update, drag, landingFor, onMove]);
+
+  // A gesture whose node stops being placeable, or that editing has stopped
+  // allowing, can never be released by its own handle: that handle is gone,
+  // so its up and cancel events never fire. Without this the gesture would
+  // persist, leaving the drop preview on screen and -- because only one
+  // gesture may be live at a time -- blocking every later drag and resize.
+  useEffect(() => {
+    const { current } = live;
+    if (!current) return;
+    // `childIds` as well as `placements`: placements are canvas-wide, so a
+    // node moved into another container is still placed, just not here -- and
+    // its handle has unmounted, so it can never release the gesture itself.
+    if (
+      !editable ||
+      !placements[current.nodeId] ||
+      !childIds.includes(current.nodeId)
+    ) {
+      update(undefined);
+    }
+  }, [editable, placements, childIds, update]);
+
+  /**
+   * Arrow keys do what dragging that handle does, one cell at a time, so the
+   * layout is reachable without a pointer: the grip moves the widget, the
+   * resize corner resizes it.
+   */
+  const onKeyDown =
+    (nodeId: string, kind: GestureKind, corner: ResizeCorner = 'se') =>
+    (event: ReactKeyboardEvent<HTMLButtonElement>) => {
+      const steps: Record<string, [number, number]> = {
+        ArrowLeft: [-1, 0],
+        ArrowRight: [1, 0],
+        ArrowUp: [0, -1],
+        ArrowDown: [0, 1],
+      };
+      const step = steps[event.key];
+      if (!step) return;
+      event.preventDefault();
+      const placement = placements[nodeId];
+      if (!placement) return;
+      const delta = { cols: step[0], rows: step[1] };
+      commit(
+        nodeId,
+        kind === 'move'
+          ? movedPlacement(placement, delta, columns)
+          : resizedPlacement(
+              placement,
+              delta,
+              columns,
+              constraints[nodeId],
+              corner,
+            ),
+        placement,
+        kind,
+      );
+    };
+
+  const preview = gesture?.armed
+    ? targetOf(
+        gesture.nodeId,
+        gesture.kind,
+        gesture.origin,
+        gesture.dx,
+        gesture.dy,
+        gesture.corner,
+      )
+    : undefined;
+  // A drag that has wandered into another grid. The one it came from shows
+  // the widget gone; the one under the pointer shows it arriving.
+  const { landing } = drag;
+  const leaving = Boolean(gesture?.armed && landing?.nodeId === 
gesture.nodeId);
+  const arriving = landing?.parentId === parentId ? landing : undefined;
+
+  // Where every widget sits for the layout being previewed. Runs the same
+  // rules the server will apply on the drop, so the neighbours move aside
+  // under the pointer and nothing jumps once the write lands.
+  const shown = (() => {
+    if (arriving) {
+      // Resolved with the incoming widget in place, so this grid's children
+      // make room for it before the drop rather than after.
+      const withIt = [...childIds];
+      withIt.splice(arriving.index, 0, arriving.nodeId);
+      return previewGrid(
+        withIt,
+        { ...layouts, [arriving.nodeId]: arriving.placement },
+        placements,
+        arriving.nodeId,
+        arriving.placement,
+        columns,
+      );
+    }
+    if (leaving) {
+      // Resolved without it: it belongs to another grid now.
+      const without = childIds.filter(id => id !== gesture?.nodeId);
+      const resolved = resolveGrid(
+        without.map(id => layouts[id] ?? {}),
+        columns,
+      );
+      return Object.fromEntries(
+        without.map((id, index) => [id, resolved[index]]),
+      );
+    }
+    if (!preview || !gesture) return placements;
+    // Landing on one widget shows the exchange, so the drop matches what was
+    // on screen a moment before it.
+    const partner = swapPartner(childIds, placements, gesture.nodeId, preview);

Review Comment:
   The live preview offers a swap for every gesture kind, but `commit` only 
swaps for a move. Grow `kpi` at row 1 down over `trend` at row 9 and the 
preview jumps `trend` into row 1 (the slot `kpi` still occupies); on release 
only `place` is sent, so `trend` is pushed below the enlarged `kpi` and the 
saved layout differs from what was on screen. The resize test at line 1625 only 
checks the release callbacks, so it passes with this preview. Should the 
preview skip `swapPartner` unless `gesture.kind === 'move'`, with an assertion 
on `trend`'s row before pointer-up?



##########
superset-frontend/src/features/canvas/resolveGrid.ts:
##########
@@ -0,0 +1,221 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview The grid placement rules, for previewing a drop.
+ *
+ * A deliberate second implementation of `superset/canvas/definition/grid.py`:
+ * the server stays the authority and its answer is what gets stored, but a
+ * drag needs to show where the neighbours will end up *before* the write, so
+ * the same rules have to run locally while the pointer moves.
+ *
+ * The two are kept honest by `grid_cases.json`, which both test suites read —
+ * generated from the server, so a change there that this file doesn't follow
+ * fails on both sides.
+ *
+ * Children resolve in reading order:
+ *
+ * - An explicitly placed child (`col`/`row` set) keeps its column, and is
+ *   pushed straight down until it clears anything resolved before it.
+ * - An auto-placed child (`col`/`row` omitted) takes the first free slot that
+ *   fits its span, scanning from a cursor that only moves forward.
+ *
+ * Reading order decides who yields, so a drop also moves the dragged widget
+ * within it: see `previewGrid`.
+ */
+
+import type { GridLayout, GridPlacement } from './types';
+
+export type { GridLayout };
+
+const cells = (rect: GridPlacement): string[] => {
+  const out: string[] = [];
+  for (let { row } = rect; row < rect.row + rect.rowSpan; row += 1) {
+    for (let { col } = rect; col < rect.col + rect.colSpan; col += 1) {
+      out.push(`${row}:${col}`);
+    }
+  }
+  return out;
+};
+
+const clear = (rect: GridPlacement, occupied: Set<string>): boolean =>
+  cells(rect).every(cell => !occupied.has(cell));
+
+/**
+ * Where each child lands on a grid of `columns` columns, in reading order.
+ *
+ * Mirrors the server's `resolve_grid`. Spans default to the full width and a
+ * single row, and a span wider than the grid is clamped to it.
+ */
+export function resolveGrid(
+  layouts: GridLayout[],
+  columns: number,
+): GridPlacement[] {
+  const occupied = new Set<string>();
+  const resolved: GridPlacement[] = [];
+  let cursorRow = 1;
+  let cursorCol = 1;
+
+  layouts.forEach(layout => {
+    const colSpan = Math.min(layout.colSpan || columns, columns);
+    const rowSpan = layout.rowSpan || 1;
+    let rect: GridPlacement;
+
+    if (layout.col !== undefined && layout.col !== null) {
+      rect = {
+        col: layout.col,
+        row: layout.row as number,
+        colSpan,
+        rowSpan,
+      };
+      while (!clear(rect, occupied)) {
+        rect = { ...rect, row: rect.row + 1 };
+      }
+    } else {
+      let row = cursorRow;
+      let col = cursorCol;
+      for (;;) {
+        if (col + colSpan - 1 > columns) {
+          row += 1;
+          col = 1;
+          // eslint-disable-next-line no-continue
+          continue;
+        }
+        rect = { col, row, colSpan, rowSpan };
+        if (clear(rect, occupied)) break;
+        col += 1;
+      }
+      cursorRow = rect.row;
+      cursorCol = rect.col + colSpan;
+    }
+
+    cells(rect).forEach(cell => occupied.add(cell));
+    resolved.push(rect);
+  });
+
+  return resolved;
+}
+
+/**
+ * Where a node dropped at `target` belongs in `rects`' reading order.
+ *
+ * `rects` is the siblings it lands among, resolved and in reading order,
+ * without the dropped node. Mirrors the server's `insert_index`: an overlap
+ * pushes the *later* child down, so the dropped node goes before the first
+ * sibling it overlaps, or that starts after it by row and then column, and
+ * last if there is no such sibling.
+ */
+export function insertIndex(
+  rects: GridPlacement[],
+  target: GridPlacement,
+): number {
+  const targetCells = new Set(cells(target));
+  const found = rects.findIndex(
+    rect =>
+      cells(rect).some(cell => targetCells.has(cell)) ||
+      rect.row > target.row ||
+      (rect.row === target.row && rect.col >= target.col),
+  );
+  return found === -1 ? rects.length : found;
+}
+
+/**
+ * The placements a grid would settle on if `moving` were dropped at `target`.
+ *
+ * The dragged widget takes `target` and is moved in reading order to sit ahead
+ * of the first sibling it overlaps, or that starts after it (by row, then
+ * column). Reading order is what decides who yields, so without the reorder a
+ * widget dropped onto an earlier sibling would lose and be pushed back under
+ * it -- making it impossible to drop anything between two others.
+ *
+ * This mirrors the reorder the server's `place` operation performs, so the
+ * preview and the write agree.
+ */
+export function previewGrid(
+  childIds: string[],
+  /** Each child's *stored* layout, which is what the server resolves from. */
+  layouts: Record<string, GridLayout>,
+  placements: Record<string, GridPlacement>,
+  moving: string,
+  target: GridPlacement,
+  columns: number,
+): Record<string, GridPlacement> {
+  const others = childIds.filter(nodeId => nodeId !== moving);
+  // Stored layouts, not resolved rectangles: an auto-placed child has no
+  // `col`/`row` of its own, and feeding back the rectangle it happens to
+  // occupy would make it explicit here while the server still auto-places
+  // it. The preview would then disagree with the drop.
+  const otherLayouts = others.map(nodeId => layouts[nodeId]);
+  // A child the caller knows nothing about cannot take part in the preview.
+  if (otherLayouts.some(layout => layout === undefined)) return placements;
+
+  // Where the others sit on their own, which is the order the dropped widget
+  // is inserted into.
+  const before = resolveGrid(otherLayouts as GridLayout[], columns);
+  const ordered = [...others];
+  ordered.splice(insertIndex(before, target), 0, moving);
+
+  const resolved = resolveGrid(
+    ordered.map(nodeId =>
+      nodeId === moving ? target : (layouts[nodeId] as GridLayout),
+    ),
+    columns,
+  );
+  return Object.fromEntries(ordered.map((id, index) => [id, resolved[index]]));
+}
+
+/**
+ * The single widget a drop lands on, if it lands squarely on one.
+ *
+ * Dropping onto one widget exchanges places with it: the dropped widget takes
+ * its slot, and it takes the slot the dropped widget came from. Dragging
+ * upwards already looked like that, but only by coincidence -- the rule is
+ * "insert before what you overlap, push it down", and pushing a widget down
+ * from the top happens to land it where the dragged one started. Pushed down
+ * from lower in the grid it lands somewhere new, so the same gesture read as
+ * a swap in one direction and a shove in the other.
+ *
+ * Nothing, when the drop straddles two widgets or lands on empty space: there
+ * is no single partner to exchange with, so the push-down rule still applies.
+ */
+export function swapPartner(
+  childIds: string[],
+  placements: Record<string, GridPlacement>,
+  moving: string,
+  target: GridPlacement,
+): string | undefined {
+  const landing = new Set(cells(target));
+  const touched = childIds.filter(
+    nodeId =>
+      nodeId !== moving &&
+      placements[nodeId] &&
+      cells(placements[nodeId]).some(cell => landing.has(cell)),
+  );
+  return touched.length === 1 ? touched[0] : undefined;
+}
+
+/** Where two widgets end up once they exchange places. */
+export function swapped(
+  partner: GridPlacement,
+  vacated: GridPlacement,
+): GridPlacement {
+  // Its own size, in the slot the dragged widget left: a swap exchanges
+  // places, not shapes.
+  return { ...partner, col: vacated.col, row: vacated.row };

Review Comment:
   `swapped` keeps the partner's `colSpan` and only copies the dragged widget's 
`col`, so the partner can be written past the right edge. On a 24-column grid, 
drag a 6-wide tile at column 19 onto a 12-wide tile at column 1: the partner is 
sent to column 19 with span 12, ending at column 30. The server then rejects 
the whole swap, so neither widget moves. Should the partner's column be clamped 
to `columns - colSpan + 1` (and the preview use the same result)?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to