You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/10/08 13:00:41 UTC

(superset) branch alexandrusoare/feat/canvas-flexible-layout created (now 11ef5b4c380)

This is an automated email from the ASF dual-hosted git repository.

alexandrusoare pushed a change to branch alexandrusoare/feat/canvas-flexible-layout
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 11ef5b4c380 adding resize and drag and drop UI interactivity

This branch includes the following new commits:

     new 11ef5b4c380 adding resize and drag and drop UI interactivity

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: adding resize and drag and drop UI interactivity

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

alexandrusoare pushed a commit to branch alexandrusoare/feat/canvas-flexible-layout
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 11ef5b4c380742ddf9b6064ec2db20965d0f6ae0
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Thu Oct 8 15:53:50 2026 +0300

    adding resize and drag and drop UI interactivity
---
 .../src/components/Icons/AntdEnhanced.tsx          |   2 +
 .../src/features/canvas/CanvasGrid.test.tsx        |  38 ++
 .../src/features/canvas/CanvasGrid.tsx             |  58 +--
 .../src/features/canvas/CanvasGridSurface.test.tsx | 260 +++++++++++++
 .../src/features/canvas/CanvasGridSurface.tsx      | 420 +++++++++++++++++++++
 .../src/features/canvas/builtinRenderers.test.tsx  |   7 +
 .../src/features/canvas/gridGeometry.test.ts       | 193 ++++++++++
 .../src/features/canvas/gridGeometry.ts            | 168 +++++++++
 superset-frontend/src/features/canvas/types.ts     |  30 ++
 .../src/features/canvas/useCanvasLayout.test.ts    | 223 +++++++++++
 .../src/features/canvas/useCanvasLayout.ts         | 187 +++++++++
 superset-frontend/src/pages/Canvas/index.tsx       |  63 +++-
 superset/canvas/api.py                             |  29 +-
 superset/canvas/definition/render.py               |  21 +-
 superset/commands/canvas/create.py                 |   8 +-
 superset/config.py                                 |  12 +-
 tests/unit_tests/canvas/api_test.py                |  58 +++
 tests/unit_tests/canvas/filters_test.py            |  34 ++
 tests/unit_tests/canvas/render_test.py             |  73 ++++
 19 files changed, 1836 insertions(+), 48 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
