Skip to content

feat(builder): drag blocks on the canvas - #1007

Merged
mobeenabdullah merged 1 commit into
mainfrom
feat/canvas-block-drag
Aug 18, 2026
Merged

mobeenabdullah merged 1 commit into
mainfrom
feat/canvas-block-drag

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Blocks can now be dragged on the page-builder canvas. Press a block, move, and a line shows where it will land; release and it goes there.

The design, and why it is not the one that was tried before

Three earlier designs for this were built and refuted by measurement (#810 and #829, both closed). Each scored every candidate position against the pointer and took the best score. Scoring is comparative, so once two positions in different containers can tie, their scores have to mean the same thing — and no measure of a single position can encode "the pointer is inside THIS container", because containment is a statement about the candidates you are being compared against. That is an arity problem, not an arithmetic one.

drop-targets.ts stops comparing across containers at all:

  1. Resolve the region — the deepest container whose rectangle holds the pointer owns the drop. Decided, not scored.
  2. Rank inside it — only then, and only among that region's own insertion points.

Every value in a comparison is then the same unit by construction.

An insertion point is a LINE, not a rectangle's middle. Ranking by distance to the line puts the switch boundary exactly at each child's centre, whatever the children's sizes are — which is what a rule measuring to a drop zone's middle gets wrong as soon as adjacent blocks differ in height. drop-targets.test.ts asserts that on a 300px block beside a 20px one; equal heights pass either way.

Nothing reads a block's height as a threshold. core/spacer takes its height from an author-set prop with no lower bound and core/divider renders 1px (measured on the canvas, not inferred), so any minimum size makes some authored block impossible to drop beside. Steadiness comes from pointer travel instead — target-switch.ts, which was merged, tested, and had no consumer until now.

Accessibility

WCAG 2.2 SC 2.5.7 requires any drag-operated function to be achievable without dragging, and SC 2.1.1 requires keyboard operability. Both were already satisfied before this change: click to select, alt+Arrow to move, each move announced. So drag is an enhancement over an existing baseline, and it adds no second live region — keyboard-actions owns the one there is.

Verified in a real browser, not only in tests

Every run included an auth control, because a 401 renders a healthy-looking admin whose field controls never load.

  • reorder at root level — H2,P,HR → P,HR,H2, twice, identical
  • drop into a container — a block moved from depth 1 to depth 2, indicator drawn inside
  • Escape abandons the drag, leaves the order untouched, and does not close the editor
  • a 1px divider still gets an insertion line beside it
  • release outside the canvas commits nothing

One fix came out of that and could not have come from anywhere else: the canvas root was exactly as tall as its content, so the end of the page had no pixels to aim at and a block could not be dragged there at all. min-height: 100%.

Known limitation, stated rather than left to be found

An empty container renders ~2px tall, so a pointer cannot realistically aim into one. Insert-into-empty-container (#1001) is unaffected — it works from the selection, not the pointer. Dragging into a container that already has content works, and is verified above. Making empty containers an aimable drop area needs either a renderer marker for "this node declares slots" or a drag-time reflow, and both are decisions worth their own change.

Also here

  • blockAllowedAt — the nesting rule by block NAME, so the palette and the drag ask one question rather than two. entryAllowedAt delegates to it.
  • registrySlotSource moves into inserter.ts; the palette and the canvas now read the same slot source, so a container the palette will fill is one a drag can aim at.
  • The DOM rectangle reads move into geometry-dom.ts, beside the frame inset, so rectangles still enter this package through one door. The ownership guard allows that second path explicitly and now exempts test files — which must assign a rectangle, because jsdom lays nothing out.
  • target-switch and the drop rules are exported from the root entry. Neither was exported from any entry before, so target-switch never reached dist at all.

Tests

573 in @nextlyhq/builder (was 549), 61 in plugin-page-builder. Every new mechanism was stub-verified — broken one at a time, confirmed the intended test failed, restored, restore confirmed by diff. That is 6 breaks for the drop rules, 7 for the gesture, 3 for the coordinate readers, plus a positive control proving the loosened ownership guard still catches a product-file read.

Adds the drop rules, the pointer gesture and the indicator that draws it.

Region first, then a line. Three earlier designs scored every candidate
position against the pointer and were refuted, because scoring is comparative
and no measure of one position can encode "the pointer is inside THIS
container": containment is a statement about the candidates being compared
against. Resolving which region owns the pointer BEFORE ranking anything makes
every value in a comparison the same unit by construction.

An insertion point is a line rather than a rectangle's middle. Ranking by
distance to the line puts the switch boundary at each child's centre whatever
the children's sizes are, which is what a rule measuring to a zone's middle
gets wrong once adjacent blocks differ in height.

Nothing reads a block's height as a threshold. `core/spacer` takes its height
from an author-set prop with no lower bound and `core/divider` renders one
pixel tall, so any minimum size excludes some authored block and makes it
impossible to drop beside. Steadiness comes from pointer travel instead, which
`target-switch` already decides and which had no consumer until now.

The canvas root gains `min-height: 100%`: it was exactly as tall as its
content, so the end of the page had no pixels to aim at and a block could not
be dragged there at all.

The DOM reads move into `geometry-dom`, beside the frame inset, so rectangles
still enter this package through one door; the ownership guard allows that
second path and now exempts test files, which must assign a rectangle because
jsdom lays nothing out.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fd6c5b42-1807-4665-8837-a1f37866f52c

📥 Commits

Reviewing files that changed from the base of the PR and between a81930d and fd5355c.

⛔ Files ignored due to path filters (1)
  • .changeset/canvas-drag-drops-where-the-line-is.md is excluded by !.changeset/**
📒 Files selected for processing (14)
  • packages/builder/src/canvas-drag.test.tsx
  • packages/builder/src/canvas-drag.tsx
  • packages/builder/src/canvas.tsx
  • packages/builder/src/drop-targets.test.ts
  • packages/builder/src/drop-targets.ts
  • packages/builder/src/geometry-dom.test.ts
  • packages/builder/src/geometry-dom.ts
  • packages/builder/src/geometry-ownership.test.ts
  • packages/builder/src/index.ts
  • packages/builder/src/insert-panel.tsx
  • packages/builder/src/inserter.ts
  • packages/builder/src/shell.ts
  • packages/builder/src/styles/builder-chrome.css
  • packages/plugin-page-builder/src/admin/BlocksField.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah
mobeenabdullah merged commit fd14539 into main Aug 18, 2026
12 of 13 checks passed
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds pointer-based block reordering and container drops to the builder canvas, using region-first target resolution and insertion-line indicators.

  • Adds activation thresholds, target-switch hysteresis, cancellation, Escape handling, and locked-block protection.
  • Shares registry-derived slot and nesting sources between insertion and dragging.
  • Adds canvas overlay styling, coordinate helpers, public exports, and gesture/geometry tests.

Confidence Score: 4/5

The same-list forward-drag indexing defect should be fixed before merging because blocks can land after a different sibling than the displayed insertion line indicates.

Drop targets are generated from the original sibling list, while the tree mover removes the dragged node before interpreting the destination index, shifting every forward destination by one.

Files Needing Attention: packages/builder/src/drop-targets.ts, packages/builder/src/canvas-drag.tsx

Important Files Changed

Filename Overview
packages/builder/src/canvas-drag.tsx Implements gesture lifecycle and commits resolved targets, but forwards target indexes unchanged to post-removal move semantics.
packages/builder/src/drop-targets.ts Adds region-first drop resolution and line ranking; same-list forward targets retain pre-removal indexes and therefore land late.
packages/builder/src/geometry-dom.ts Adds consistent viewport-to-canvas coordinate conversion for rectangles and pointer positions.
packages/plugin-page-builder/src/admin/BlocksField.tsx Wires the drag hook, shared registry sources, handlers, and indicator into the production builder canvas.
packages/builder/src/inserter.ts Centralizes block-name nesting checks and registry slot discovery for palette and drag consumers.

Sequence Diagram

sequenceDiagram
  participant User
  participant Canvas
  participant Drag as useCanvasDrag
  participant Resolver as resolveDrop
  participant Editor
  User->>Canvas: pointer down on block
  Canvas->>Drag: snapshot document regions and rectangles
  User->>Canvas: pointer move
  Canvas->>Drag: current pointer coordinates
  Drag->>Resolver: regions, nesting rules, pointer
  Resolver-->>Drag: insertion target and line
  Drag-->>Canvas: render DropIndicator
  User->>Canvas: pointer up
  Canvas->>Drag: commit target
  Drag->>Editor: apply move operation
Loading

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
packages/builder/src/drop-targets.ts:349-357
**Forward drops use shifted indexes**

When a block is dragged forward within its current root or slot, `targetsInRegion` computes the target index from the list that still contains the moving block, but `moveNode` interprets that index after removing it. The block therefore lands one position after the displayed insertion line—for example, dropping A between C and D in `[A,B,C,D]` produces `[B,C,D,A]`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(builder): drag blocks on the canvas" | Re-trigger Greptile

Comment on lines +349 to +357
const positionAt = (index: number): OpPosition =>
region.parentId === undefined || region.slot === undefined
? { index }
: { parentId: region.parentId, slot: region.slot, index };

const make = (index: number, line: number): DropTarget => ({
id: `${region.id}#${String(index)}`,
regionId: region.id,
at: positionAt(index),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Forward drops use shifted indexes

When a block is dragged forward within its current root or slot, targetsInRegion computes the target index from the list that still contains the moving block, but moveNode interprets that index after removing it. The block therefore lands one position after the displayed insertion line—for example, dropping A between C and D in [A,B,C,D] produces [B,C,D,A].

Knowledge Base Used: Page Builder plugin

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/builder/src/drop-targets.ts
Line: 349-357

Comment:
**Forward drops use shifted indexes**

When a block is dragged forward within its current root or slot, `targetsInRegion` computes the target index from the list that still contains the moving block, but `moveNode` interprets that index after removing it. The block therefore lands one position after the displayed insertion line—for example, dropping A between C and D in `[A,B,C,D]` produces `[B,C,D,A]`.

**Knowledge Base Used:** [Page Builder plugin](https://app.greptile.com/nextly/-/custom-context/knowledge-base/nextlyhq/nextly/-/docs/plugin-page-builder.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Fallow audit report

Found 41 findings.

Dependencies (40)
Severity Rule Location Description
major fallow/unused-dependency apps/playground/package.json:24 Package '@nextlyhq/adapter-drizzle' is in dependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
major fallow/unused-dependency apps/playground/package.json:25 Package '@nextlyhq/adapter-mysql' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:26 Package '@nextlyhq/adapter-postgres' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:27 Package '@nextlyhq/adapter-sqlite' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:42 Package 'sharp' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:45 Package '@eslint/eslintrc' is in devDependencies but never imported
minor fallow/unused-dev-dependency apps/playground/package.json:54 Package 'better-sqlite3' is in devDependencies but never imported; imported in other workspaces: packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:55 Package 'culori' is in devDependencies but never imported; imported in other workspaces: packages/ui
minor fallow/unused-dev-dependency e2e/package.json:17 Package '@nextlyhq/adapter-drizzle' is in devDependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:18 Package '@nextlyhq/adapter-sqlite' is in devDependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:22 Package '@nextlyhq/plugin-form-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/unused-dev-dependency e2e/package.json:23 Package '@nextlyhq/plugin-page-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/dev-dependency-in-production package.json:92 devDependency '@manypkg/get-packages' is imported by production code at runtime (consider moving to dependencies)
minor fallow/dev-dependency-in-production package.json:104 devDependency 'micromatch' is imported by production code at runtime (consider moving to dependencies)
minor fallow/unused-dev-dependency package.json:106 Package 'publint' is in devDependencies but never imported
major fallow/unused-dependency packages/admin/package.json:110 Package '@lexical/history' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:119 Package '@radix-ui/react-accordion' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:120 Package '@radix-ui/react-alert-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:121 Package '@radix-ui/react-avatar' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:122 Package '@radix-ui/react-checkbox' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:123 Package '@radix-ui/react-collapsible' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:124 Package '@radix-ui/react-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:125 Package '@radix-ui/react-dropdown-menu' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:127 Package '@radix-ui/react-popover' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:128 Package '@radix-ui/react-radio-group' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:129 Package '@radix-ui/react-select' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:130 Package '@radix-ui/react-separator' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:132 Package '@radix-ui/react-switch' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:133 Package '@radix-ui/react-tabs' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:134 Package '@radix-ui/react-tooltip' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:140 Package 'cmdk' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:149 Package 'tailwind-variants' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:150 Package 'tailwindcss-animate' is in dependencies but never imported
minor fallow/unused-dev-dependency packages/builder/package.json:68 Package '@nextlyhq/plugin-sdk' is in devDependencies but never imported; imported in other workspaces: apps/playground, packages/plugin-form-builder, packages/plugin-page-builder, packages/plugin-sdk, packages/plugin-seo
major fallow/unused-dependency packages/plugin-page-builder/package.json:51 Package '@dnd-kit/dom' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:52 Package '@dnd-kit/react' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:56 Package 'isomorphic-dompurify' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:63 Package '@tanstack/react-query' is in devDependencies but never imported; imported in other workspaces: packages/admin, packages/plugin-form-builder
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:72 Package '@dnd-kit/abstract' is in devDependencies but never imported
major fallow/unused-dependency packages/telemetry/package.json:32 Package 'env-paths' is in dependencies but never imported
Health (1)
Severity Rule Location Description
minor fallow/high-crap-score packages/builder/src/canvas-drag.tsx:246 'onPointerMove' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)

Generated by fallow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant