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


##########
superset-frontend/src/features/canvas/resolveGrid.ts:
##########
@@ -0,0 +1,139 @@
+/**
+ * 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.
+ */
+
+import type { GridPlacement } from './types';
+
+/** A child's stored layout: any of these may be absent. */
+export interface GridLayout {
+  col?: number;
+  row?: number;
+  colSpan?: number;
+  rowSpan?: number;
+}
+
+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;
+}
+
+/**
+ * The placements a grid would settle on if `moving` were dropped at `target`.
+ *
+ * Children keep their reading order, so the dragged widget pushes the ones
+ * after it down exactly as the server will once the drop is written.
+ */
+export function previewGrid(
+  childIds: string[],
+  placements: Record<string, GridPlacement>,
+  moving: string,
+  target: GridPlacement,
+  columns: number,
+): Record<string, GridPlacement> {
+  const layouts = childIds.map(nodeId =>

Review Comment:
   `previewGrid` feeds each sibling's already-resolved rectangle back into 
`resolveGrid` as if it were a stored layout, so auto-placed siblings become 
explicit in the preview while the server resolves them from their stored (auto) 
layouts. With two full-width auto-placed widgets at rows 1 and 2, dragging the 
first to row 3 previews the second staying at row 2, but after the save the 
server places it at row 1 and the layout jumps. Could the preview take the 
nodes' stored layouts instead of the resolved placements?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.test.tsx:
##########
@@ -0,0 +1,875 @@
+/**
+ * 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, useState } from 'react';
+import { fireEvent, render, screen } from 'spec/helpers/testing-library';
+import CanvasGridSurface from './CanvasGridSurface';
+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 },
+  trend: { col: 1, row: 5, 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,
+) => (
+  <CanvasGridSurface
+    childIds={childIds}
+    columns={24}
+    gap={16}
+    rowUnit={40}
+    placements={live}
+    constraints={constraints}
+    editable={editable}
+    depth={depth}
+    onPlace={onPlace}
+    renderNode={renderNode}
+  />
+);
+
+const renderSurface = ({
+  editable = true,
+  onPlace = jest.fn(),
+  constraints = {} as Record<string, SpanConstraints>,
+  live = placements,
+  childIds = Object.keys(placements),
+  renderNode,
+  depth = 0,
+}: {
+  editable?: boolean;
+  onPlace?: 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),
+  );
+  return Object.assign(onPlace, {
+    container,
+    /** 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, so page scroll is simulated. */
+let pageScrollY = 0;
+const scrollTo = (y: number) => {
+  pageScrollY = y;
+};
+
+beforeEach(() => {
+  pageScrollY = 0;
+});
+
+beforeAll(() => {
+  Object.defineProperty(window, 'scrollY', {
+    configurable: true,
+    get: () => pageScrollY,
+  });
+  Object.defineProperty(window, 'scrollX', {
+    configurable: true,
+    get: () => 0,
+  });
+  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' }),
+  ).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: 6,
+    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: 6,
+    colSpan: 24,
+    rowSpan: 8,
+  });
+});
+
+const gripTop = () => getComputedStyle(handles('Move widget')[0]).top;
+
+test('a root grid puts its handles at the top of each widget', () => {
+  renderSurface();
+
+  expect(gripTop()).toBe('4px');
+});
+
+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 the page scrolls under it', () => {
+  // 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.
+  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('neighbours move aside while the pointer is still down', () => {
+  // Live reflow: the same rules the server will apply on the drop run on
+  // every move, so nothing jumps once the write lands.
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+  const before = rowOf(onPlace.container, 'trend');
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  // 'kpi' is 4 rows tall at row 1; four rows down puts it on 'trend'.
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 4 });
+
+  // 'kpi' comes first in reading order, so it takes row 5 and 'trend' is
+  // pushed below it -- visibly, before the drop.
+  expect(rowOf(onPlace.container, 'trend')).not.toBe(before);
+  expect(rowOf(onPlace.container, 'trend')).toContain('9');
+});
+
+test('neighbours go back when a gesture is abandoned', () => {
+  const onPlace = renderSurface();
+  const handle = handles('Move widget')[0];
+  const before = rowOf(onPlace.container, 'trend');
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 4 });
+  pointer(handle, 'pointercancel');
+
+  expect(rowOf(onPlace.container, 'trend')).toBe(before);
+});
+
+test('the drop outline no longer carries an error state', () => {
+  // Overlap is shown by the neighbours moving, not by colouring this red.
+  renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 4 });
+
+  const outline = screen.getByTestId('canvas-drop-preview');
+  expect(outline).not.toHaveAttribute('data-colliding');
+  expect(outline).not.toHaveAttribute('title');
+});
+
+const widgetBody = (container: HTMLElement, nodeId: string) =>
+  container.querySelector(
+    `[data-node-id="${nodeId}"] .canvas-interactive`,
+  ) as HTMLElement;
+
+test('the widget body itself can be dragged in a plan', () => {
+  // The grip is small and hover-only, so the whole widget is a drag source
+  // where editing is allowed.
+  const onPlace = renderSurface();
+  const body = widgetBody(onPlace.container, 'kpi');
+
+  pointer(body, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  pointer(body, 'pointermove', { clientX: COL_PITCH * 4, clientY: 0 });
+  pointer(body, 'pointerup', { clientX: COL_PITCH * 4, clientY: 0 });
+
+  expect(onPlace).toHaveBeenCalledWith('kpi', {
+    col: 5,
+    row: 1,
+    colSpan: 6,
+    rowSpan: 4,
+  });
+});
+
+/** Widgets lift while being dragged, which is how "is it dragging" is read. */
+const liftedOpacity = (container: HTMLElement, nodeId: string) =>
+  getComputedStyle(
+    container.querySelector(`[data-node-id="${nodeId}"]`) as HTMLElement,
+  ).opacity;
+
+test('a press on a widget body that barely moves stays a click', () => {

Review Comment:
   This test mocks pointer capture away and only asserts that nothing lifts or 
is placed, so it never checks that the click reaches the widget's own control. 
It stays green while a press on a child button is retargeted to the wrapper. 
Could it render a widget with a button, press and release it without movement, 
and assert the button's `onClick` fires once and `onPlace` is not called?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,666 @@
+/**
+ * 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 } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import { previewGrid } from './resolveGrid';
+import type { GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Page scroll when the widget was grabbed. Pointer coordinates are relative
+   * to the viewport, so scrolling mid-gesture moves the content under a still
+   * pointer; without this the drag would report no movement at all.
+   */
+  startScrollX: number;
+  startScrollY: number;
+  dx: number;
+  dy: 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;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/** A gesture's movement over the page content, scrolling included. */
+const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({
+  dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX),
+  dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY),
+});
+
+const Grid = styled.div<GridMetrics>`
+  display: grid;
+  grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr));
+  grid-auto-rows: ${({ rowUnit }) => rowUnit}px;
+  gap: ${({ gap }) => gap}px;
+`;
+
+const GridItem = styled.div<{
+  placement?: GridPlacement;
+  dragging?: boolean;
+  offset?: { x: number; y: number };
+}>`
+  ${({ 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;
+      `
+    }
+  `}
+`;
+
+/**
+ * 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`
+  ${({ theme }) => css`
+    height: 100%;
+    position: relative;
+    border-radius: ${theme.borderRadius}px;
+    transition: box-shadow ${theme.motionDurationMid};
+
+    /* 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;
+    }
+  `}
+`;
+
+/** 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);
+};
+
+/**
+ * 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`
+  height: 100%;
+  overflow: auto;
+`;
+
+const Grip = styled.button<{ depth: number; extent: number }>`
+  ${({ theme, depth, extent }) => css`
+    position: absolute;
+    top: ${handleOffset(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;
+    padding: ${theme.sizeUnit / 2}px;
+    border: 1px solid ${theme.colorBorder};
+    border-radius: ${theme.borderRadiusSM}px;
+    background: ${theme.colorBgElevated};
+    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>;
+  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;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  constraints,
+  editable,
+  depth,
+  onPlace,
+  renderNode,
+}: CanvasGridSurfaceProps) {
+  const [gridRef, width] = useElementWidth();
+  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) => {
+      const placement = placements[nodeId];
+      if (!next || !placement) return;
+      if (samePlacement(next, from) || samePlacement(next, placement)) return;
+      onPlace(nodeId, next);
+    },
+    [onPlace, placements],
+  );
+
+  const begin =
+    (
+      nodeId: string,
+      kind: GestureKind,
+      armed = true,
+      corner: ResizeCorner = 'se',
+    ) =>
+    (event: ReactPointerEvent<HTMLElement>) => {
+      if (event.button !== 0) 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 gesture must leave the press alone until it becomes a
+      // drag, or a click on a chart would never reach it.
+      if (armed) {
+        event.preventDefault();
+        event.stopPropagation();
+      }
+      event.currentTarget.setPointerCapture(event.pointerId);

Review Comment:
   `setPointerCapture` runs for the deferred body gesture too (`armed` false), 
so a plain press on anything inside an editable widget is captured by the 
wrapper. `pointerup` and the resulting `click` then target the wrapper rather 
than the button or chart under the pointer. In a draft, clicking the widget's 
More Options button without moving never opens the menu, and chart clicks stop 
reaching the chart. Could the capture wait until the pointer crosses 
`DRAG_THRESHOLD` in `onPointerMove`, so an unmoved press stays an ordinary 
click?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,666 @@
+/**
+ * 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 } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import { previewGrid } from './resolveGrid';
+import type { GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Page scroll when the widget was grabbed. Pointer coordinates are relative
+   * to the viewport, so scrolling mid-gesture moves the content under a still
+   * pointer; without this the drag would report no movement at all.
+   */
+  startScrollX: number;
+  startScrollY: number;
+  dx: number;
+  dy: 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;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/** A gesture's movement over the page content, scrolling included. */
+const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({
+  dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX),
+  dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY),
+});
+
+const Grid = styled.div<GridMetrics>`
+  display: grid;
+  grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr));
+  grid-auto-rows: ${({ rowUnit }) => rowUnit}px;
+  gap: ${({ gap }) => gap}px;
+`;
+
+const GridItem = styled.div<{
+  placement?: GridPlacement;
+  dragging?: boolean;
+  offset?: { x: number; y: number };
+}>`
+  ${({ 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;
+      `
+    }
+  `}
+`;
+
+/**
+ * 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`
+  ${({ theme }) => css`
+    height: 100%;
+    position: relative;
+    border-radius: ${theme.borderRadius}px;
+    transition: box-shadow ${theme.motionDurationMid};
+
+    /* 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;
+    }
+  `}
+`;
+
+/** 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);
+};
+
+/**
+ * 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`
+  height: 100%;
+  overflow: auto;
+`;
+
+const Grip = styled.button<{ depth: number; extent: number }>`
+  ${({ theme, depth, extent }) => css`
+    position: absolute;
+    top: ${handleOffset(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;
+    padding: ${theme.sizeUnit / 2}px;
+    border: 1px solid ${theme.colorBorder};
+    border-radius: ${theme.borderRadiusSM}px;
+    background: ${theme.colorBgElevated};
+    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>;
+  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;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  constraints,
+  editable,
+  depth,
+  onPlace,
+  renderNode,
+}: CanvasGridSurfaceProps) {
+  const [gridRef, width] = useElementWidth();
+  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) => {
+      const placement = placements[nodeId];
+      if (!next || !placement) return;
+      if (samePlacement(next, from) || samePlacement(next, placement)) return;
+      onPlace(nodeId, next);
+    },
+    [onPlace, placements],
+  );
+
+  const begin =
+    (
+      nodeId: string,
+      kind: GestureKind,
+      armed = true,
+      corner: ResizeCorner = 'se',
+    ) =>
+    (event: ReactPointerEvent<HTMLElement>) => {
+      if (event.button !== 0) 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 gesture must leave the press alone until it becomes a
+      // drag, or a click on a chart would never reach it.
+      if (armed) {

Review Comment:
   A deferred body press skips `stopPropagation`, so for a widget inside a 
group the press bubbles to both the child's surface and the group's surface. 
Each has its own `live` ref, so both begin a gesture; the group's wrapper is 
the last to call `setPointerCapture` and wins, so on release the group moves 
instead of the child, and the child's surface keeps a stale `live.current` that 
blocks later drags and resizes in that grid. Should only the innermost surface 
claim the press (for example by marking the event as handled once a surface 
begins a gesture)?



-- 
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