index c98b93a0451..a6aabb09666 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
@@ -86,6 +86,7 @@ import {
   FundProjectionScreenOutlined,
   FunctionOutlined,
   HighlightOutlined,
+  HolderOutlined,
   HomeOutlined,
   InfoCircleOutlined,
   InfoCircleFilled,
@@ -246,6 +247,7 @@ const AntdIcons = {
   GoogleOutlined,
   GroupOutlined,
   HighlightOutlined,
+  HolderOutlined,
   HomeOutlined,
   InfoCircleOutlined,
   InfoCircleFilled,
diff --git a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
index f19f001367e..48936da3df1 100644
--- a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
+++ b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
@@ -21,6 +21,7 @@ import { render, screen } from 'spec/helpers/testing-library';
 import { canvas } from 'src/core';
 import CanvasGrid, { emptyScopeValues, ScopeValues } from './CanvasGrid';
 import { CanvasDefinitionResult, CanvasSettings } from './types';
+import type { CanvasLayout } from './useCanvasLayout';
 
 const settings: CanvasSettings = {
   refresh: { interval: 0, stagger: 0, exempt: [] },
@@ -73,6 +74,8 @@ const result: CanvasDefinitionResult = {
     unknown: 'test.unknown',
   },
   gridColumns: { g: 12 },
+  layoutConstraints: { c1: { minColSpan: 2 } },
+  canEdit: false,
 };
 
 const Chart = ({
@@ -110,10 +113,20 @@ const registrations = [
   canvas.registerWidgetRenderer('test.odd', CrossSource),
 ];
 
+const layoutOf = (
+  gridResult: CanvasDefinitionResult,
+  place = jest.fn(),
+): CanvasLayout => ({
+  placements: gridResult.placements,
+  place,
+  dismissError: jest.fn(),
+});
+
 const renderGrid = (
   values: ScopeValues = emptyScopeValues(),
   onValueChange = jest.fn(),
   gridResult: CanvasDefinitionResult = result,
+  place = jest.fn(),
 ) =>
   render(
     <CanvasGrid
@@ -122,6 +135,7 @@ const renderGrid = (
       values={values}
       onValueChange={onValueChange}
       refreshKeys={{ c1: 2 }}
+      layout={layoutOf(gridResult, place)}
     />,
   );
 afterAll(() => registrations.forEach(registration => registration.dispose()));
@@ -190,3 +204,27 @@ test('setting a cross-filter does nothing while cross-filters are off', () => {
 
   expect(onValueChange).not.toHaveBeenCalled();
 });
+
+test('a user without edit permission gets no drag or resize handles', () => {
+  renderGrid();
+
+  expect(
+    screen.queryByRole('button', { name: 'Move widget' }),
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'Resize widget' }),
+  ).not.toBeInTheDocument();
+});
+
+test('a user with edit permission gets a handle per placed widget', () => {
+  renderGrid(emptyScopeValues(), jest.fn(), { ...result, canEdit: true });
+
+  // Every node with a resolved placement, on the root grid and inside the
+  // nested group, is draggable and resizable.
+  expect(screen.getAllByRole('button', { name: 'Move widget' })).toHaveLength(
+    Object.keys(result.placements).length,
+  );
+  expect(screen.getAllByRole('button', { name: 'Resize widget' })).toHaveLength(
+    Object.keys(result.placements).length,
+  );
+});
diff --git a/superset-frontend/src/features/canvas/CanvasGrid.tsx b/superset-frontend/src/features/canvas/CanvasGrid.tsx
index 99031d082f1..699bd9f5488 100644
--- a/superset-frontend/src/features/canvas/CanvasGrid.tsx
+++ b/superset-frontend/src/features/canvas/CanvasGrid.tsx
@@ -22,7 +22,9 @@ import { t } from '@apache-superset/core/translation';
 import { css, styled } from '@apache-superset/core/theme';
 import { ErrorBoundary } from 'src/components/ErrorBoundary';
 import { useWidgetRenderers } from 'src/core';
-import { CanvasDefinitionResult, GridPlacement } from './types';
+import CanvasGridSurface from './CanvasGridSurface';
+import type { CanvasLayout } from './useCanvasLayout';
+import { CanvasDefinitionResult } from './types';
 
 /** Current values set by nodes, per scope kind, by node id. */
 export type ScopeValues = Record<canvasApi.ScopeKind, Record<string, unknown>>;
@@ -53,30 +55,6 @@ function valuesByNode(
   return byNode;
 }
 
-interface GridMetrics {
-  columns: number;
-  gap: number;
-  rowUnit: number;
-}
-
-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 }>`
-  min-width: 0;
-  min-height: 0;
-  ${({ placement }) =>
-    placement &&
-    css`
-      grid-column: ${placement.col} / span ${placement.colSpan};
-      grid-row: ${placement.row} / span ${placement.rowSpan};
-    `}
-`;
-
 const Stack = styled.div`
   ${({ theme }) => css`
     display: flex;
@@ -112,6 +90,8 @@ export interface CanvasGridProps {
   ) => void;
   /** Per node, how many automatic refreshes have reached it. */
   refreshKeys?: Record<string, number>;
+  /** Placements to render and the write path drag and resize commit through. */
+  layout: CanvasLayout;
 }
 
 /**
@@ -125,17 +105,20 @@ export default function CanvasGrid({
   values,
   onValueChange,
   refreshKeys = {},
+  layout,
 }: CanvasGridProps) {
   const getRenderer = useWidgetRenderers();
   const {
     definition,
-    placements,
     widgetTypes,
     gridColumns,
+    layoutConstraints,
+    canEdit,
     filterScopes,
     crossFilterScopes,
     customizationScopes,
   } = result;
+  const { placements, place } = layout;
   const { gap, rowUnit, columns } = definition.root.layout;
   const { colors, display, crossFilters } = definition.settings;
   const sharedColors = useMemo<canvasApi.CanvasColors>(
@@ -161,18 +144,17 @@ export default function CanvasGrid({
     gridColumnCount: number,
     render: (nodeId: string) => ReactNode,
   ) => (
-    <Grid columns={gridColumnCount} gap={gap} rowUnit={rowUnit}>
-      {childIds.map(childId => (
-        <GridItem
-          key={childId}
-          placement={placements[childId]}
-          data-test="canvas-node"
-          data-node-id={childId}
-        >
-          {render(childId)}
-        </GridItem>
-      ))}
-    </Grid>
+    <CanvasGridSurface
+      childIds={childIds}
+      columns={gridColumnCount}
+      gap={gap}
+      rowUnit={rowUnit}
+      placements={placements}
+      constraints={layoutConstraints}
+      editable={canEdit}
+      onPlace={place}
+      renderNode={render}
+    />
   );
 
   function renderNode(nodeId: string): ReactNode {
diff --git a/superset-frontend/src/features/canvas/CanvasGridSurface.test.tsx b/superset-frontend/src/features/canvas/CanvasGridSurface.test.tsx
new file mode 100644
index 00000000000..6acc81e326b
--- /dev/null
+++ b/superset-frontend/src/features/canvas/CanvasGridSurface.test.tsx
@@ -0,0 +1,260 @@
+/**
+ * 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 { fireEvent, render, screen } from 'spec/helpers/testing-library';
+import CanvasGridSurface from './CanvasGridSurface';
+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 renderSurface = ({
+  editable = true,
+  onPlace = jest.fn(),
+  constraints = {} as Record<string, SpanConstraints>,
+} = {}) => {
+  render(
+    <CanvasGridSurface
+      childIds={Object.keys(placements)}
+      columns={24}
+      gap={16}
+      rowUnit={40}
+      placements={placements}
+      constraints={constraints}
+      editable={editable}
+      onPlace={onPlace}
+      renderNode={nodeId => <div>{`widget ${nodeId}`}</div>}
+    />,
+  );
+  return onPlace;
+};
+
+/**
+ * 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 });
+
+beforeAll(() => {
+  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(handles('Resize widget')[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(handles('Resize widget')[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(handles('Resize widget')[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 preview warns while it covers another widget', () => {
+  renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  // 'kpi' is 6x4 at row 1; four rows down puts it on 'trend' at row 5.
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 4 });
+
+  const preview = screen.getByTestId('canvas-drop-preview');
+  expect(preview).toHaveAttribute('data-colliding', 'true');
+  expect(preview).toHaveAttribute('title', 'This overlaps another widget');
+});
+
+test('the drop preview stays neutral over free space', () => {
+  renderSurface();
+  const handle = handles('Move widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  // Straight right, staying on row 1 where nothing else sits.
+  pointer(handle, 'pointermove', { clientX: COL_PITCH * 8, clientY: 0 });
+
+  const preview = screen.getByTestId('canvas-drop-preview');
+  expect(preview).toHaveAttribute('data-colliding', 'false');
+  expect(preview).not.toHaveAttribute('title');
+});
+
+test('a resize that grows onto a neighbour warns too', () => {
+  renderSurface();
+  const handle = handles('Resize widget')[0];
+
+  pointer(handle, 'pointerdown', { button: 0, clientX: 0, clientY: 0 });
+  // Grow 'kpi' four rows taller, reaching into 'trend'.
+  pointer(handle, 'pointermove', { clientX: 0, clientY: ROW_PITCH * 4 });
+
+  expect(screen.getByTestId('canvas-drop-preview')).toHaveAttribute(
+    'data-colliding',
+    'true',
+  );
+});
diff --git a/superset-frontend/src/features/canvas/CanvasGridSurface.tsx b/superset-frontend/src/features/canvas/CanvasGridSurface.tsx
new file mode 100644
index 00000000000..1cb37b637ee
--- /dev/null
+++ b/superset-frontend/src/features/canvas/CanvasGridSurface.tsx
@@ -0,0 +1,420 @@
+/**
+ * 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,
+  overlappedSiblings,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import type { GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  dx: number;
+  dy: number;
+}
+
+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 would land. Turns red once it covers a
+ * sibling, so the user sees an overlap before releasing rather than being
+ * surprised when the server pushes a widget down to resolve it.
+ */
+const DropPreview = styled.div<{
+  placement: GridPlacement;
+  colliding?: boolean;
+}>`
+  ${({ theme, placement, colliding }) => css`
+    grid-column: ${placement.col} / span ${placement.colSpan};
+    grid-row: ${placement.row} / span ${placement.rowSpan};
+    border: 2px dashed ${colliding ? theme.colorError : theme.colorPrimary};
+    border-radius: ${theme.borderRadius}px;
+    background: ${colliding ? theme.colorErrorBg : 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.
+ */
+const Interactive = styled.div`
+  ${({ theme }) => css`
+    height: 100%;
+    position: relative;
+
+    /* Direct children only: hovering a container must not reveal the
+       handles of every widget nested inside it. */
+    & > .canvas-handle {
+      opacity: 0;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover > .canvas-handle,
+    & > .canvas-handle:focus-visible {
+      opacity: 1;
+    }
+  `}
+`;
+
+const Grip = styled.button`
+  ${({ theme }) => css`
+    position: absolute;
+    top: ${theme.sizeUnit}px;
+    right: ${theme.sizeUnit}px;
+    z-index: 3;
+    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;
+    }
+  `}
+`;
+
+const ResizeCorner = styled.button`
+  ${({ theme }) => css`
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    z-index: 3;
+    width: ${theme.sizeUnit * 4}px;
+    height: ${theme.sizeUnit * 4}px;
+    padding: 0;
+    border: none;
+    background: transparent;
+    cursor: nwse-resize;
+    touch-action: none;
+
+    /* A two-line corner, the conventional resize affordance. */
+    &::after {
+      content: '';
+      position: absolute;
+      right: ${theme.sizeUnit / 2}px;
+      bottom: ${theme.sizeUnit / 2}px;
+      width: ${theme.sizeUnit * 2}px;
+      height: ${theme.sizeUnit * 2}px;
+      border-right: 2px solid ${theme.colorTextTertiary};
+      border-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;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  constraints,
+  editable,
+  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 `nodeId` would land given a pointer or keyboard delta. */
+  const targetOf = useCallback(
+    (nodeId: string, kind: GestureKind, dx: number, dy: number) => {
+      const placement = placements[nodeId];
+      if (!placement) return undefined;
+      const metrics: GridMetrics = { columns, gap, rowUnit };
+      const delta = width > 0 ? gridDelta(dx, dy, width, metrics) : NO_DELTA;
+      return kind === 'move'
+        ? movedPlacement(placement, delta, columns)
+        : resizedPlacement(placement, delta, columns, constraints[nodeId]);
+    },
+    [placements, width, columns, gap, rowUnit, constraints],
+  );
+
+  const commit = useCallback(
+    (nodeId: string, next: GridPlacement | undefined) => {
+      const placement = placements[nodeId];
+      if (next && placement && !samePlacement(next, placement)) {
+        onPlace(nodeId, next);
+      }
+    },
+    [onPlace, placements],
+  );
+
+  const begin =
+    (nodeId: string, kind: GestureKind) =>
+    (event: ReactPointerEvent<HTMLButtonElement>) => {
+      if (event.button !== 0) return;
+      event.preventDefault();
+      event.stopPropagation();
+      event.currentTarget.setPointerCapture(event.pointerId);
+      update({
+        nodeId,
+        kind,
+        pointerId: event.pointerId,
+        startX: event.clientX,
+        startY: event.clientY,
+        dx: 0,
+        dy: 0,
+      });
+    };
+
+  const onPointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
+    const { current } = live;
+    if (!current || current.pointerId !== event.pointerId) return;
+    update({
+      ...current,
+      dx: event.clientX - current.startX,
+      dy: event.clientY - current.startY,
+    });
+  };
+
+  const onPointerUp = (event: ReactPointerEvent<HTMLButtonElement>) => {
+    const { current } = live;
+    if (!current || current.pointerId !== event.pointerId) return;
+    update(undefined);
+    commit(
+      current.nodeId,
+      targetOf(current.nodeId, current.kind, current.dx, current.dy),
+    );
+  };
+
+  const onPointerCancel = () => update(undefined);
+
+  /**
+   * Arrow keys do what dragging does, one cell at a time, so the layout is
+   * reachable without a pointer. Shift resizes instead of moving.
+   */
+  const onKeyDown =
+    (nodeId: string, kind: GestureKind) =>
+    (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]),
+      );
+    };
+
+  const handlers = {
+    onPointerMove,
+    onPointerUp,
+    onPointerCancel,
+  };
+
+  const preview = gesture
+    ? targetOf(gesture.nodeId, gesture.kind, gesture.dx, gesture.dy)
+    : undefined;
+  const covered =
+    preview && gesture
+      ? overlappedSiblings(preview, childIds, placements, gesture.nodeId)
+      : [];
+
+  return (
+    <Grid
+      ref={gridRef}
+      columns={columns}
+      gap={gap}
+      rowUnit={rowUnit}
+      data-test="canvas-grid-surface"
+    >
+      {preview &&
+        gesture &&
+        !samePlacement(preview, placements[gesture.nodeId]) && (
+          <DropPreview
+            placement={preview}
+            colliding={covered.length > 0}
+            data-test="canvas-drop-preview"
+            data-colliding={covered.length > 0}
+            title={
+              covered.length > 0 ? t('This overlaps another widget') : undefined
+            }
+          />
+        )}
+      {childIds.map(childId => {
+        const dragging = gesture?.nodeId === childId;
+        return (
+          <GridItem
+            key={childId}
+            placement={placements[childId]}
+            dragging={dragging && gesture?.kind === 'move'}
+            offset={dragging ? { x: gesture.dx, y: gesture.dy } : undefined}
+            data-test="canvas-node"
+            data-node-id={childId}
+          >
+            {editable && placements[childId] ? (
+              <Interactive>
+                {renderNode(childId)}
+                <Grip
+                  type="button"
+                  className="canvas-handle"
+                  aria-label={t('Move widget')}
+                  data-test="canvas-drag-handle"
+                  onPointerDown={begin(childId, 'move')}
+                  onKeyDown={onKeyDown(childId, 'move')}
+                  {...handlers}
+                >
+                  <Icons.HolderOutlined iconSize="s" />
+                </Grip>
+                <ResizeCorner
+                  type="button"
+                  className="canvas-handle"
+                  aria-label={t('Resize widget')}
+                  data-test="canvas-resize-handle"
+                  onPointerDown={begin(childId, 'resize')}
+                  onKeyDown={onKeyDown(childId, 'resize')}
+                  {...handlers}
+                />
+              </Interactive>
+            ) : (
+              renderNode(childId)
+            )}
+          </GridItem>
+        );
+      })}
+    </Grid>
+  );
+}
diff --git a/superset-frontend/src/features/canvas/builtinRenderers.test.tsx b/superset-frontend/src/features/canvas/builtinRenderers.test.tsx
index fc69400d122..3bb51c48f84 100644
--- a/superset-frontend/src/features/canvas/builtinRenderers.test.tsx
+++ b/superset-frontend/src/features/canvas/builtinRenderers.test.tsx
@@ -63,6 +63,8 @@ const result: CanvasDefinitionResult = {
     kpis: 'group',
   },
   gridColumns: { emea: 24, apac: 24, kpis: 12 },
+  layoutConstraints: {},
+  canEdit: false,
 };
 
 test('renders core containers and markdown without extensions', () => {
@@ -73,6 +75,11 @@ test('renders core containers and markdown without extensions', () => {
       result={result}
       values={emptyScopeValues()}
       onValueChange={jest.fn()}
+      layout={{
+        placements: result.placements,
+        place: jest.fn(),
+        dismissError: jest.fn(),
+      }}
     />,
   );
 
diff --git a/superset-frontend/src/features/canvas/gridGeometry.test.ts b/superset-frontend/src/features/canvas/gridGeometry.test.ts
new file mode 100644
index 00000000000..aea4c71f9dc
--- /dev/null
+++ b/superset-frontend/src/features/canvas/gridGeometry.test.ts
@@ -0,0 +1,193 @@
+/**
+ * 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 {
+  columnWidth,
+  gridDelta,
+  movedPlacement,
+  overlappedSiblings,
+  placementsOverlap,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import type { GridPlacement } from './types';
+
+// A 24-column grid 1000px wide with the canvas defaults: the 23 gaps take
+// 368px, so the columns share 632px — 26.33px each, a 42.33px column pitch.
+// Rows are a flat 40px plus the gap, a 56px row pitch.
+const metrics = { columns: 24, gap: 16, rowUnit: 40 };
+const WIDTH = 1000;
+const COL_PITCH = 632 / 24 + 16;
+
+const placement = (
+  col: number,
+  row: number,
+  colSpan: number,
+  rowSpan: number,
+): GridPlacement => ({ col, row, colSpan, rowSpan });
+
+test('a column is what the gaps leave, shared evenly', () => {
+  expect(columnWidth(WIDTH, metrics)).toBeCloseTo(26.33);
+  // One column and no gaps takes the whole width.
+  expect(columnWidth(WIDTH, { ...metrics, columns: 1 })).toBe(WIDTH);
+});
+
+test('a pointer delta snaps to the nearest whole cell', () => {
+  // Just under half a column pitch rounds back to no movement.
+  expect(gridDelta(20, 0, WIDTH, metrics)).toEqual({ cols: 0, rows: 0 });
+  // Just over half rounds on to the next column.
+  expect(gridDelta(23, 0, WIDTH, metrics)).toEqual({ cols: 1, rows: 0 });
+  expect(gridDelta(COL_PITCH * 3, 56 * 2, WIDTH, metrics)).toEqual({
+    cols: 3,
+    rows: 2,
+  });
+  // Dragging up and left gives negative units.
+  expect(gridDelta(-COL_PITCH, -56, WIDTH, metrics)).toEqual({
+    cols: -1,
+    rows: -1,
+  });
+});
+
+test('an unmeasured grid yields no movement, whatever the pointer did', () => {
+  expect(gridDelta(500, 500, 0, metrics)).toEqual({ cols: 0, rows: 0 });
+  expect(gridDelta(500, 500, -1, metrics)).toEqual({ cols: 0, rows: 0 });
+});
+
+test('a grid too narrow for its gaps still has non-negative columns', () => {
+  // 10px across 24 columns with 16px gaps: the gaps alone want 368px.
+  expect(columnWidth(10, metrics)).toBe(0);
+});
+
+test('moving keeps the widget whole inside the grid', () => {
+  const widget = placement(1, 1, 6, 4);
+
+  expect(movedPlacement(widget, { cols: 3, rows: 2 }, 24)).toEqual(
+    placement(4, 3, 6, 4),
+  );
+  // Stops at the last column that still fits all 6 columns of span.
+  expect(movedPlacement(widget, { cols: 100, rows: 0 }, 24)).toEqual(
+    placement(19, 1, 6, 4),
+  );
+  // Can't be dragged off the top or the left.
+  expect(movedPlacement(widget, { cols: -5, rows: -5 }, 24)).toEqual(
+    placement(1, 1, 6, 4),
+  );
+});
+
+test('moving never changes the spans', () => {
+  const moved = movedPlacement(placement(2, 2, 8, 3), { cols: 4, rows: 1 }, 24);
+
+  expect(moved.colSpan).toBe(8);
+  expect(moved.rowSpan).toBe(3);
+});
+
+test('resizing grows towards the bottom right, stopping at the grid edge', () => {
+  const widget = placement(19, 1, 6, 4);
+
+  expect(resizedPlacement(widget, { cols: -3, rows: 2 }, 24)).toEqual(
+    placement(19, 1, 3, 6),
+  );
+  // Only 6 columns remain from column 19, so the span can't exceed that.
+  expect(resizedPlacement(widget, { cols: 50, rows: 0 }, 24)).toEqual(
+    placement(19, 1, 6, 4),
+  );
+  // A span is always at least one cell.
+  expect(resizedPlacement(widget, { cols: -50, rows: -50 }, 24)).toEqual(
+    placement(19, 1, 1, 1),
+  );
+});
+
+test("resizing honours the widget's declared span limits", () => {
+  const widget = placement(1, 1, 6, 4);
+  const limits = {
+    minColSpan: 4,
+    maxColSpan: 8,
+    minRowSpan: 2,
+    maxRowSpan: 6,
+  };
+
+  expect(
+    resizedPlacement(widget, { cols: -10, rows: -10 }, 24, limits),
+  ).toEqual(placement(1, 1, 4, 2));
+  expect(resizedPlacement(widget, { cols: 10, rows: 10 }, 24, limits)).toEqual(
+    placement(1, 1, 8, 6),
+  );
+});
+
+test('the grid edge wins over a widget maximum that reaches past it', () => {
+  // 20 columns of span would fit the widget's maximum but not the grid.
+  const widget = placement(10, 1, 6, 2);
+
+  expect(
+    resizedPlacement(widget, { cols: 20, rows: 0 }, 24, { maxColSpan: 20 }),
+  ).toEqual(placement(10, 1, 15, 2));
+});
+
+test('placements compare by the cells they take', () => {
+  expect(samePlacement(placement(1, 2, 3, 4), placement(1, 2, 3, 4))).toBe(
+    true,
+  );
+  expect(samePlacement(placement(1, 2, 3, 4), placement(1, 2, 3, 5))).toBe(
+    false,
+  );
+});
+
+test('placements overlap only when they share a cell', () => {
+  const widget = placement(5, 5, 4, 4); // cols 5-8, rows 5-8
+
+  expect(placementsOverlap(widget, placement(8, 8, 2, 2))).toBe(true);
+  // Flush neighbours on each side touch but do not overlap.
+  expect(placementsOverlap(widget, placement(9, 5, 4, 4))).toBe(false);
+  expect(placementsOverlap(widget, placement(1, 5, 4, 4))).toBe(false);
+  expect(placementsOverlap(widget, placement(5, 9, 4, 4))).toBe(false);
+  expect(placementsOverlap(widget, placement(5, 1, 4, 4))).toBe(false);
+  // A placement always overlaps itself.
+  expect(placementsOverlap(widget, widget)).toBe(true);
+});
+
+test('overlapped siblings name what a drop would land on, never itself', () => {
+  const placements = {
+    a: placement(1, 1, 6, 4),
+    b: placement(7, 1, 6, 4),
+    c: placement(1, 5, 24, 4),
+  };
+  const siblings = ['a', 'b', 'c'];
+
+  // Dragging 'a' onto 'b'.
+  expect(
+    overlappedSiblings(placement(7, 1, 6, 4), siblings, placements, 'a'),
+  ).toEqual(['b']);
+  // A wide drop can cover more than one.
+  expect(
+    overlappedSiblings(placement(1, 1, 24, 8), siblings, placements, 'a'),
+  ).toEqual(['b', 'c']);
+  // Free space collides with nothing.
+  expect(
+    overlappedSiblings(placement(13, 1, 6, 4), siblings, placements, 'a'),
+  ).toEqual([]);
+  // An id with no placement is ignored rather than throwing.
+  expect(
+    overlappedSiblings(
+      placement(1, 1, 6, 4),
+      [...siblings, 'gone'],
+      placements,
+      'a',
+    ),
+  ).toEqual([]);
+});
diff --git a/superset-frontend/src/features/canvas/gridGeometry.ts b/superset-frontend/src/features/canvas/gridGeometry.ts
new file mode 100644
index 00000000000..a22631724cb
--- /dev/null
+++ b/superset-frontend/src/features/canvas/gridGeometry.ts
@@ -0,0 +1,168 @@
+/**
+ * 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 Turning pointer movement into grid placement.
+ *
+ * A drag or resize is a pointer delta in pixels; a placement is in whole grid
+ * units. These helpers convert between the two and clamp the result to the
+ * grid and to the widget's declared span limits, so a gesture can only ever
+ * produce a layout the server also accepts.
+ *
+ * Collision resolution is deliberately absent: the server owns push-down (see
+ * `superset/canvas/definition/grid.py`) and returns resolved placements after
+ * the write, so nothing here guesses where other widgets end up.
+ */
+
+import type { GridPlacement, SpanConstraints } from './types';
+
+export interface GridMetrics {
+  columns: number;
+  gap: number;
+  rowUnit: number;
+}
+
+/** A gesture's movement, in whole grid units. */
+export interface GridDelta {
+  cols: number;
+  rows: number;
+}
+
+export const NO_DELTA: GridDelta = { cols: 0, rows: 0 };
+
+const clamp = (value: number, min: number, max: number) =>
+  Math.min(Math.max(value, min), max);
+
+/**
+ * Width in pixels of one column on a grid of `width` pixels.
+ *
+ * Mirrors `grid-template-columns: repeat(columns, minmax(0, 1fr))` with
+ * `gap`: the columns share whatever the gaps leave.
+ */
+export function columnWidth(width: number, metrics: GridMetrics): number {
+  const { columns, gap } = metrics;
+  if (columns <= 0) return 0;
+  // A grid narrower than its own gaps would give a negative column, which
+  // would then invert the direction of every gesture.
+  return Math.max(0, (width - gap * (columns - 1)) / columns);
+}
+
+/**
+ * A pointer delta in pixels as whole grid units, rounded to the nearest cell
+ * so a gesture snaps rather than drifting.
+ */
+export function gridDelta(
+  dx: number,
+  dy: number,
+  width: number,
+  metrics: GridMetrics,
+): GridDelta {
+  // An unmeasured grid has no cells to snap to, so nothing has moved yet.
+  if (width <= 0) return NO_DELTA;
+  const colPitch = columnWidth(width, metrics) + metrics.gap;
+  const rowPitch = metrics.rowUnit + metrics.gap;
+  return {
+    cols: colPitch > 0 ? Math.round(dx / colPitch) : 0,
+    rows: rowPitch > 0 ? Math.round(dy / rowPitch) : 0,
+  };
+}
+
+/**
+ * `placement` moved by `delta`, kept inside the grid.
+ *
+ * The spans don't change, so the widget stays whole: it stops at the right
+ * edge rather than being clipped, and can't move above the first row.
+ */
+export function movedPlacement(
+  placement: GridPlacement,
+  delta: GridDelta,
+  columns: number,
+): GridPlacement {
+  const lastCol = Math.max(1, columns - placement.colSpan + 1);
+  return {
+    ...placement,
+    col: clamp(placement.col + delta.cols, 1, lastCol),
+    row: Math.max(1, placement.row + delta.rows),
+  };
+}
+
+/**
+ * `placement` resized by `delta`, kept inside the grid and within the
+ * widget's declared limits.
+ *
+ * `col`/`row` stay put, so resizing grows or shrinks towards the bottom
+ * right. A column span can't reach past the grid's last column, whatever the
+ * widget's own maximum allows.
+ */
+export function resizedPlacement(
+  placement: GridPlacement,
+  delta: GridDelta,
+  columns: number,
+  constraints: SpanConstraints = {},
+): GridPlacement {
+  const room = columns - placement.col + 1;
+  const maxColSpan = Math.min(constraints.maxColSpan ?? columns, room);
+  // The stored placement already satisfies the widget's minimum, so this
+  // range is non-empty; the guard only keeps it that way if limits change.
+  const minColSpan = Math.min(constraints.minColSpan ?? 1, maxColSpan);
+  const maxRowSpan = constraints.maxRowSpan ?? Number.MAX_SAFE_INTEGER;
+  const minRowSpan = Math.min(constraints.minRowSpan ?? 1, maxRowSpan);
+  return {
+    ...placement,
+    colSpan: clamp(placement.colSpan + delta.cols, minColSpan, maxColSpan),
+    rowSpan: clamp(placement.rowSpan + delta.rows, minRowSpan, maxRowSpan),
+  };
+}
+
+/** Whether two placements describe the same cells. */
+export const samePlacement = (a: GridPlacement, b: GridPlacement): boolean =>
+  a.col === b.col &&
+  a.row === b.row &&
+  a.colSpan === b.colSpan &&
+  a.rowSpan === b.rowSpan;
+
+/** Whether two placements share at least one cell. */
+export const placementsOverlap = (
+  a: GridPlacement,
+  b: GridPlacement,
+): boolean =>
+  a.col < b.col + b.colSpan &&
+  b.col < a.col + a.colSpan &&
+  a.row < b.row + b.rowSpan &&
+  b.row < a.row + a.rowSpan;
+
+/**
+ * The siblings `target` would land on top of, ignoring `moving` itself.
+ *
+ * Used to warn before a drop, not to resolve it: the server decides what
+ * actually happens to an overlap (it pushes whichever widget comes later in
+ * reading order straight down).
+ */
+export function overlappedSiblings(
+  target: GridPlacement,
+  siblingIds: string[],
+  placements: Record<string, GridPlacement>,
+  moving: string,
+): string[] {
+  return siblingIds.filter(nodeId => {
+    if (nodeId === moving) return false;
+    const other = placements[nodeId];
+    return other !== undefined && placementsOverlap(target, other);
+  });
+}
diff --git a/superset-frontend/src/features/canvas/types.ts b/superset-frontend/src/features/canvas/types.ts
index 4e3e512eb6c..fbc6f9f7bdb 100644
--- a/superset-frontend/src/features/canvas/types.ts
+++ b/superset-frontend/src/features/canvas/types.ts
@@ -24,6 +24,18 @@ export interface GridPlacement {
   rowSpan: number;
 }
 
+/**
+ * The span limits a widget declares. Absent limits are open; the client
+ * clamps to these so a drag or resize can't produce a layout the server
+ * would reject.
+ */
+export interface SpanConstraints {
+  minColSpan?: number;
+  maxColSpan?: number;
+  minRowSpan?: number;
+  maxRowSpan?: number;
+}
+
 interface CanvasNodeBase {
   layout: Record<string, unknown>;
   children?: string[];
@@ -86,6 +98,24 @@ export interface CanvasDefinitionResult {
   widgetTypes: Record<string, string>;
   /** Grid container node id -> its column count. */
   gridColumns: Record<string, number>;
+  /** Node id -> its widget's span limits, for nodes that declare any. */
+  layoutConstraints: Record<string, SpanConstraints>;
+  /** Whether the current user may apply operations to this canvas. */
+  canEdit: boolean;
+}
+
+/**
+ * `PATCH /api/v1/canvas/<id>/definition` — the revision the write produced
+ * and the placements resolved from it, so a client that just moved a widget
+ * renders the server's collision push-down without refetching.
+ */
+export interface ApplyOperationsResult {
+  revision: number;
+  ops: Record<string, unknown>[];
+  placements: Record<string, GridPlacement>;
+  widgetTypes: Record<string, string>;
+  gridColumns: Record<string, number>;
+  layoutConstraints: Record<string, SpanConstraints>;
 }
 
 /** A row of `GET /api/v1/canvas/`. */
diff --git a/superset-frontend/src/features/canvas/useCanvasLayout.test.ts b/superset-frontend/src/features/canvas/useCanvasLayout.test.ts
new file mode 100644
index 00000000000..4e79b8c5260
--- /dev/null
+++ b/superset-frontend/src/features/canvas/useCanvasLayout.test.ts
@@ -0,0 +1,223 @@
+/**
+ * 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 { act, renderHook, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import { useCanvasLayout } from './useCanvasLayout';
+import type { CanvasDefinitionResult, GridPlacement } from './types';
+
+const endpoint = 'glob:*/api/v1/canvas/7/definition';
+
+const KPI: GridPlacement = { col: 1, row: 1, colSpan: 6, rowSpan: 4 };
+const TREND: GridPlacement = { col: 1, row: 5, colSpan: 24, rowSpan: 8 };
+const MOVED: GridPlacement = { col: 7, row: 1, colSpan: 6, rowSpan: 4 };
+
+const resultAt = (
+  revision: number,
+  canEdit = true,
+  placements: Record<string, GridPlacement> = { kpi: KPI, trend: TREND },
+) =>
+  ({
+    revision,
+    canEdit,
+    placements,
+  }) as CanvasDefinitionResult;
+
+beforeEach(() => {
+  fetchMock.removeRoutes();
+  fetchMock.clearHistory();
+});
+
+test('placements come straight from the definition before any edit', () => {
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  expect(result.current.placements).toEqual({ kpi: KPI, trend: TREND });
+  expect(result.current.error).toBeUndefined();
+});
+
+test('a placement shows at once and is sent as a place op', async () => {
+  fetchMock.patch(endpoint, {
+    result: { revision: 4, ops: [], placements: { kpi: MOVED, trend: TREND } },
+  });
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+
+  // Shown before the server has answered.
+  expect(result.current.placements.kpi).toEqual(MOVED);
+
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));
+  const [call] = fetchMock.callHistory.calls();
+  expect(call.options.method?.toUpperCase()).toBe('PATCH');
+  expect(JSON.parse(call.options.body as string)).toEqual({
+    base_revision: 3,
+    ops: [{ op: 'place', id: 'kpi', layout: MOVED }],
+  });
+});
+
+test("the server's resolved placements replace the optimistic ones", async () => {
+  // The server pushed the trend down to make room, which the client never
+  // guesses for itself.
+  const pushed: GridPlacement = { col: 1, row: 9, colSpan: 24, rowSpan: 8 };
+  fetchMock.patch(endpoint, {
+    result: { revision: 4, ops: [], placements: { kpi: MOVED, trend: pushed } },
+  });
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+
+  await waitFor(() => expect(result.current.placements.trend).toEqual(pushed));
+});
+
+test('a refetch that catches up takes over from the pending view', async () => {
+  fetchMock.patch(endpoint, {
+    result: { revision: 4, ops: [], placements: { kpi: MOVED, trend: TREND } },
+  });
+  const { result, rerender } = renderHook(
+    ({ revision }: { revision: number }) =>
+      useCanvasLayout(7, resultAt(revision), jest.fn()),
+    { initialProps: { revision: 3 } },
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+  await waitFor(() => expect(result.current.placements.kpi).toEqual(MOVED));
+
+  // The realtime nudge's refetch arrives at the revision the write produced,
+  // so the definition is authoritative again.
+  rerender({ revision: 4 });
+
+  expect(result.current.placements).toEqual({ kpi: KPI, trend: TREND });
+});
+
+test('a failed write snaps back and explains itself', async () => {
+  fetchMock.patch(endpoint, 422);
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+  expect(result.current.placements.kpi).toEqual(MOVED);
+
+  await waitFor(() => expect(result.current.error).toBeDefined());
+  expect(result.current.placements.kpi).toEqual(KPI);
+
+  act(() => result.current.dismissError());
+  expect(result.current.error).toBeUndefined();
+});
+
+test('a conflicting write reloads the canvas and says so', async () => {
+  fetchMock.patch(endpoint, 409);
+  const reload = jest.fn();
+  const { result } = renderHook(() => useCanvasLayout(7, resultAt(3), reload));
+
+  act(() => result.current.place('kpi', MOVED));
+
+  await waitFor(() => expect(reload).toHaveBeenCalled());
+  expect(result.current.error).toContain('changed while you were editing');
+  expect(result.current.placements.kpi).toEqual(KPI);
+});
+
+test('a refused write names permission as the reason', async () => {
+  fetchMock.patch(endpoint, 403);
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+
+  await waitFor(() =>
+    expect(result.current.error).toContain('do not have permission'),
+  );
+});
+
+test('a user without edit permission writes nothing', () => {
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3, false), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+
+  expect(fetchMock.callHistory.calls()).toHaveLength(0);
+  expect(result.current.placements.kpi).toEqual(KPI);
+});
+
+test('a second drag is based on the revision the first write returned', async () => {
+  // The regression this guards: basing every write on the definition's
+  // revision, which only advances when a realtime nudge triggers a refetch.
+  // Without one, the second drag collided with the user's own first drag.
+  fetchMock.patch(endpoint, {
+    result: { revision: 4, ops: [], placements: { kpi: MOVED, trend: TREND } },
+  });
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  act(() => result.current.place('kpi', MOVED));
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));
+
+  const again: GridPlacement = { col: 13, row: 1, colSpan: 6, rowSpan: 4 };
+  act(() => result.current.place('kpi', again));
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(2));
+
+  const bases = fetchMock.callHistory
+    .calls()
+    .map(call => JSON.parse(call.options.body as string).base_revision);
+  // 3 is the loaded definition; 4 is what the first write returned.
+  expect(bases).toEqual([3, 4]);
+});
+
+test('drags made while a write is in flight are coalesced, not conflicted', async () => {
+  fetchMock.patch(endpoint, {
+    result: { revision: 4, ops: [], placements: { kpi: MOVED, trend: TREND } },
+  });
+  const { result } = renderHook(() =>
+    useCanvasLayout(7, resultAt(3), jest.fn()),
+  );
+
+  const second: GridPlacement = { col: 13, row: 1, colSpan: 6, rowSpan: 4 };
+  const third: GridPlacement = { col: 19, row: 1, colSpan: 6, rowSpan: 4 };
+  act(() => {
+    // Three gestures before the first response can land.
+    result.current.place('kpi', MOVED);
+    result.current.place('kpi', second);
+    result.current.place('trend', third);
+  });
+
+  // The last intent per node is shown straight away.
+  expect(result.current.placements.kpi).toEqual(second);
+  expect(result.current.placements.trend).toEqual(third);
+
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(2));
+  const [first, follow] = fetchMock.callHistory
+    .calls()
+    .map(call => JSON.parse(call.options.body as string));
+  expect(first.ops).toEqual([{ op: 'place', id: 'kpi', layout: MOVED }]);
+  // The queued gestures go out together, on the confirmed revision.
+  expect(follow.base_revision).toBe(4);
+  expect(follow.ops).toEqual([
+    { op: 'place', id: 'kpi', layout: second },
+    { op: 'place', id: 'trend', layout: third },
+  ]);
+  expect(result.current.error).toBeUndefined();
+});
diff --git a/superset-frontend/src/features/canvas/useCanvasLayout.ts b/superset-frontend/src/features/canvas/useCanvasLayout.ts
new file mode 100644
index 00000000000..3cfb5fd7901
--- /dev/null
+++ b/superset-frontend/src/features/canvas/useCanvasLayout.ts
@@ -0,0 +1,187 @@
+/**
+ * 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 { useCallback, useEffect, useRef, useState } from 'react';
+import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import type {
+  ApplyOperationsResult,
+  CanvasDefinitionResult,
+  GridPlacement,
+} from './types';
+
+/** The newest layout this client knows of, and the revision it belongs to. */
+interface AppliedLayout {
+  revision: number;
+  placements: Record<string, GridPlacement>;
+}
+
+export interface CanvasLayout {
+  /** Placements to render: the newest local ones, else the server's. */
+  placements: Record<string, GridPlacement>;
+  /** Persist a node's new placement. A no-op when the user can't edit. */
+  place: (nodeId: string, placement: GridPlacement) => void;
+  /** Why the last write failed, for a dismissable notice. */
+  error?: string;
+  dismissError: () => void;
+}
+
+/**
+ * Persists drag and resize as `place` operations.
+ *
+ * The moved widget appears where it was dropped straight away, then takes the
+ * placements the server resolved — which may differ, since the server pushes
+ * overlapping widgets down.
+ *
+ * Each write is based on the newest revision the server has *confirmed*, taken
+ * from the previous write's response rather than from the definition. The
+ * definition's revision only advances when a refetch lands, and that refetch
+ * is driven by a realtime nudge; without one (no realtime service, a dropped
+ * socket) it would stay put and every drag after the first would collide with
+ * the user's own previous drag.
+ *
+ * A write while another is still in flight is queued rather than sent with a
+ * revision the server hasn't reached yet, so dragging quickly coalesces into
+ * one follow-up request instead of conflicting.
+ */
+export function useCanvasLayout(
+  canvasId: number,
+  result: CanvasDefinitionResult,
+  reload: () => void,
+): CanvasLayout {
+  const [applied, setApplied] = useState<AppliedLayout>();
+  const [error, setError] = useState<string>();
+  const { revision, canEdit } = result;
+
+  // Last revision the server acknowledged. Never an optimistic guess, so a
+  // write is never based on a revision the server hasn't reached.
+  const confirmed = useRef<number>();
+  // The definition's own revision, for the write that hasn't had a response
+  // yet (first drag after a load or a refetch).
+  const fetched = useRef(revision);
+  const writing = useRef(false);
+  // Newest intent per node while a write is in flight; sent as one batch.
+  const queued = useRef(new Map<string, GridPlacement>());
+  // The definition's placements, for an optimistic view that has no newer
+  // local layout to build on.
+  const fetchedPlacements = useRef(result.placements);
+
+  useEffect(() => {
+    fetched.current = revision;
+    fetchedPlacements.current = result.placements;
+  }, [revision, result.placements]);
+
+  useEffect(() => {
+    // A different canvas shares none of this state.
+    confirmed.current = undefined;
+    writing.current = false;
+    queued.current.clear();
+    setApplied(undefined);
+    setError(undefined);
+  }, [canvasId]);
+
+  const send = useCallback(
+    (ops: Map<string, GridPlacement>) => {
+      writing.current = true;
+      const base = Math.max(fetched.current, confirmed.current ?? 0);
+      SupersetClient.request({
+        endpoint: `/api/v1/canvas/${canvasId}/definition`,
+        method: 'PATCH',
+        jsonPayload: {
+          base_revision: base,
+          ops: [...ops].map(([id, layout]) => ({ op: 'place', id, layout })),
+        },
+      })
+        .then(({ json }) => {
+          const written = json?.result as ApplyOperationsResult | undefined;
+          if (written) {
+            confirmed.current = written.revision;
+            setApplied({
+              revision: written.revision,
+              placements: written.placements,
+            });
+          }
+        })
+        .catch(async response => {
+          queued.current.clear();
+          setApplied(undefined);
+          const { status } = response ?? {};
+          if (status === 409) {
+            setError(
+              t(
+                'This canvas changed while you were editing, so your change was undone.',
+              ),
+            );
+            reload();
+            return;
+          }
+          const { message } = await getClientErrorObject(response);
+          setError(
+            status === 403
+              ? t('You do not have permission to change this layout.')
+              : message || t('That change could not be saved.'),
+          );
+        })
+        .finally(() => {
+          writing.current = false;
+          if (queued.current.size > 0) {
+            const next = new Map(queued.current);
+            queued.current.clear();
+            send(next);
+          }
+        });
+    },
+    [canvasId, reload],
+  );
+
+  const place = useCallback(
+    (nodeId: string, placement: GridPlacement) => {
+      if (!canEdit) return;
+      setError(undefined);
+      // Show the drop straight away, built on the newest layout so an earlier
+      // gesture isn't undone -- including one from this same batch, which a
+      // ref read wouldn't see since refs only advance on render.
+      setApplied(current => ({
+        revision: Math.max(fetched.current, confirmed.current ?? 0) + 1,
+        placements: {
+          ...(current && current.revision > fetched.current
+            ? current.placements
+            : fetchedPlacements.current),
+          [nodeId]: placement,
+        },
+      }));
+      if (writing.current) {
+        queued.current.set(nodeId, placement);
+        return;
+      }
+      send(new Map([[nodeId, placement]]));
+    },
+    [canEdit, send],
+  );
+
+  const dismissError = useCallback(() => setError(undefined), []);
+
+  // The definition wins once a fetch catches up to what was applied locally.
+  const placements =
+    applied && applied.revision > revision
+      ? applied.placements
+      : result.placements;
+
+  return { placements, place, error, dismissError };
+}
diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx
index 209c2088a84..c36dddda359 100644
--- a/superset-frontend/src/pages/Canvas/index.tsx
+++ b/superset-frontend/src/pages/Canvas/index.tsx
@@ -26,6 +26,7 @@ import {
   EmptyState,
   Loading,
 } from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
 import { chat, setActiveCanvas, useChat } from 'src/core';
 import CrudThemeProvider from 'src/components/CrudThemeProvider';
 import injectCustomCss from 'src/dashboard/util/injectCustomCss';
@@ -35,11 +36,15 @@ import CanvasGrid, {
   emptyScopeValues,
   ScopeValues,
 } from 'src/features/canvas/CanvasGrid';
-import { CanvasMetadata } from 'src/features/canvas/types';
+import {
+  CanvasDefinitionResult,
+  CanvasMetadata,
+} from 'src/features/canvas/types';
 import { useCanvasDefinition } from 'src/features/canvas/useCanvasDefinition';
 import { registerBuiltinRenderers } from 'src/features/canvas/builtinRenderers';
 import { useCanvasId } from 'src/features/canvas/useCanvasId';
 import { useCanvasRefresh } from 'src/features/canvas/useCanvasRefresh';
+import { useCanvasLayout } from 'src/features/canvas/useCanvasLayout';
 
 registerBuiltinRenderers();
 
@@ -99,9 +104,60 @@ const notFound = (
   />
 );
 
+interface CanvasBodyProps {
+  canvasId: number;
+  result: CanvasDefinitionResult;
+  reload: () => void;
+  values: ScopeValues;
+  onValueChange: (
+    kind: canvasApi.ScopeKind,
+    nodeId: string,
+    value: unknown,
+  ) => void;
+  refreshKeys: Record<string, number>;
+}
+
+/**
+ * The canvas itself, once its definition has loaded. Drag and resize persist
+ * from here; a write that doesn't land says so and leaves the layout where the
+ * server still has it.
+ */
+function CanvasBody({
+  canvasId,
+  result,
+  reload,
+  values,
+  onValueChange,
+  refreshKeys,
+}: CanvasBodyProps) {
+  const layout = useCanvasLayout(canvasId, result, reload);
+  return (
+    <>
+      {layout.error && (
+        <Alert
+          type="warning"
+          closable
+          showIcon
+          message={layout.error}
+          onClose={layout.dismissError}
+          data-test="canvas-layout-error"
+        />
+      )}
+      <CanvasGrid
+        canvasId={canvasId}
+        result={result}
+        values={values}
+        onValueChange={onValueChange}
+        refreshKeys={refreshKeys}
+        layout={layout}
+      />
+    </>
+  );
+}
+
 function CanvasContent({ id }: { id: number }) {
   const metadata = useApiV1Resource<CanvasMetadata>(`/api/v1/canvas/${id}`);
-  const { state } = useCanvasDefinition(id);
+  const { state, reload } = useCanvasDefinition(id);
   const [values, setValues] = useState<ScopeValues>(emptyScopeValues);
   const refreshKeys = useCanvasRefresh(
     state.status === 'complete' ? state.result.definition : undefined,
@@ -175,9 +231,10 @@ function CanvasContent({ id }: { id: number }) {
             description={t('Ask the chat to add widgets to it.')}
           />
         ) : (
-          <CanvasGrid
+          <CanvasBody
             canvasId={id}
             result={state.result}
+            reload={reload}
             values={values}
             onValueChange={onValueChange}
             refreshKeys={refreshKeys}
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index d0019a45e3c..8dcc89ffeab 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -33,7 +33,7 @@ from pydantic import TypeAdapter, ValidationError as PydanticValidationError
 from superset_core.canvas import GridPlacement
 from superset_core.widgets import Widget
 
-from superset import is_feature_enabled
+from superset import is_feature_enabled, security_manager
 from superset.canvas.definition.registry import get_widget_types
 from superset.canvas.definition.render import render_context
 from superset.canvas.definition.schemas import (
@@ -71,6 +71,7 @@ from superset.commands.canvas.exceptions import (
 from superset.commands.canvas.update import UpdateCanvasCommand
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
 from superset.daos.canvas import CanvasDAO
+from superset.exceptions import SupersetSecurityException
 from superset.extensions import event_logger
 from superset.models.canvas import Canvas
 from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
@@ -149,6 +150,21 @@ def describe_widget(widget: type[Widget]) -> dict[str, Any]:
     }
 
 
+def _can_edit(canvas: Canvas) -> bool:
+    """
+    Whether the current user may change ``canvas``.
+
+    Asks the same check the write path enforces
+    (``ApplyCanvasOperationsCommand.validate``), so a client that hides its
+    editing affordances hides exactly what the server would refuse.
+    """
+    try:
+        security_manager.raise_for_editorship(canvas)
+    except SupersetSecurityException:
+        return False
+    return True
+
+
 def _get_canvas(id_or_uuid: str) -> Canvas:
     canvas = CanvasDAO.find_by_id_or_uuid(id_or_uuid)
     if canvas is None:
@@ -511,6 +527,16 @@ class CanvasRestApi(BaseSupersetModelRestApi):
                           gridColumns:
                             type: object
                             description: The column count of each grid container
+                          layoutConstraints:
+                            type: object
+                            description: >-
+                              The span limits each node's widget declares, for
+                              a client that lets the user resize
+                          canEdit:
+                            type: boolean
+                            description: >-
+                              Whether the current user may apply operations to
+                              this canvas
             401:
               $ref: '#/components/responses/401'
             404:
@@ -524,6 +550,7 @@ class CanvasRestApi(BaseSupersetModelRestApi):
                 "version": canvas.definition_version,
                 "revision": canvas.revision,
                 "definition": definition,
+                "canEdit": _can_edit(canvas),
                 **resolve_scopes(definition),
                 **render_context(definition),
             },
diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py
index c738c711af5..ae2aa24938e 100644
--- a/superset/canvas/definition/render.py
+++ b/superset/canvas/definition/render.py
@@ -23,7 +23,9 @@ without re-implementing auto-placement and collision push-down.
 ``widgetTypes`` maps placements to their widget so clients pick a renderer;
 placements that don't resolve are left out and render as placeholders.
 ``gridColumns`` gives the column count of each grid container, whose
-children's placements are in its own grid units.
+children's placements are in its own grid units. ``layoutConstraints`` gives
+the span limits a widget declares, so a client that lets the user drag and
+resize clamps to the same bounds the server validates against.
 """
 
 from __future__ import annotations
@@ -32,6 +34,7 @@ from typing import Any
 
 from pydantic import ValidationError
 from superset_core.canvas import GridPlacement, WidgetResolver
+from superset_core.widgets import WidgetUi
 
 from superset.canvas.definition.grid import resolve_grid
 from superset.canvas.definition.placements import placement_types
@@ -80,9 +83,25 @@ def render_context(
         "widgetTypes": type_ids,
         "placements": placements,
         "gridColumns": grid_columns,
+        "layoutConstraints": {
+            node_id: constraints
+            for node_id, widget in node_types.items()
+            if node_id in placements and (constraints := _span_constraints(widget.ui))
+        },
     }
 
 
+def _span_constraints(ui: WidgetUi) -> dict[str, int]:
+    """A widget's declared span limits, omitting the ones it leaves open."""
+    limits = {
+        "minColSpan": ui.min_col_span,
+        "maxColSpan": ui.max_col_span,
+        "minRowSpan": ui.min_row_span,
+        "maxRowSpan": ui.max_row_span,
+    }
+    return {name: limit for name, limit in limits.items() if limit is not None}
+
+
 def _grid_layout(layout: dict[str, Any]) -> dict[str, Any]:
     """A stored layout as grid placement; one kept from another layout model
     after a container's rules changed is auto-placed instead."""
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
index 2c287cf5317..9d4ec7f1137 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 from functools import partial
 from typing import Any
 
+from flask import current_app
 from marshmallow import ValidationError
 
 from superset.canvas.definition.registry import get_widget_resolver
@@ -53,7 +54,12 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
     @transaction(on_error=partial(on_error, reraise=CanvasCreateFailedError))
     def run(self) -> Canvas:
         self.validate()
-        return CanvasDAO.create(attributes=self._properties)
+        canvas = CanvasDAO.create(attributes=self._properties)
+        # Same post-create hook charts and dashboards fire, so a deployment
+        # that registers new assets somewhere (folders, say) sees canvases too.
+        if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
+            after_create(canvas, "canvas")
+        return canvas
 
     def validate(self) -> None:
         exceptions: list[ValidationError] = []
diff --git a/superset/config.py b/superset/config.py
index 4dd0a1b81f0..cdaf689d3aa 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -3424,23 +3424,27 @@ EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
 
 
 # Extra access query filters inject additional OR conditions into
-# ChartFilter and DashboardAccessFilter, enabling external systems
-# (e.g. folder permissions) to grant asset visibility.
+# ChartFilter, DashboardAccessFilter and CanvasAccessFilter, enabling external
+# systems (e.g. folder permissions) to grant asset visibility.
 # The callable receives the current user ID and returns a subquery of asset IDs.
+# Canvas applies this to single fetches as well as lists, since its DAO runs
+# CanvasAccessFilter as a base filter.
 class ExtraAccessQueryFilters(TypedDict, total=False):
     charts: Callable[[int], Query]
     dashboards: Callable[[int], Query]
+    canvases: Callable[[int], Query]
 
 
 # Extension hooks for deployments to plug in custom access logic.
-# Additional query filters for chart/dashboard list views.
+# Additional query filters for chart/dashboard/canvas access.
 EXTRA_ACCESS_QUERY_FILTERS: ExtraAccessQueryFilters = {}
 # Bypass raise_for_access for specific assets. Return True to skip checks.
 EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
 # Resolve additional editor subjects for a resource. Also used for editorship
 # checks and lockout-prevention logic.
 EXTRA_EDITORS_RESOLVER: Callable[..., list[Any]] | None = None
-# Post-create hook for charts/dashboards. Receives (model, asset_type).
+# Post-create hook for charts/dashboards/canvases. Receives
+# (model, asset_type), where asset_type is "chart", "dashboard" or "canvas".
 AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
 # Contribute extra fields to a chart or dashboard export. Receives
 # (model, asset_type) and returns a mapping serialised under the "extra" key of
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index eeff755cefd..4c1007cd9ad 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -92,6 +92,44 @@ def test_create_and_read_a_canvas(
     assert definition["definition"]["nodes"] == {}
 
 
+def test_create_fires_the_post_create_asset_hook(
+    client: Any,
+    full_api_access: None,
+    canvas: Any,
+    mocker: MockerFixture,
+) -> None:
+    """
+    A canvas create fires ``AFTER_ASSET_CREATE`` the way a chart or dashboard
+    create does, so a deployment that registers new assets externally (folder
+    permissions, say) is not silently skipped by canvases.
+    """
+    mocker.patch("superset.commands.canvas.create.populate_subjects")
+    after_create = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.canvas.create.current_app.config",
+        {"AFTER_ASSET_CREATE": after_create},
+    )
+
+    response = client.post(BASE + "/", json={"title": "Ops breakdown"})
+
+    assert response.status_code == 201
+    after_create.assert_called_once()
+    created, asset_type = after_create.call_args.args
+    assert asset_type == "canvas"
+    assert created.title == "Ops breakdown"
+
+
+def test_create_without_the_hook_configured_still_succeeds(
+    client: Any,
+    full_api_access: None,
+    canvas: Any,
+    mocker: MockerFixture,
+) -> None:
+    mocker.patch("superset.commands.canvas.create.populate_subjects")
+
+    assert client.post(BASE + "/", json={"title": "No hook"}).status_code == 201
+
+
 def test_metadata_and_slug(
     client: Any,
     full_api_access: None,
@@ -154,6 +192,26 @@ def test_get_definition(client: Any, full_api_access: None, canvas: Any) -> None
     assert response.json["result"]["placements"] == {}
     assert response.json["result"]["widgetTypes"] == {}
     assert response.json["result"]["gridColumns"] == {}
+    assert response.json["result"]["layoutConstraints"] == {}
+    assert response.json["result"]["canEdit"] is True
+
+
+def test_definition_reports_a_view_only_user_cannot_edit(
+    client: Any, full_api_access: None, canvas: Any, mocker: MockerFixture
+) -> None:
+    """
+    ``canEdit`` answers the same question the write path enforces, so a user
+    who may only read is told not to offer drag and resize.
+    """
+    mocker.patch(
+        "superset.canvas.api.security_manager.raise_for_editorship",
+        side_effect=SupersetSecurityException(MagicMock()),
+    )
+
+    response = client.get(URL)
+
+    assert response.status_code == 200
+    assert response.json["result"]["canEdit"] is False
 
 
 def test_apply_operations_bumps_revision_logs_and_publishes(
diff --git a/tests/unit_tests/canvas/filters_test.py b/tests/unit_tests/canvas/filters_test.py
index 33dacc5bb47..1b44647bd1b 100644
--- a/tests/unit_tests/canvas/filters_test.py
+++ b/tests/unit_tests/canvas/filters_test.py
@@ -100,3 +100,37 @@ def test_embedded_guests_see_none(
     as_user(mocker, guest=True)
 
     assert visible(session) == []
+
+
+def test_extra_access_query_filter_can_grant_a_canvas(
+    session: Session, shared: dict[str, Any], mocker: MockerFixture
+) -> None:
+    """
+    ``EXTRA_ACCESS_QUERY_FILTERS["canvases"]`` grants visibility the editor and
+    viewer lists don't, the way it already does for charts and dashboards.
+    """
+    as_user(mocker)
+    mocker.patch("superset.canvas.filters.get_user_id", return_value=7)
+    granted = mocker.MagicMock(return_value=[shared["hidden"].id])
+    mocker.patch.dict(
+        "superset.canvas.filters.current_app.config",
+        {"EXTRA_ACCESS_QUERY_FILTERS": {"canvases": granted}},
+    )
+
+    assert visible(session) == ["edited", "hidden", "viewed"]
+    granted.assert_called_once_with(7)
+
+
+def test_extra_access_query_filter_is_skipped_without_a_user(
+    session: Session, shared: dict[str, Any], mocker: MockerFixture
+) -> None:
+    as_user(mocker)
+    mocker.patch("superset.canvas.filters.get_user_id", return_value=None)
+    granted = mocker.MagicMock(return_value=[shared["hidden"].id])
+    mocker.patch.dict(
+        "superset.canvas.filters.current_app.config",
+        {"EXTRA_ACCESS_QUERY_FILTERS": {"canvases": granted}},
+    )
+
+    assert visible(session) == ["edited", "viewed"]
+    granted.assert_not_called()
diff --git a/tests/unit_tests/canvas/render_test.py b/tests/unit_tests/canvas/render_test.py
index f1cbbad3549..f39c97215d6 100644
--- a/tests/unit_tests/canvas/render_test.py
+++ b/tests/unit_tests/canvas/render_test.py
@@ -14,6 +14,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from superset_core.widgets import Widget, WidgetBehavior, WidgetUi
+
 from superset.canvas.definition.render import render_context
 from superset.canvas.definition.validation import normalize_definition
 from tests.unit_tests.canvas.fixtures import (
@@ -21,6 +23,7 @@ from tests.unit_tests.canvas.fixtures import (
     canvas_widget_types,
     FakeResolver,
     node,
+    Props,
 )
 
 
@@ -59,3 +62,73 @@ def test_placements_include_auto_placed_nodes_and_grid_containers() -> None:
         "c": "chart",
     }
     assert context["gridColumns"] == {"g": 12}
+
+
+def test_layout_constraints_carry_declared_span_limits() -> None:
+    """Only the limits a widget actually declares reach the client."""
+    rules, resolver = canvas_widget_types(), FakeResolver()
+    rules["sized"] = type(
+        "SizedWidget",
+        (Widget,),
+        {
+            "widget_type": "sized",
+            "name": "Sized",
+            "controls_class": Props,
+            "behavior": WidgetBehavior(),
+            "ui": WidgetUi(default_size=(6, 4), min_col_span=4, max_row_span=8),
+        },
+    )
+    definition = normalize_definition(
+        canvas(
+            {
+                "tile": node("sized-1", layout={"colSpan": 6}),
+                # The same widget, placed inside a grid container.
+                "g": node("group", layout={"colSpan": 24}, children=["inner"]),
+                "inner": node("sized-2", layout={"colSpan": 6}),
+                # A widget that declares nothing is left out entirely.
+                "plain": node("chart-1", layout={"colSpan": 4}),
+            },
+            children=["tile", "g", "plain"],
+        ),
+        rules,
+        resolver,
+    )
+
+    context = render_context(definition, rules, resolver)
+
+    assert context["layoutConstraints"] == {
+        "tile": {"minColSpan": 4, "maxRowSpan": 8},
+        "inner": {"minColSpan": 4, "maxRowSpan": 8},
+    }
+
+
+def test_layout_constraints_skip_nodes_that_are_not_on_a_grid() -> None:
+    """A child of a container with its own layout model can't be dragged."""
+    rules, resolver = canvas_widget_types(), FakeResolver()
+    rules["sized"] = type(
+        "SizedWidget",
+        (Widget,),
+        {
+            "widget_type": "sized",
+            "name": "Sized",
+            "controls_class": Props,
+            "behavior": WidgetBehavior(),
+            "ui": WidgetUi(min_col_span=4),
+        },
+    )
+    definition = normalize_definition(
+        canvas(
+            {
+                "b": node("board", layout={"colSpan": 24}, children=["card"]),
+                "card": node("sized-1", layout={"lane": "todo"}),
+            },
+            children=["b"],
+        ),
+        rules,
+        resolver,
+    )
+
+    context = render_context(definition, rules, resolver)
+
+    assert "card" not in context["placements"]
+    assert "card" not in context["layoutConstraints"]