From fd5355c06ba1c09be8924683847e8bf211531f69 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Tue, 18 Aug 2026 10:08:06 +0500 Subject: [PATCH] feat(builder): drag blocks on the canvas 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. --- .../canvas-drag-drops-where-the-line-is.md | 34 ++ packages/builder/src/canvas-drag.test.tsx | 327 +++++++++++ packages/builder/src/canvas-drag.tsx | 440 ++++++++++++++ packages/builder/src/canvas.tsx | 28 + packages/builder/src/drop-targets.test.ts | 540 ++++++++++++++++++ packages/builder/src/drop-targets.ts | 540 ++++++++++++++++++ packages/builder/src/geometry-dom.test.ts | 89 ++- packages/builder/src/geometry-dom.ts | 49 +- .../builder/src/geometry-ownership.test.ts | 67 ++- packages/builder/src/index.ts | 56 ++ packages/builder/src/insert-panel.tsx | 18 +- packages/builder/src/inserter.ts | 54 +- packages/builder/src/shell.ts | 22 + .../builder/src/styles/builder-chrome.css | 67 +++ .../src/admin/BlocksField.tsx | 23 + 15 files changed, 2330 insertions(+), 24 deletions(-) create mode 100644 .changeset/canvas-drag-drops-where-the-line-is.md create mode 100644 packages/builder/src/canvas-drag.test.tsx create mode 100644 packages/builder/src/canvas-drag.tsx create mode 100644 packages/builder/src/drop-targets.test.ts create mode 100644 packages/builder/src/drop-targets.ts diff --git a/.changeset/canvas-drag-drops-where-the-line-is.md b/.changeset/canvas-drag-drops-where-the-line-is.md new file mode 100644 index 0000000000..5d144c7636 --- /dev/null +++ b/.changeset/canvas-drag-drops-where-the-line-is.md @@ -0,0 +1,34 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +Blocks can now be dragged on the page-builder canvas. Dragging a block shows a line where it +will land and drops it there, including into a container, and the line is chosen by the region +the pointer is over rather than by the nearest rectangle — so a block goes where it is aimed +even when its neighbours are very different heights. Pressing Escape abandons a drag without +moving anything, a press that does not travel stays a click, and a locked block does not move. +Everything drag does was already possible from the keyboard, which remains the way to place a +block precisely between two very short ones. diff --git a/packages/builder/src/canvas-drag.test.tsx b/packages/builder/src/canvas-drag.test.tsx new file mode 100644 index 0000000000..bfe87f17fb --- /dev/null +++ b/packages/builder/src/canvas-drag.test.tsx @@ -0,0 +1,327 @@ +// @vitest-environment jsdom + +/** + * Dragging a block, driven through a host that renders a real canvas. + * + * The harness mounts what a consumer mounts — an editor state, a canvas + * carrying the handlers, and the indicator in its overlay — rather than the + * smallest thing that makes the hook execute. A harness that mounted the hook + * and returned null would exercise every pointer path while the rendering never + * ran, which is precisely how a dropped field survives review in this package. + * + * **Rectangles are stubbed, because jsdom reports every element as zero-sized.** + * That is not a limitation being worked around: the geometry is deliberately + * pure and asserted in `drop-targets.test.ts`, so what remains for this file is + * the GESTURE — when a press becomes a drag, what a release commits, and what + * abandons one. Each of those is real here. + * + * @module canvas-drag.test + */ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import * as React from "react"; + +import { + clearBlocks, + registerBlocks, + type BlockDocument, + type BlockNode, +} from "@nextlyhq/blocks-engine"; +import { NODE_ID_ATTRIBUTE } from "@nextlyhq/blocks-react"; + +import { Canvas, CANVAS_ROOT_CLASS } from "./canvas"; +import { DropIndicator, useCanvasDrag } from "./canvas-drag"; +import { useEditorState, type EditorState } from "./editor-state"; +import { registrySlotSource } from "./inserter"; + +afterEach(() => { + cleanup(); + clearBlocks(); +}); + +beforeAll(() => { + // Absent from jsdom entirely. Without them the first pointerdown throws and + // every case below fails on the harness rather than on the behaviour. + const element = window.Element.prototype as unknown as Record< + string, + unknown + >; + element.setPointerCapture = function setPointerCapture(): void {}; + element.releasePointerCapture = function releasePointerCapture(): void {}; + element.hasPointerCapture = function hasPointerCapture(): boolean { + return true; + }; + element.scrollIntoView = function scrollIntoView(): void {}; +}); + +/** A block that renders its children inside a wrapper, so slots are reachable. */ +const BLOCKS = [ + { + name: "test/heading", + version: 1, + description: "A heading.", + example: { props: {} }, + editor: { label: "Heading" }, + render: () => React.createElement("h2", null, "heading"), + }, + { + name: "test/box", + version: 1, + description: "A container.", + example: { props: {} }, + editor: { label: "Box" }, + slots: { children: {} }, + render: () => React.createElement("div", null, "box"), + }, +]; + +function node(id: string, type: string, slots?: Record) { + return { + id, + type, + version: 1, + props: {}, + ...(slots ? { slots } : {}), + } as BlockNode; +} + +function documentOf(nodes: BlockNode[]): BlockDocument { + return { formatVersion: 1, kind: "page", nodes } as BlockDocument; +} + +/** The editor the last render produced, for asserting what a drop did. */ +let editorRef: EditorState | null = null; + +function Host({ document }: { document: BlockDocument }): React.JSX.Element { + const editor = useEditorState({ initialDocument: document }); + editorRef = editor; + const drag = useCanvasDrag({ + editor, + slots: registrySlotSource(), + // Everything is permitted, so a refused placement never masks a case that + // is about the gesture rather than about nesting. + nesting: { parentsOf: () => undefined, slotAllowOf: () => undefined }, + }); + + return ( + } + /> + ); +} + +/** + * Give every rendered element a rectangle, since jsdom gives them none. + * + * The canvas root spans the viewport and each block is stacked beneath the last, + * which is what the real layout does and what the axis derivation reads. + */ +function layout(container: HTMLElement, heights: Record): void { + const root = container.querySelector(`.${CANVAS_ROOT_CLASS}`); + if (root === null) throw new Error("no canvas root rendered"); + root.getBoundingClientRect = () => + ({ x: 0, y: 0, width: 400, height: 1000, top: 0, left: 0 }) as DOMRect; + + let top = 0; + root + .querySelectorAll(`[${NODE_ID_ATTRIBUTE}]`) + .forEach(element => { + const id = element.getAttribute(NODE_ID_ATTRIBUTE) ?? ""; + const height = heights[id] ?? 100; + const box = { x: 0, y: top, width: 400, height, top, left: 0 } as DOMRect; + element.getBoundingClientRect = () => box; + top += height; + }); +} + +function rootOf(container: HTMLElement): HTMLElement { + const root = container.querySelector(`.${CANVAS_ROOT_CLASS}`); + if (root === null) throw new Error("no canvas root rendered"); + return root; +} + +function indicator(container: HTMLElement): HTMLElement | null { + return container.querySelector(".nx-drop-indicator"); +} + +function press(root: HTMLElement, target: Element, x: number, y: number): void { + fireEvent.pointerDown(target, { + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }); + void root; +} + +function moveTo(root: HTMLElement, x: number, y: number): void { + fireEvent.pointerMove(root, { pointerId: 1, clientX: x, clientY: y }); +} + +function release(root: HTMLElement, x: number, y: number): void { + fireEvent.pointerUp(root, { pointerId: 1, clientX: x, clientY: y }); +} + +function blockElement(container: HTMLElement, id: string): Element { + const element = container.querySelector(`[${NODE_ID_ATTRIBUTE}="${id}"]`); + if (element === null) throw new Error(`no element for ${id}`); + return element; +} + +/** Three stacked blocks: 0-100, 100-200, 200-300. */ +function renderThree() { + registerBlocks(BLOCKS as never, { source: "canvas-drag-test" }); + const result = render( + + ); + layout(result.container, { a: 100, b: 100, c: 100 }); + return { ...result, root: rootOf(result.container) }; +} + +describe("useCanvasDrag", () => { + it("does not start a drag, or move anything, on a click", () => { + // A press that never travels is a click. Without the activation threshold + // every click on a block commits a move to the position it already occupies, + // and the undo history fills with edits the author never made. + const { container, root } = renderThree(); + const before = editorRef?.undoDepth ?? -1; + + press(root, blockElement(container, "a"), 200, 50); + moveTo(root, 201, 51); + release(root, 201, 51); + + expect(indicator(container)).toBeNull(); + expect(editorRef?.undoDepth).toBe(before); + }); + + it("shows an indicator once the pointer has travelled far enough", () => { + const { container, root } = renderThree(); + + press(root, blockElement(container, "a"), 200, 50); + expect(indicator(container)).toBeNull(); + + moveTo(root, 200, 250); + + // The presence of the line is asserted BEFORE anything about where it is: + // every later assertion about a position would be satisfied by absence, and + // an indicator that never appears would certify them all. + expect(indicator(container)).not.toBeNull(); + }); + + it("commits a move to the position the line was drawn at", () => { + const { container, root } = renderThree(); + + press(root, blockElement(container, "a"), 200, 50); + // Past the middle of the last block, so the line is the document end. + moveTo(root, 200, 290); + expect(indicator(container)).not.toBeNull(); + release(root, 200, 290); + + // "a" removed from the front and re-inserted at the end. + expect(editorRef?.document.nodes.map(n => n.id)).toEqual(["b", "c", "a"]); + }); + + it("leaves the document alone when the pointer is released off the canvas", () => { + // Committing "the last valid target" from a pointer that has since left + // would drop the block somewhere the author was not pointing when they let + // go. + const { container, root } = renderThree(); + const order = editorRef?.document.nodes.map(n => n.id); + + press(root, blockElement(container, "a"), 200, 50); + moveTo(root, 200, 250); + moveTo(root, 200, 5000); + release(root, 200, 5000); + + expect(editorRef?.document.nodes.map(n => n.id)).toEqual(order); + expect(indicator(container)).toBeNull(); + }); + + it("abandons the drag on Escape, and commits nothing after it", () => { + const { container, root } = renderThree(); + const order = editorRef?.document.nodes.map(n => n.id); + + press(root, blockElement(container, "a"), 200, 50); + moveTo(root, 200, 290); + expect(indicator(container)).not.toBeNull(); + + // Dispatched on the DOCUMENT, which is the point: pointer capture does not + // move focus, so a handler bound to the canvas would never see this. + fireEvent.keyDown(document, { key: "Escape" }); + + expect(indicator(container)).toBeNull(); + release(root, 200, 290); + expect(editorRef?.document.nodes.map(n => n.id)).toEqual(order); + }); + + it("abandons the drag when the browser cancels the gesture", () => { + // A cancel is the pointer being taken away — captured elsewhere, or a touch + // that became a scroll. Dropping there would commit a move the author never + // released. + const { container, root } = renderThree(); + const order = editorRef?.document.nodes.map(n => n.id); + + press(root, blockElement(container, "a"), 200, 50); + moveTo(root, 200, 290); + fireEvent.pointerCancel(root, { pointerId: 1 }); + + expect(indicator(container)).toBeNull(); + expect(editorRef?.document.nodes.map(n => n.id)).toEqual(order); + }); + + it("refuses to drag a locked block", () => { + // The engine declares `locked` as "the editor command layer must not let the + // author move or delete this node", and a drag is that layer. + registerBlocks(BLOCKS as never, { source: "canvas-drag-test" }); + const locked = { ...node("a", "test/heading"), locked: true } as BlockNode; + const { container } = render( + + ); + layout(container, { a: 100, b: 100 }); + const root = rootOf(container); + const order = editorRef?.document.nodes.map(n => n.id); + + press(root, blockElement(container, "a"), 200, 50); + moveTo(root, 200, 190); + + expect(indicator(container)).toBeNull(); + release(root, 200, 190); + expect(editorRef?.document.nodes.map(n => n.id)).toEqual(order); + }); + + it("ignores a press that is not the primary button", () => { + // A right-click opens a context menu and a middle-click scrolls; starting a + // drag from either takes a gesture the browser has already given a meaning. + const { container, root } = renderThree(); + + fireEvent.pointerDown(blockElement(container, "a"), { + button: 2, + pointerId: 1, + clientX: 200, + clientY: 50, + }); + moveTo(root, 200, 290); + + expect(indicator(container)).toBeNull(); + }); + + it("selects the dragged block when the drag begins", () => { + const { container, root } = renderThree(); + + press(root, blockElement(container, "b"), 200, 150); + moveTo(root, 200, 290); + + expect(editorRef?.selectedId).toBe("b"); + }); +}); diff --git a/packages/builder/src/canvas-drag.tsx b/packages/builder/src/canvas-drag.tsx new file mode 100644 index 0000000000..28bf8a6443 --- /dev/null +++ b/packages/builder/src/canvas-drag.tsx @@ -0,0 +1,440 @@ +"use client"; + +/** + * Dragging a block on the canvas: the pointer, and the line that answers it. + * + * Decides nothing about WHERE a block may land — {@link resolveDrop} does that, + * and {@link nextTargetSwitchState} decides when the answer is allowed to + * change. This module is the seam between those rules and a browser: it reads + * rectangles, tracks a gesture, and draws. + * + * ## Drag is an enhancement, never the only way + * + * WCAG 2.2 SC 2.5.7 requires any function operated by dragging to be reachable + * without dragging, and SC 2.1.1 requires keyboard operability. Both are already + * satisfied before this file exists: a block is selected by clicking and moved + * with `alt+Arrow`, and each move is announced. So nothing here is a sole route + * to anything, and nothing here announces — `keyboard-actions` owns the one + * live region, and a second one would read one author's action twice. + * + * ## A press is not a drag + * + * The pointer must travel {@link DEFAULT_ACTIVATION_PX} before a press becomes a + * drag. Without that threshold every click on a block is a zero-length drag that + * commits a move to the position it already occupies, and the undo history fills + * with edits the author never made. + * + * ## Rectangles are measured ONCE, when the drag begins + * + * The document cannot change mid-drag — this is the only gesture in flight — and + * blocks do not move out of the way as the pointer passes, so the layout that + * was measured is the layout the whole gesture sees. Re-reading every + * `getBoundingClientRect` on each pointer move would force a reflow per frame to + * learn nothing. + * + * They are stored in the canvas's own CONTENT coordinates — viewport rectangle + * minus the root's, plus the root's scroll — so the snapshot survives the page + * scrolling under it, and an indicator positioned absolutely inside the root + * lands where the rectangles say. **Not** handled: content that reflows during a + * drag, such as a lazily-loaded image resizing above the pointer. + * + * @module canvas-drag + */ + +import type { NestingSource } from "@nextlyhq/blocks-engine"; +import { findNode } from "@nextlyhq/blocks-engine"; +import { NODE_ID_ATTRIBUTE } from "@nextlyhq/blocks-react"; +import * as React from "react"; + +import { nodeIdFromEvent } from "./canvas"; +import { + collectRegions, + movingSubtree, + resolveDrop, + type DropRefusal, + type DropRegion, + type DropTarget, + type RectSource, +} from "./drop-targets"; +import type { EditorState } from "./editor-state"; +import type { Point, Rect } from "./geometry"; +import { canvasContentPoint, canvasContentRect } from "./geometry-dom"; +import type { SlotSource } from "./inserter"; +import { + nextTargetSwitchState, + NO_TARGET, + type TargetSwitchState, +} from "./target-switch"; + +/** + * How far the pointer travels before a press becomes a drag. + * + * Large enough that the hand-shake in a deliberate click never reaches it, small + * enough that a short drag still starts. The same order as the field's other + * editors; the exact figure is not load-bearing, because the threshold only has + * to separate a click from an intent to move. + */ +export const DEFAULT_ACTIVATION_PX = 4; + +/** + * How far a rival target must be held before it replaces the committed one. + * + * Measured as pointer TRAVEL rather than as a property of the blocks, which is + * the whole reason this canvas can have hysteresis at all: `core/spacer` takes + * its height from an author-set prop with no lower bound and `core/divider` + * renders one pixel tall, so any rule keyed on a block's size makes some + * authored block impossible to drop beside. Travel is a property of the gesture + * and treats a 1px divider and a 900px hero identically. + */ +export const DEFAULT_SWITCH_PX = 8; + +/** What a drag is doing right now, for the canvas to draw. */ +export interface CanvasDragState { + /** The node being dragged, or null when no drag is in flight. */ + readonly draggingId: string | null; + /** Where a drop would land, or null when nowhere would accept it. */ + readonly target: DropTarget | null; + /** + * Why the region under the pointer will not take the block, or null. + * + * Distinct from a null target: the author has aimed at something, and saying + * nothing tells them the editor did not notice. + */ + readonly refusal: DropRefusal | null; +} + +/** The handlers the canvas root must carry for a drag to work. */ +export interface CanvasDragHandlers { + readonly onPointerDown: React.PointerEventHandler; + readonly onPointerMove: React.PointerEventHandler; + readonly onPointerUp: React.PointerEventHandler; + readonly onPointerCancel: React.PointerEventHandler; +} + +export interface CanvasDrag extends CanvasDragState { + readonly handlers: CanvasDragHandlers; +} + +export interface UseCanvasDragOptions { + /** The editor whose document a drop edits. */ + editor: EditorState; + /** Which child regions each block type declares. */ + slots: SlotSource; + /** The nesting rule, asked before any position is offered. */ + nesting: NestingSource; + activationPx?: number; + switchPx?: number; +} + +/** What a drag needs to remember, none of which renders. */ +interface Gesture { + readonly nodeId: string; + readonly origin: Point; + readonly regions: readonly DropRegion[]; + readonly rects: RectSource; + readonly forbiddenParents: ReadonlySet; + readonly blockName: string; + /** False until the pointer has travelled far enough to mean a drag. */ + active: boolean; + switchState: TargetSwitchState; + /** Targets by id, so the committed id resolves back to something drawable. */ + targets: Map; +} + +/** + * Every rendered node's rectangle, read once. + * + * Compared in JavaScript rather than matched with a selector built from an id: + * a node id reaches this from stored data, and interpolating it into + * `querySelector` makes any character CSS treats specially either throw or, + * worse, match a different element. + */ +function snapshotRects(root: HTMLElement): RectSource { + const measured = new Map(); + // `forEach` rather than `for…of`: a `NodeList` is only iterable under a lib + // that declares its iterator, and this package compiles without one. + root.querySelectorAll(`[${NODE_ID_ATTRIBUTE}]`).forEach(element => { + const id = element.getAttribute(NODE_ID_ATTRIBUTE); + if (id !== null) measured.set(id, canvasContentRect(element, root)); + }); + // The canvas's own box, measured through the SAME reader as the blocks + // inside it. `scrollWidth`/`scrollHeight` describe the whole content rather + // than the part on screen, and mixing them with a rectangle read means the + // region and its children answer to two different measurements — which is + // the disagreement this package keeps its geometry in one place to avoid. + // + // The visible box is also the correct extent for what it is used for: it + // decides whether the POINTER is inside the canvas, and a pointer cannot be + // over content that is scrolled out of view. + const rootRect: Rect = canvasContentRect(root, root); + return { + rectOf: id => measured.get(id), + rootRect: () => rootRect, + }; +} + +/** + * Wire pointer events on the canvas root to the drop rules. + * + * Returns handlers to spread onto the root and the state to draw. The canvas + * owns the element; this owns the gesture. + */ +export function useCanvasDrag({ + editor, + slots, + nesting, + activationPx = DEFAULT_ACTIVATION_PX, + switchPx = DEFAULT_SWITCH_PX, +}: UseCanvasDragOptions): CanvasDrag { + const gesture = React.useRef(null); + const [state, setState] = React.useState({ + draggingId: null, + target: null, + refusal: null, + }); + + // Read at event time rather than closed over, so a handler bound on one render + // never patches a document that a later edit has already replaced. + const latest = React.useRef({ editor, slots, nesting }); + latest.current = { editor, slots, nesting }; + + const reset = React.useCallback(() => { + gesture.current = null; + setState({ draggingId: null, target: null, refusal: null }); + }, []); + + const onPointerDown = React.useCallback( + (event: React.PointerEvent) => { + // The primary button only. A right-click opens a context menu and a + // middle-click scrolls, and starting a drag from either takes a gesture + // the browser has already given a meaning. + if (event.button !== 0) return; + + const root = event.currentTarget; + const nodeId = nodeIdFromEvent(event.target); + if (nodeId === null) return; + + const { editor: current } = latest.current; + const node = findNode(current.document.nodes, nodeId); + if (node === undefined) return; + + // A locked block is one the author has asked the editor not to move. The + // press is left alone rather than swallowed, so it still selects. + if (node.locked === true) return; + + const rects = snapshotRects(root); + gesture.current = { + nodeId, + origin: canvasContentPoint(event.clientX, event.clientY, root), + regions: collectRegions(current.document, latest.current.slots, rects), + rects, + forbiddenParents: movingSubtree(current.document, nodeId), + blockName: node.type, + active: false, + switchState: NO_TARGET, + targets: new Map(), + }; + // Capture on the ROOT, so a pointer that leaves the canvas keeps + // delivering. Without it a drag that wanders outside simply stops + // reporting, and the gesture ends wherever the pointer happened to exit. + root.setPointerCapture(event.pointerId); + }, + [] + ); + + const onPointerMove = React.useCallback( + (event: React.PointerEvent) => { + const drag = gesture.current; + if (drag === null) return; + + const root = event.currentTarget; + const pointer = canvasContentPoint(event.clientX, event.clientY, root); + + if (!drag.active) { + const travelled = Math.hypot( + pointer.x - drag.origin.x, + pointer.y - drag.origin.y + ); + if (travelled < activationPx) return; + drag.active = true; + // Selecting on activation rather than on press: a press that turns out + // to be a click is handled by the canvas's own click handler, and + // selecting here as well would run the same decision twice. + latest.current.editor.select(drag.nodeId); + } + + const resolution = resolveDrop( + { + blockName: drag.blockName, + forbiddenParents: drag.forbiddenParents, + regions: drag.regions, + rects: drag.rects, + nesting: latest.current.nesting, + }, + pointer + ); + + if (resolution.kind === "none") { + // The pointer has left every region, which ENDS the aim rather than + // proposing a different one — so it is not put through the switch rule. + // + // That rule exists to stop the committed target flickering between + // RIVALS at a boundary, and it withholds any change until the pointer + // has travelled: a target lost this way would go on being drawn, and a + // release would commit it. Dragging a block off the canvas would then + // drop it back onto the page, which is the opposite of what leaving + // means. There are no rivals here, so there is nothing to smooth. + drag.switchState = NO_TARGET; + setState({ draggingId: drag.nodeId, target: null, refusal: null }); + return; + } + + const candidate = resolution.kind === "target" ? resolution.target : null; + if (candidate !== null) drag.targets.set(candidate.id, candidate); + + // A REFUSAL still goes through the switch rule as a null candidate. The + // pointer is over something, so a boundary between a region that accepts + // the block and one that does not is exactly the jitter the rule is for. + drag.switchState = nextTargetSwitchState( + drag.switchState, + candidate === null ? null : candidate.id, + pointer, + switchPx + ); + + const committed = drag.switchState.committed; + setState({ + draggingId: drag.nodeId, + target: + committed === null ? null : (drag.targets.get(committed) ?? null), + // The refusal is NOT held to the threshold. It says why the region under + // the pointer refuses the block, and delaying that would leave the + // sentence describing a region the pointer has left. + refusal: resolution.kind === "refused" ? resolution.refusal : null, + }); + }, + [activationPx, switchPx] + ); + + const onPointerUp = React.useCallback( + (event: React.PointerEvent) => { + const drag = gesture.current; + if (drag === null) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const committed = drag.switchState.committed; + // ONE absent value, not two. Written as `null` for "nothing committed" + // and `undefined` for "committed to something no longer drawable", the + // guard below would have to exclude both — and excluding one reads as + // complete, so a drag released over no target would reach `target.at`. + const target = + committed === null ? undefined : drag.targets.get(committed); + // A press that never became a drag, or one released where nothing accepts + // the block, ends without an edit. Committing "the last valid target" from + // a pointer that has since moved off it would drop the block somewhere the + // author was not pointing when they let go. + if (drag.active && target !== undefined) { + latest.current.editor.apply({ + kind: "move", + id: drag.nodeId, + to: target.at, + }); + } + reset(); + }, + [reset] + ); + + /* + * Escape abandons the drag, listened for on the DOCUMENT rather than on the + * canvas root. + * + * A key event goes to the FOCUSED element, and pointer capture does not move + * focus — so during a drag the keyboard is still wherever it was, which for a + * drag begun from the inserter is a search field. A handler on the canvas + * would therefore never run for the gesture it exists to cancel. + * + * Registered only WHILE a drag is in flight, and in the capture phase so it + * runs before whatever else claims Escape — the editor's chrome closes on it, + * and closing the editor mid-drag is exactly what an author pressing Escape is + * trying to avoid. Both are the reason `stopPropagation` is warranted here and + * would not be on an always-registered listener. + */ + const dragging = state.draggingId !== null; + React.useEffect(() => { + if (!dragging) return; + const abandon = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + reset(); + }; + document.addEventListener("keydown", abandon, true); + return () => { + document.removeEventListener("keydown", abandon, true); + }; + }, [dragging, reset]); + + return { + ...state, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp, + // A cancel is the browser withdrawing the gesture — the pointer was + // captured by something else, or the touch became a scroll. Dropping + // there would commit a move the author never released. + onPointerCancel: reset, + }, + }; +} + +export interface DropIndicatorProps { + /** Where a drop would land, or null to draw nothing. */ + target: DropTarget | null; +} + +/** + * The line showing where a dropped block will go. + * + * Positioned absolutely in the canvas's content coordinates, which is what the + * rectangles were measured in — so it needs the canvas root to be a positioned + * ancestor, and `builder-chrome.css` makes it one. + * + * `aria-hidden`, and deliberately: the equivalent keyboard move announces its + * own outcome through the editor's one live region, and a second element + * describing the same pointer gesture would be read alongside it. A pointer drag + * is also self-describing to anyone who can see the line. + */ +export function DropIndicator({ + target, +}: DropIndicatorProps): React.JSX.Element | null { + if (target === null) return null; + + // The line is drawn ACROSS the axis children are separated along: a region + // stacking its children downward gets a horizontal rule, a row gets a + // vertical one. + const style: React.CSSProperties = + target.axis === "y" + ? { + left: target.from, + top: target.line, + width: target.to - target.from, + } + : { + left: target.line, + top: target.from, + height: target.to - target.from, + }; + + return ( + ); } diff --git a/packages/builder/src/drop-targets.test.ts b/packages/builder/src/drop-targets.test.ts new file mode 100644 index 0000000000..77931d150d --- /dev/null +++ b/packages/builder/src/drop-targets.test.ts @@ -0,0 +1,540 @@ +/** + * Where a dragged block can land, and which of those places the pointer means. + * + * Every fixture here carries a RELATIONSHIP between adjacent candidates rather + * than a tidy shape, because that is what separates this model from the one it + * replaces. Children of equal height, containers that do not overlap and rows + * of uniform width all resolve correctly under a ranking that measures to a + * rectangle's centre — so a fixture built to look like a page cannot show that + * the line model is doing anything. + * + * @module drop-targets.test + */ +import { describe, expect, it } from "vitest"; + +import type { + BlockDocument, + BlockNode, + NestingSource, +} from "@nextlyhq/blocks-engine"; + +import { + axisOfRects, + collectRegions, + movingSubtree, + regionAt, + resolveDrop, + ROOT_REGION, + targetsInRegion, + type DropRegion, + type DropTarget, + type RectSource, +} from "./drop-targets"; +import type { Rect } from "./geometry"; +import type { SlotSource } from "./inserter"; + +/** Rectangles from a literal map, and a canvas that holds all of them. */ +function rectsOf( + map: Record, + root: Rect = { x: 0, y: 0, width: 400, height: 1000 } +): RectSource { + return { + rectOf: id => map[id], + rootRect: () => root, + }; +} + +/** Slots from a literal map, standing in for the block definitions. */ +function slotsOf(map: Record): SlotSource { + return { slotsOf: type => map[type] }; +} + +/** A nesting source that permits everything, so nesting never masks a case. */ +const PERMISSIVE: NestingSource = { + parentsOf: () => undefined, + slotAllowOf: () => undefined, +}; + +function node( + id: string, + type: string, + slots?: Record +): BlockNode { + return { + id, + type, + version: 1, + props: {}, + ...(slots ? { slots } : {}), + } as BlockNode; +} + +function documentOf(nodes: BlockNode[]): BlockDocument { + return { formatVersion: 1, kind: "page", nodes } as BlockDocument; +} + +/** The one region a flat document has, built the way the resolver builds it. */ +function rootRegion(childIds: string[], axis: "x" | "y" = "y"): DropRegion { + return { + id: ROOT_REGION, + at: { at: "root" }, + depth: 0, + rect: { x: 0, y: 0, width: 400, height: 1000 }, + axis, + childIds, + }; +} + +describe("axisOfRects", () => { + it("reads a vertical stack as running down the page", () => { + expect( + axisOfRects([ + { x: 0, y: 0, width: 400, height: 50 }, + { x: 0, y: 50, width: 400, height: 50 }, + ]) + ).toBe("y"); + }); + + it("reads a row as running across it", () => { + expect( + axisOfRects([ + { x: 0, y: 0, width: 100, height: 200 }, + { x: 100, y: 0, width: 100, height: 200 }, + ]) + ).toBe("x"); + }); + + it("takes vertical when there is no evidence", () => { + // One child cannot indicate a direction, and page content runs down the + // page far more often than across — so the default is not arbitrary. + expect(axisOfRects([{ x: 0, y: 0, width: 10, height: 10 }])).toBe("y"); + expect(axisOfRects([])).toBe("y"); + }); + + it("reads the direction from where children LANDED, not from their shape", () => { + // Two wide, short children stacked vertically. An axis guessed from the + // aspect ratio of the rectangles would call these horizontal; where they + // sit relative to each other is what actually decides it. + expect( + axisOfRects([ + { x: 0, y: 0, width: 400, height: 4 }, + { x: 0, y: 20, width: 400, height: 4 }, + ]) + ).toBe("y"); + }); +}); + +describe("targetsInRegion", () => { + it("gives a region with n children n+1 lines", () => { + const targets = targetsInRegion( + rootRegion(["a", "b"]), + rectsOf({ + a: { x: 0, y: 0, width: 400, height: 100 }, + b: { x: 0, y: 100, width: 400, height: 100 }, + }) + ); + + expect(targets.map(t => t.at)).toEqual([ + { index: 0 }, + { index: 1 }, + { index: 2 }, + ]); + }); + + it("puts the lines on the edges and in the gap between", () => { + // A 20px gap between the two blocks. The interior line sits in the MIDDLE + // of it: reading either edge alone puts the line against one of the two + // blocks, so a margin makes it look like it belongs to that block rather + // than to the space separating them. + const targets = targetsInRegion( + rootRegion(["a", "b"]), + rectsOf({ + a: { x: 0, y: 0, width: 400, height: 100 }, + b: { x: 0, y: 120, width: 400, height: 100 }, + }) + ); + + expect(targets.map(t => t.line)).toEqual([0, 110, 220]); + }); + + it("draws across the region, on the axis the region runs", () => { + const [vertical] = targetsInRegion( + rootRegion(["a"]), + rectsOf({ a: { x: 0, y: 10, width: 400, height: 100 } }) + ); + expect(vertical).toMatchObject({ axis: "y", from: 0, to: 400 }); + + const [horizontal] = targetsInRegion( + rootRegion(["a"], "x"), + rectsOf({ a: { x: 10, y: 0, width: 100, height: 400 } }) + ); + // An x-axis region separates its children left to right, so the line is + // VERTICAL and its extent is the region's height. + expect(horizontal).toMatchObject({ axis: "x", from: 0, to: 1000 }); + }); + + it("gives an empty region one line across its middle", () => { + // The only way to fill a container that has just been inserted. An edge + // would read as "beside this container" rather than "inside it". + const region: DropRegion = { + id: "box::children", + at: { at: "slot", parentType: "core/box", slot: "children" }, + parentId: "box", + slot: "children", + depth: 1, + rect: { x: 0, y: 100, width: 400, height: 200 }, + axis: "y", + childIds: [], + }; + + const targets = targetsInRegion(region, rectsOf({})); + + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + line: 200, + at: { parentId: "box", slot: "children", index: 0 }, + }); + }); + + it("keeps a child's INDEX when it rendered nothing measurable", () => { + // THE case. The op addresses a position in the STORED children, so + // renumbering around an unrendered node would drop the block at a different + // index from the one the line was drawn for. Here "b" is missing from the + // rect source, and the line after "c" must still be index 3. + const targets = targetsInRegion( + rootRegion(["a", "b", "c"]), + rectsOf({ + a: { x: 0, y: 0, width: 400, height: 100 }, + c: { x: 0, y: 100, width: 400, height: 100 }, + }) + ); + + expect(targets.map(t => t.at)).toEqual([ + { index: 0 }, + { index: 2 }, + { index: 3 }, + ]); + }); + + it("addresses a slot region by parent and slot, never by position", () => { + const region: DropRegion = { + id: "box::children", + at: { at: "slot", parentType: "core/box", slot: "children" }, + parentId: "box", + slot: "children", + depth: 1, + rect: { x: 0, y: 0, width: 400, height: 200 }, + axis: "y", + childIds: ["kid"], + }; + + const targets = targetsInRegion( + region, + rectsOf({ kid: { x: 0, y: 10, width: 400, height: 100 } }) + ); + + expect(targets.map(t => t.at)).toEqual([ + { parentId: "box", slot: "children", index: 0 }, + { parentId: "box", slot: "children", index: 1 }, + ]); + }); +}); + +describe("collectRegions", () => { + const slots = slotsOf({ "core/box": ["children"], "core/row": ["items"] }); + + it("gives a container a region from its DEFINITION, not from its node", () => { + // An empty container carries no `slots` key at all, so asking the node + // produces no region for exactly the containers that most need one — and an + // author could never fill anything they had just inserted. + const regions = collectRegions( + documentOf([node("box", "core/box")]), + slots, + rectsOf({ box: { x: 0, y: 0, width: 400, height: 200 } }) + ); + + expect(regions.map(r => r.id)).toEqual([ROOT_REGION, "box::children"]); + expect(regions[1]).toMatchObject({ childIds: [], depth: 1 }); + }); + + it("skips a container that rendered nothing measurable", () => { + // Rather than giving it a zero rectangle. A zero rectangle contains no + // point so it would never resolve, but it WOULD sit in the list claiming to + // be droppable — an absence that reads as a presence. + const regions = collectRegions( + documentOf([node("box", "core/box")]), + slots, + rectsOf({}) + ); + + expect(regions.map(r => r.id)).toEqual([ROOT_REGION]); + }); + + it("reports a nested container as deeper than the one holding it", () => { + const regions = collectRegions( + documentOf([ + node("outer", "core/box", { children: [node("inner", "core/row")] }), + ]), + slots, + rectsOf({ + outer: { x: 0, y: 0, width: 400, height: 300 }, + inner: { x: 20, y: 20, width: 360, height: 100 }, + }) + ); + + expect(regions.map(r => [r.id, r.depth])).toEqual([ + [ROOT_REGION, 0], + ["outer::children", 1], + ["inner::items", 2], + ]); + }); + + it("gives a block with no declared slots no region", () => { + const regions = collectRegions( + documentOf([node("h", "core/heading")]), + slots, + rectsOf({ h: { x: 0, y: 0, width: 400, height: 40 } }) + ); + + expect(regions.map(r => r.id)).toEqual([ROOT_REGION]); + }); +}); + +describe("regionAt", () => { + const outer: DropRegion = { + id: "outer::children", + at: { at: "slot", parentType: "core/box", slot: "children" }, + parentId: "outer", + slot: "children", + depth: 1, + rect: { x: 0, y: 0, width: 400, height: 300 }, + axis: "y", + childIds: ["inner"], + }; + const inner: DropRegion = { + id: "inner::items", + at: { at: "slot", parentType: "core/row", slot: "items" }, + parentId: "inner", + slot: "items", + depth: 2, + rect: { x: 20, y: 20, width: 360, height: 100 }, + axis: "x", + childIds: [], + }; + const regions = [rootRegion(["outer"]), outer, inner]; + + it("gives the pointer to the DEEPEST container holding it", () => { + // Nested containers overlap by construction, so depth is what stops an + // ancestor claiming a pointer that is inside its own child. + expect(regionAt(regions, { x: 100, y: 50 })?.id).toBe("inner::items"); + expect(regionAt(regions, { x: 100, y: 200 })?.id).toBe("outer::children"); + expect(regionAt(regions, { x: 100, y: 500 })?.id).toBe(ROOT_REGION); + }); + + it("reports nothing when the pointer has left the canvas", () => { + // Not the root as a fallback. Falling back would let a drop resolve from a + // pointer that had left the canvas, which is how a block lands on the page + // after a drag the author abandoned by dragging away. + expect(regionAt(regions, { x: 100, y: 2000 })).toBeUndefined(); + }); + + it("skips a region inside the block being dragged", () => { + // Skipped rather than refused, so the pointer falls through to the + // container around it: an author dragging a box over its own interior means + // "put it beside itself". + expect(regionAt(regions, { x: 100, y: 50 }, new Set(["inner"]))?.id).toBe( + "outer::children" + ); + expect( + regionAt(regions, { x: 100, y: 50 }, new Set(["outer", "inner"]))?.id + ).toBe(ROOT_REGION); + }); +}); + +describe("movingSubtree", () => { + it("collects the node and everything under it", () => { + const document = documentOf([ + node("outer", "core/box", { + children: [ + node("inner", "core/row", { items: [node("leaf", "core/heading")] }), + ], + }), + ]); + + expect([...movingSubtree(document, "outer")].sort()).toEqual([ + "inner", + "leaf", + "outer", + ]); + }); + + it("is empty for a palette drag and for an id the document lost", () => { + const document = documentOf([node("a", "core/heading")]); + + expect(movingSubtree(document, undefined).size).toBe(0); + expect(movingSubtree(document, "gone").size).toBe(0); + }); +}); + +describe("resolveDrop", () => { + /** Two children of very UNEQUAL height, which is the separating fixture. */ + const unequal = { + regions: [rootRegion(["tall", "short"])], + rects: rectsOf({ + tall: { x: 0, y: 0, width: 400, height: 300 }, + short: { x: 0, y: 300, width: 400, height: 20 }, + }), + nesting: PERMISSIVE, + blockName: "core/heading", + forbiddenParents: new Set(), + }; + + function targetAt(y: number): DropTarget { + const resolution = resolveDrop(unequal, { x: 200, y }); + if (resolution.kind !== "target") { + throw new Error( + `expected a target at y=${String(y)}, got ${resolution.kind}` + ); + } + return resolution.target; + } + + it("switches at each child's CENTRE, whatever the children's sizes are", () => { + // THE test this model exists for. The lines are at 0, 300 and 320, so the + // boundaries fall at 150 (the tall child's centre) and 310 (the short + // one's). + // + // A ranker measuring to the middle of a drop ZONE would put the boundary + // between index 1 and index 2 near the middle of the GAP between the two + // zones instead — and with a 20px child against a 300px one, that is + // nowhere near the short child's centre. y=305 is inside the short block's + // leading half and must still resolve to index 1. + expect(targetAt(140).at).toEqual({ index: 0 }); + expect(targetAt(160).at).toEqual({ index: 1 }); + expect(targetAt(305).at).toEqual({ index: 1 }); + expect(targetAt(315).at).toEqual({ index: 2 }); + }); + + it("carries the line it resolved to, so the indicator and the drop agree", () => { + // Produced together rather than by two calls: computed apart, the indicator + // and the drop can be derived from different readings and the block lands + // somewhere other than where the line was drawn. + expect(targetAt(160)).toMatchObject({ line: 300, at: { index: 1 } }); + }); + + it("refuses with the engine's reason when the region will not take the block", () => { + const regions = [ + { + id: "acc::panels", + at: { at: "slot", parentType: "core/accordion", slot: "panels" }, + parentId: "acc", + slot: "panels", + depth: 1, + rect: { x: 0, y: 0, width: 400, height: 200 }, + axis: "y", + childIds: [], + } satisfies DropRegion, + ]; + + const resolution = resolveDrop( + { + blockName: "core/heading", + forbiddenParents: new Set(), + regions, + rects: rectsOf({}), + // The container's half of the rule: the slot names what it holds. + nesting: { + parentsOf: () => undefined, + slotAllowOf: () => ["core/accordion-item"], + }, + }, + { x: 100, y: 100 } + ); + + // A refusal rather than an absent target, because the canvas has to say + // WHY: the author aimed at this region. + expect(resolution).toEqual({ + kind: "refused", + refusal: { + regionId: "acc::panels", + reason: "not-allowed-in-slot", + permitted: ["core/accordion-item"], + }, + }); + }); + + it("refuses on the CHILD's half of the rule too", () => { + // Both halves are asked, and neither implies the other. Here the slot takes + // anything and the block itself declares where it belongs. + const resolution = resolveDrop( + { + blockName: "core/accordion-item", + forbiddenParents: new Set(), + regions: [rootRegion([])], + rects: rectsOf({}), + nesting: { + parentsOf: type => + type === "core/accordion-item" ? ["core/accordion"] : undefined, + }, + }, + { x: 100, y: 100 } + ); + + expect(resolution).toMatchObject({ + kind: "refused", + refusal: { reason: "restricted-at-root" }, + }); + }); + + it("reports nothing when the pointer is off the canvas", () => { + expect(resolveDrop(unequal, { x: 200, y: 5000 })).toEqual({ kind: "none" }); + }); + + it("keeps the EARLIER index when two lines coincide", () => { + // Two lines land on the same coordinate when a child measures zero on the + // axis — a collapsed block, or one whose styles gave it no height. + // Preferring the later one would make a drop land AFTER a block the author + // aimed before. + const resolution = resolveDrop( + { + blockName: "core/heading", + forbiddenParents: new Set(), + regions: [rootRegion(["zero"])], + rects: rectsOf({ zero: { x: 0, y: 100, width: 400, height: 0 } }), + nesting: PERMISSIVE, + }, + { x: 200, y: 100 } + ); + + expect(resolution).toMatchObject({ + kind: "target", + target: { at: { index: 0 } }, + }); + }); + + it("resolves a drop inside a container to that container's own line", () => { + const document = documentOf([node("box", "core/box")]); + const rects = rectsOf({ box: { x: 0, y: 100, width: 400, height: 200 } }); + const regions = collectRegions( + document, + slotsOf({ "core/box": ["children"] }), + rects + ); + + const resolution = resolveDrop( + { + blockName: "core/heading", + forbiddenParents: new Set(), + regions, + rects, + nesting: PERMISSIVE, + }, + { x: 200, y: 150 } + ); + + expect(resolution).toMatchObject({ + kind: "target", + target: { at: { parentId: "box", slot: "children", index: 0 } }, + }); + }); +}); diff --git a/packages/builder/src/drop-targets.ts b/packages/builder/src/drop-targets.ts new file mode 100644 index 0000000000..50957b0071 --- /dev/null +++ b/packages/builder/src/drop-targets.ts @@ -0,0 +1,540 @@ +/** + * Where a dragged block can land, and which of those places the pointer means. + * + * The canvas has to answer one question on every pointer move: of all the + * positions a block could be dropped into, which one is the author aiming at? + * This module answers it, and the shape of the answer is the whole design. + * + * ## Regions first, then lines — and that order is the point + * + * Three earlier designs for this failed, and they failed for one reason. Each + * tried to SCORE every candidate position against the pointer and take the best + * score. Scoring is comparative, so the moment 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 and not + * that one", because containment is a statement about the candidates you are + * being compared against. + * + * The way out is to stop comparing across containers at all: + * + * 1. **Resolve the region.** The deepest container whose rectangle holds the + * pointer owns the drop. This is decided, not scored. + * 2. **Rank inside it.** Only then, and only among that region's own insertion + * points, measure a distance. + * + * Every number in a comparison is then the same kind of number by construction, + * and "which container" is never something a distance has to smuggle. + * + * ## An insertion point is a LINE, not a rectangle + * + * The natural thing to measure against is the middle of a drop zone. It is also + * wrong, and wrong in a way that only shows up when adjacent blocks have + * different heights: a short block's midpoint sits close to its neighbour's, so + * the target flips while the pointer is still well inside the block the author + * is pointing at, and the block lands at the wrong index. + * + * An insertion point is a place children are separated, so it is a LINE. Ranking + * by distance to that line puts the switch boundary exactly at each child's + * centre, whatever the children's sizes are — which is the behaviour an author + * expects and describes as "it goes where I point". + * + * ## Nothing here reads a block's height as a threshold + * + * A rule of the form "a block must be at least N pixels tall to compete" cannot + * work on this canvas. `core/spacer` takes its height from an author-set prop + * with no lower bound, and `core/divider` renders as a one-pixel rule — so any + * N excludes some block an author can legitimately place, and excluding it + * means they can never drop beside it. There is no floor to measure, so this + * module measures distances and never a size. + * + * Steadiness near a boundary is not this module's job either. It reports the + * target the pointer is over right now; {@link nextTargetSwitchState} decides + * when a new one is allowed to replace the committed one, on pointer travel + * rather than on geometry. Keeping the two apart is what lets a region boundary + * be an ordinary candidate change rather than a special case: hysteresis is + * applied to the resolved answer, so it does not matter which region produced + * it. + * + * Pure, and it takes measured rectangles rather than elements — so every rule + * above is assertable without a browser, which matters because jsdom reports + * every element as zero-sized and could not exercise one of them. + * + * @module drop-targets + */ + +import type { + BlockDocument, + BlockNode, + NestingRefusal, + NestingSource, +} from "@nextlyhq/blocks-engine"; + +import type { Point, Rect } from "./geometry"; +import { blockAllowedAt, type InsertTarget, type SlotSource } from "./inserter"; +import type { OpPosition } from "./ops"; + +/** + * The direction a region lays its children out. + * + * `"y"` is an ordinary vertical stack; `"x"` is a row or a grid track running + * across. It decides which coordinate separates two children and therefore + * which way an insertion line is drawn. + */ +export type DropAxis = "x" | "y"; + +/** The region id standing for the document's top level. */ +export const ROOT_REGION = "root"; + +/** + * Rectangles, measured from wherever they actually are. + * + * An interface rather than a DOM read so this module stays pure. The canvas + * supplies one backed by `getBoundingClientRect`; a test supplies a literal. + * + * All rectangles must be in ONE coordinate space — the canvas's. Mixing spaces + * produces an indicator drawn a constant distance from the gap it names, which + * looks like a styling mistake rather than a measurement one. + */ +export interface RectSource { + /** Where a node's element sits, or `undefined` when it rendered nothing. */ + rectOf(nodeId: string): Rect | undefined; + /** The canvas's own extent, which is the root region's. */ + rootRect(): Rect; +} + +/** + * One container's child list, as somewhere a block can be dropped. + * + * A region is addressed by a parent and a slot, never by position: the document + * makes ids the only thing anything stores, and a positional region would name + * a different container the moment a sibling is inserted above it. + */ +export interface DropRegion { + /** Stable identity: {@link ROOT_REGION}, or `"::"`. */ + readonly id: string; + /** What this region is, as the nesting rule needs to see it. */ + readonly at: InsertTarget; + /** The container node, absent for the root region. */ + readonly parentId?: string; + /** The slot within that container, absent for the root region. */ + readonly slot?: string; + /** How far inside the document this region sits; the root is 0. */ + readonly depth: number; + /** The container's measured extent, which decides what it contains. */ + readonly rect: Rect; + /** Which way its children run. */ + readonly axis: DropAxis; + /** Its children's ids, in document order. */ + readonly childIds: readonly string[]; +} + +/** + * One place a block can land, and the line that says so. + * + * `at` is what the op needs and `line` is what the author sees, produced + * together for the same reason the inserter produces a position and its target + * together: computed apart, the indicator and the drop can be derived from + * different readings and the block lands somewhere other than where the line + * was drawn. + */ +export interface DropTarget { + /** + * Stable identity across pointer moves. + * + * Required by the switch rule, which compares the committed target with a + * rival — an identity derived from the rectangle would differ every time the + * page reflowed, and every reflow would read as a crossing. + */ + readonly id: string; + /** The region this target belongs to. */ + readonly regionId: string; + /** Where the block goes. */ + readonly at: OpPosition; + /** What that position is, for the nesting rule. */ + readonly target: InsertTarget; + /** The axis children are separated along. */ + readonly axis: DropAxis; + /** + * Where the line sits along {@link axis} — a `y` for a horizontal line, an + * `x` for a vertical one. + */ + readonly line: number; + /** Where the line starts, across the axis. */ + readonly from: number; + /** Where the line ends, across the axis. */ + readonly to: number; +} + +/** + * Why a drop the author aimed at cannot happen. + * + * The engine's refusal CODE, not a sentence. Wording is a presentation + * decision — it belongs where the words are drawn and where they can be + * translated — and a code is what a caller can branch on. `permitted` travels + * with it because naming what the region DOES take is the difference between + * "no" and an instruction. + */ +export interface DropRefusal { + readonly regionId: string; + readonly reason: NestingRefusal; + readonly permitted: readonly string[]; +} + +/** + * What the pointer currently means. + * + * A refusal is a distinct answer rather than an absent target, and the + * distinction is what the canvas needs to say why. An author who drags a + * heading over an accordion has aimed at something; showing no indicator tells + * them the editor did not notice, while showing a refusal tells them the region + * does not take that block. + */ +export type DropResolution = + | { readonly kind: "target"; readonly target: DropTarget } + | { readonly kind: "refused"; readonly refusal: DropRefusal } + | { readonly kind: "none" }; + +/** The coordinate that separates children on an axis. */ +function along(point: Point, axis: DropAxis): number { + return axis === "y" ? point.y : point.x; +} + +/** A rectangle's leading edge on an axis. */ +function leadingEdge(rect: Rect, axis: DropAxis): number { + return axis === "y" ? rect.y : rect.x; +} + +/** A rectangle's trailing edge on an axis. */ +function trailingEdge(rect: Rect, axis: DropAxis): number { + return axis === "y" ? rect.y + rect.height : rect.x + rect.width; +} + +/** Whether a rectangle holds a point, edges included. */ +function contains(rect: Rect, point: Point): boolean { + return ( + point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height + ); +} + +/** + * Which way a region's children run, read from where they actually landed. + * + * Measured rather than declared. A container's CSS is only half the story — a + * `flex-direction: row` whose children wrapped runs down the page, and a block + * that declares nothing still lays out somehow — so the rectangles are the + * evidence and the declaration is a claim about them. Reading the result also + * means grid, flex, inline and ordinary flow need no separate handling. + * + * The axis with the greater spread of leading edges wins. Children stacked + * vertically share an `x` and differ in `y`, and a row is the reverse. + * + * **Vertical when there is no evidence** — fewer than two children, or children + * that spread equally. A single child cannot indicate a direction, and page + * content runs down the page far more often than across. + * + * **Known limit: a region that WRAPS is served on one axis only.** A grid of + * three columns by two rows genuinely separates its children both ways, and a + * single line cannot express "after the end of row one". Such a region gets its + * dominant axis, which orders row-major grids correctly and leaves column-major + * ones approximate. + */ +export function axisOfRects(rects: readonly Rect[]): DropAxis { + if (rects.length < 2) return "y"; + const xs = rects.map(rect => rect.x); + const ys = rects.map(rect => rect.y); + const spreadX = Math.max(...xs) - Math.min(...xs); + const spreadY = Math.max(...ys) - Math.min(...ys); + return spreadX > spreadY ? "x" : "y"; +} + +/** + * Every region in the document, deepest last. + * + * A container contributes a region for each slot its DEFINITION declares, not + * for each slot its node happens to hold. An empty container carries no `slots` + * key at all — `makeNode` writes one only when children are supplied — so + * asking the node would produce no region for exactly the containers that most + * need one, and an author could never fill anything they had just inserted. + * + * A container that rendered nothing measurable is skipped rather than given a + * zero rectangle. A zero rectangle contains no point, so it would never be + * resolved, but it WOULD sit in the list claiming to be droppable — an absence + * that reads as a presence is the harder of the two to notice. + */ +export function collectRegions( + document: BlockDocument, + slots: SlotSource, + rects: RectSource +): DropRegion[] { + const regions: DropRegion[] = []; + + const walk = (nodes: readonly BlockNode[], depth: number): void => { + for (const node of nodes) { + const declared = slots.slotsOf(node.type) ?? []; + for (const slot of declared) { + const rect = rects.rectOf(node.id); + if (rect === undefined) continue; + const children = node.slots?.[slot] ?? []; + const childRects = children + .map(child => rects.rectOf(child.id)) + .filter((child): child is Rect => child !== undefined); + regions.push({ + id: `${node.id}::${slot}`, + at: { at: "slot", parentType: node.type, slot }, + parentId: node.id, + slot, + depth, + rect, + axis: axisOfRects(childRects), + childIds: children.map(child => child.id), + }); + walk(children, depth + 1); + } + } + }; + + const rootChildRects = document.nodes + .map(node => rects.rectOf(node.id)) + .filter((rect): rect is Rect => rect !== undefined); + + regions.push({ + id: ROOT_REGION, + at: { at: "root" }, + depth: 0, + rect: rects.rootRect(), + axis: axisOfRects(rootChildRects), + childIds: document.nodes.map(node => node.id), + }); + + walk(document.nodes, 1); + return regions; +} + +/** + * The insertion lines inside one region, in index order. + * + * A region with `n` children has `n + 1` of them. The first sits on the first + * child's leading edge and the last on the final child's trailing edge; the + * ones between sit in the middle of the gap separating two children, which is + * where an author sees the boundary and where a margin puts it. + * + * Ranking by distance to these lines is what makes each child's CENTRE the + * place the target changes: a pointer in a child's leading half is nearer the + * line before it, and in its trailing half nearer the line after. That holds + * for children of any size, which is precisely what a rule measuring to a zone's + * middle gets wrong. + * + * **An empty region still has one**, drawn across its middle. It is the only + * way to fill a container that has just been inserted, and a container that + * cannot be filled is decorative. + * + * A child that rendered nothing measurable is skipped, but its INDEX is not: + * the op addresses a position in the stored children, and quietly renumbering + * around an unrendered node would drop the block at a different index from the + * one the line was drawn for. + */ +export function targetsInRegion( + region: DropRegion, + rects: RectSource +): DropTarget[] { + const axis = region.axis; + const across: Pick = + axis === "y" + ? { from: region.rect.x, to: region.rect.x + region.rect.width } + : { from: region.rect.y, to: region.rect.y + region.rect.height }; + + 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), + target: region.at, + axis, + line, + ...across, + }); + + const measured = region.childIds.map(id => rects.rectOf(id)); + const rendered = measured.filter((rect): rect is Rect => rect !== undefined); + if (rendered.length === 0) { + // Across the middle: with nothing rendered there is no gap to sit in, and + // an edge would read as "beside this container" rather than "inside it". + const middle = + axis === "y" + ? region.rect.y + region.rect.height / 2 + : region.rect.x + region.rect.width / 2; + return [make(0, middle)]; + } + + const targets: DropTarget[] = []; + let previous: Rect | undefined; + measured.forEach((rect, index) => { + if (rect === undefined) return; + targets.push( + make( + index, + previous === undefined + ? leadingEdge(rect, axis) + : // The middle of the gap. Reading either edge alone puts the line + // against one of the two blocks, so a margin makes it look like it + // belongs to that one rather than to the space between them. + (trailingEdge(previous, axis) + leadingEdge(rect, axis)) / 2 + ) + ); + previous = rect; + }); + if (previous !== undefined) { + targets.push(make(region.childIds.length, trailingEdge(previous, axis))); + } + return targets; +} + +/** Ids of a node and everything under it. */ +function subtreeIds(node: BlockNode, into: Set): void { + into.add(node.id); + for (const children of Object.values(node.slots ?? {})) { + for (const child of children) subtreeIds(child, into); + } +} + +/** + * The node with this id, and everything inside it. + * + * A block cannot be dropped into itself or into anything it contains: the move + * would detach a subtree and re-attach it beneath itself, which is not a page. + * Collected as a set so the check below is a lookup rather than a walk per + * candidate region. + */ +export function movingSubtree( + document: BlockDocument, + movingId: string | undefined +): Set { + const ids = new Set(); + if (movingId === undefined) return ids; + const find = (nodes: readonly BlockNode[]): BlockNode | undefined => { + for (const node of nodes) { + if (node.id === movingId) return node; + for (const children of Object.values(node.slots ?? {})) { + const found = find(children); + if (found !== undefined) return found; + } + } + return undefined; + }; + const node = find(document.nodes); + if (node !== undefined) subtreeIds(node, ids); + return ids; +} + +/** What {@link resolveDrop} needs to answer a pointer. */ +export interface DropQuery { + /** The block being dragged, as its registered type name. */ + readonly blockName: string; + /** + * Nodes the drop may not land inside: the dragged block and its descendants. + * + * Empty for a drag from the palette, where nothing is being detached. Passed + * in already computed rather than derived here, because it cannot change + * during a drag while this runs on every pointer move — and a walk of the + * document per move is work whose answer is known before the drag starts. + * + * Build it with {@link movingSubtree}. + */ + readonly forbiddenParents: ReadonlySet; + readonly regions: readonly DropRegion[]; + readonly nesting: NestingSource; + readonly rects: RectSource; +} + +/** + * Which region owns the pointer. + * + * The DEEPEST containing one, so a container nested inside another claims the + * pointer over its own area rather than losing it to the ancestor it sits in. + * Ties on depth go to the one declared later, which is document order — a + * region overlapping a sibling is already a layout the author can see, and + * taking the later one matches which of the two paints on top. + * + * `undefined` when the pointer is outside everything, INCLUDING outside the + * root. Falling back to the root would let a drop resolve from a pointer that + * had left the canvas, which is how a block lands on the page after a drag the + * author abandoned by dragging away. + */ +export function regionAt( + regions: readonly DropRegion[], + pointer: Point, + forbiddenParents: ReadonlySet = new Set() +): DropRegion | undefined { + let owner: DropRegion | undefined; + for (const region of regions) { + // A region inside the block being dragged is SKIPPED rather than refused, + // so the pointer falls through to the container around it. That is what an + // author dragging a box over its own interior means: put it beside itself. + // Refusing here would show "you cannot drop that there" for a gesture with + // an obvious correct reading. + if ( + region.parentId !== undefined && + forbiddenParents.has(region.parentId) + ) { + continue; + } + if (!contains(region.rect, pointer)) continue; + if (owner === undefined || region.depth >= owner.depth) owner = region; + } + return owner; +} + +/** + * The target the pointer means right now. + * + * Region first, then the nearest line inside it — never a distance across the + * two, for the reason in the module docblock. + * + * A region the block cannot go in produces a REFUSAL rather than deferring to + * the container around it. Resolving to the ancestor would silently drop the + * block somewhere the author was not aiming, which the nesting rule exists to + * prevent; saying no is the honest answer and it carries the reason. + */ +export function resolveDrop(query: DropQuery, pointer: Point): DropResolution { + const region = regionAt(query.regions, pointer, query.forbiddenParents); + if (region === undefined) return { kind: "none" }; + + const verdict = blockAllowedAt(query.blockName, region.at, query.nesting); + if (!verdict.allowed) { + return { + kind: "refused", + refusal: { + regionId: region.id, + reason: verdict.reason, + permitted: verdict.permitted, + }, + }; + } + + let best: DropTarget | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of targetsInRegion(region, query.rects)) { + const distance = Math.abs(along(pointer, candidate.axis) - candidate.line); + // Strictly nearer, so an exact tie keeps the EARLIER index. Two lines + // coincide when a child measures zero on the axis, and preferring the later + // one there would make a drop land after a block the author aimed before. + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + // Undefined for a region with no lines at all, and for one whose coordinates + // are not comparable. Starting from an infinite distance rather than from the + // first element is what makes those the same answer: seeding with `targets[0]` + // would commit to a line before measuring it, so a region producing only + // unusable coordinates would resolve to one of them. + if (best === undefined) return { kind: "none" }; + return { kind: "target", target: best }; +} diff --git a/packages/builder/src/geometry-dom.test.ts b/packages/builder/src/geometry-dom.test.ts index 736ed8442a..c36797e7f4 100644 --- a/packages/builder/src/geometry-dom.test.ts +++ b/packages/builder/src/geometry-dom.test.ts @@ -17,7 +17,11 @@ */ import { afterEach, describe, expect, it } from "vitest"; -import { frameInsetOf } from "./geometry-dom"; +import { + canvasContentPoint, + canvasContentRect, + frameInsetOf, +} from "./geometry-dom"; /** * A frame with known border and padding. @@ -107,3 +111,86 @@ describe("frameInsetOf", () => { expect(Number.isFinite(inset.top)).toBe(true); }); }); + +/** + * An element reporting a fixed viewport rectangle. + * + * Stubbed because jsdom lays nothing out and reports every element as + * zero-sized, so a fixture built from real styles would measure zero against + * zero and pass whatever the arithmetic did. + */ +function box(rect: { x: number; y: number; width: number; height: number }) { + const element = document.createElement("div"); + element.getBoundingClientRect = () => + ({ ...rect, top: rect.y, left: rect.x }) as DOMRect; + return element; +} + +/** A canvas root at a viewport position, optionally scrolled. */ +function canvasRoot( + rect: { x: number; y: number; width: number; height: number }, + scroll: { left: number; top: number } = { left: 0, top: 0 } +) { + const root = box(rect); + Object.defineProperty(root, "scrollLeft", { value: scroll.left }); + Object.defineProperty(root, "scrollTop", { value: scroll.top }); + return root; +} + +describe("canvasContentRect", () => { + it("reports a child relative to the canvas, not to the viewport", () => { + // The separating property is the ROOT's own offset. A canvas at the origin + // makes viewport and content coordinates identical, so a fixture placing + // the root at 0,0 passes on an implementation that never subtracts it. + const root = canvasRoot({ x: 100, y: 50, width: 400, height: 800 }); + const child = box({ x: 140, y: 150, width: 200, height: 60 }); + + expect(canvasContentRect(child, root)).toEqual({ + x: 40, + y: 100, + width: 200, + height: 60, + }); + }); + + it("does not move when the canvas is scrolled", () => { + // THE case this function exists for. `getBoundingClientRect` answers in + // viewport coordinates, so a scrolled canvas reports its children higher up + // — and a rectangle stored raw drifts by exactly the scroll, which reads as + // an overlay slowly going wrong rather than as a wrong measurement. + const unscrolled = canvasRoot({ x: 0, y: 0, width: 400, height: 800 }); + const restingChild = box({ x: 0, y: 300, width: 400, height: 100 }); + + // The same page scrolled down 250: the root stays put and the child's + // viewport position moves up by the scroll. + const scrolled = canvasRoot( + { x: 0, y: 0, width: 400, height: 800 }, + { left: 0, top: 250 } + ); + const scrolledChild = box({ x: 0, y: 50, width: 400, height: 100 }); + + expect(canvasContentRect(scrolledChild, scrolled)).toEqual( + canvasContentRect(restingChild, unscrolled) + ); + }); +}); + +describe("canvasContentPoint", () => { + it("maps a pointer into the same space the rectangles are measured in", () => { + // Asserted AGAINST a rectangle rather than against literals. The fault this + // pairing prevents is the two disagreeing, and two independent assertions on + // two sets of numbers would both pass while they disagreed with each other. + const root = canvasRoot( + { x: 100, y: 50, width: 400, height: 800 }, + { left: 0, top: 250 } + ); + const child = box({ x: 140, y: 150, width: 200, height: 60 }); + const rect = canvasContentRect(child, root); + + // A pointer on the child's top-left corner, in viewport coordinates. + expect(canvasContentPoint(140, 150, root)).toEqual({ + x: rect.x, + y: rect.y, + }); + }); +}); diff --git a/packages/builder/src/geometry-dom.ts b/packages/builder/src/geometry-dom.ts index bd6fae3f63..771a4129aa 100644 --- a/packages/builder/src/geometry-dom.ts +++ b/packages/builder/src/geometry-dom.ts @@ -20,7 +20,7 @@ * @module geometry-dom */ -import type { FrameInset } from "./geometry"; +import type { FrameInset, Point, Rect } from "./geometry"; /** * How far a frame's content viewport sits inside its border box. @@ -44,3 +44,50 @@ export function frameInsetOf(frame: HTMLIFrameElement): FrameInset { top: frame.clientTop + (Number.isFinite(paddingTop) ? paddingTop : 0), }; } + +/** + * A rectangle in the canvas's own CONTENT coordinates. + * + * The space the editor's chrome is drawn in: the origin is the canvas root's + * top-left, and scrolling the root does not move it. `getBoundingClientRect` + * answers in VIEWPORT coordinates, which move with every scroll and with the + * page's own layout — so a rectangle stored raw is only correct until something + * scrolls, and the symptom is an overlay that drifts rather than one that is + * plainly wrong. + * + * Adding the root's scroll is what makes the result stable, and it is also what + * makes it the same space an absolutely positioned child of the root resolves + * against: such a child is placed relative to the padding box with scroll + * included. So a rectangle measured here can be handed straight to `style.top` + * and lands on what was measured. + */ +export function canvasContentRect(element: Element, root: HTMLElement): Rect { + const box = element.getBoundingClientRect(); + const rootBox = root.getBoundingClientRect(); + return { + x: box.x - rootBox.x + root.scrollLeft, + y: box.y - rootBox.y + root.scrollTop, + width: box.width, + height: box.height, + }; +} + +/** + * A viewport point in the canvas's own content coordinates. + * + * The exact counterpart of {@link canvasContentRect} rather than a second + * mapping written to match: a pointer event arrives in viewport coordinates and + * has to be compared against rectangles measured above, and the two disagreeing + * by a scroll offset is the fault this pairing exists to prevent. + */ +export function canvasContentPoint( + clientX: number, + clientY: number, + root: HTMLElement +): Point { + const rootBox = root.getBoundingClientRect(); + return { + x: clientX - rootBox.x + root.scrollLeft, + y: clientY - rootBox.y + root.scrollTop, + }; +} diff --git a/packages/builder/src/geometry-ownership.test.ts b/packages/builder/src/geometry-ownership.test.ts index ff56ac8776..ba098a6c0d 100644 --- a/packages/builder/src/geometry-ownership.test.ts +++ b/packages/builder/src/geometry-ownership.test.ts @@ -56,9 +56,49 @@ const SRC_DIR = dirname(fileURLToPath(import.meta.url)); */ const GEOMETRY_MODULE = "geometry.ts"; +/** + * The module that READS what the mapping needs from the DOM. + * + * A second allowed path, and deliberately not a widening of the rule this file + * enforces. `geometry.ts` documents itself as taking plain numbers so the + * arithmetic stays testable without a browser, which leaves the reads somewhere + * — and that somewhere has been `geometry-dom.ts` since it was split out. The + * two together ARE the boundary; the invariant is that a rectangle enters this + * package through one door, not that the door has one file behind it. + * + * The caution in the docblock still stands and still bites: the allowance is + * these two exact paths, so `overlay-geometry.ts` and `overlays/geometry-dom.ts` + * are both refused. Adding a third is a decision to be argued in a review, not a + * name to be chosen. + */ +const GEOMETRY_DOM_MODULE = "geometry-dom.ts"; + /** This file, which necessarily names the reads it is looking for. */ const OWN_TEST = "geometry-ownership.test.ts"; +/** + * Whether a file is a test, which this guard does not constrain. + * + * A family of names, deliberately, and the one place that is safe here — for a + * STRUCTURAL reason rather than a convenience one. The invariant is that the + * editor maps between coordinate spaces in one place, and "the editor" is what + * ships: nothing in `dist` can reach a test file, because no entry point + * references one. A second mapping written in a test is therefore not a second + * mapping the editor could ever use. + * + * A test also has a legitimate need the product code does not. jsdom lays + * nothing out and reports every element as zero-sized, so a fixture with a + * position has to ASSIGN `getBoundingClientRect` — and an assignment reads to + * this scan exactly as a call does. Refusing those would make the geometry the + * one part of this package that cannot be exercised against a DOM. + * + * `layering.test.ts` makes the same split for the same reason, holding a + * separate `ALLOWED_IN_TESTS` list beside its runtime one. + */ +function isTest(file: string): boolean { + return /\.test\.(ts|tsx|mts)$/.test(file); +} + /** Whether a file IS the named module, by its path beneath `src`. */ function isModule(file: string, relativePath: string): boolean { return relative(SRC_DIR, file) === relativePath; @@ -134,16 +174,22 @@ function crossFrameReads(text: string, file: string): string[] { describe("rectangles are read across the frame in one place", () => { const files = sourceFiles(SRC_DIR); - it("has files to check", () => { + it("has PRODUCT files to check", () => { // A guard that read nothing reports the same clean pass as one that read // everything and found nothing, and a renamed directory is all it takes. - expect(files.length).toBeGreaterThan(0); + // + // Counted after the test files are removed, which is the half that matters + // now they are exempt: a scan that had somehow reduced to tests alone would + // constrain nothing while still satisfying a bare "found some files". + expect(files.filter(file => !isTest(file)).length).toBeGreaterThan(0); }); it("finds no cross-frame read outside the geometry module", () => { const offenders = files .filter(file => !isModule(file, GEOMETRY_MODULE)) + .filter(file => !isModule(file, GEOMETRY_DOM_MODULE)) .filter(file => !isModule(file, OWN_TEST)) + .filter(file => !isTest(file)) .flatMap(file => { const reads = crossFrameReads(readFileSync(file, "utf8"), file); return reads.map(read => `${relative(SRC_DIR, file)} reads ${read}`); @@ -167,6 +213,23 @@ describe("rectangles are read across the frame in one place", () => { expect( isModule(join(SRC_DIR, "overlays", "geometry.ts"), GEOMETRY_MODULE) ).toBe(false); + + // The SECOND allowance carries the same narrowness, asserted rather than + // assumed. A second allowed path is where an exact-match rule quietly + // becomes a family of names, because the argument for adding one reads just + // as well the third time. + expect( + isModule(join(SRC_DIR, "geometry-dom.ts"), GEOMETRY_DOM_MODULE) + ).toBe(true); + expect( + isModule(join(SRC_DIR, "canvas-geometry-dom.ts"), GEOMETRY_DOM_MODULE) + ).toBe(false); + expect( + isModule( + join(SRC_DIR, "overlays", "geometry-dom.ts"), + GEOMETRY_DOM_MODULE + ) + ).toBe(false); }); it("can see a cross-frame read when there is one", () => { diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index 4d11bc0e7b..3cf5ac1dbd 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -157,3 +157,59 @@ export type { * without reaching for the client entry. */ export type { BuilderCommand, CommandPaletteProps } from "./command-palette"; + +/** + * @experimental The drop rules: where a dragged block can land, and which of + * those places a pointer means. + * + * From this entry rather than from `/shell`, because none of it touches React — + * it is arithmetic over measured rectangles, and the split is what lets the + * acceptance harness and a host reason about a drop without loading an editor. + * + * The same argument as the geometry above, and the same hazard: a harness + * carrying its own copy of this ranking would certify its own copy, and would + * keep passing through exactly the correction it exists to catch. + * + * `InsertTarget` and `SlotSource` travel with them because the region types are + * written in terms of both: a consumer that can name a region but not what it + * accepts cannot ask the nesting rule about one. + */ +export { + axisOfRects, + collectRegions, + movingSubtree, + regionAt, + resolveDrop, + ROOT_REGION, + targetsInRegion, + type DropAxis, + type DropQuery, + type DropRefusal, + type DropRegion, + type DropResolution, + type DropTarget, + type RectSource, +} from "./drop-targets"; +export { + blockAllowedAt, + registrySlotSource, + type InsertTarget, + type SlotSource, +} from "./inserter"; + +/** + * @experimental When a drag is allowed to change the target it has committed + * to. + * + * Separate from the drop rules above because it decides something different: + * those say what the pointer is over NOW, this says when that answer may + * replace the one being drawn. Keeping them apart is what lets a region + * boundary be an ordinary candidate change rather than a case of its own. + */ +export { + nextTargetSwitchState, + NO_TARGET, + type PendingTarget, + type TargetId, + type TargetSwitchState, +} from "./target-switch"; diff --git a/packages/builder/src/insert-panel.tsx b/packages/builder/src/insert-panel.tsx index e1b68b4f45..8b0b7c478d 100644 --- a/packages/builder/src/insert-panel.tsx +++ b/packages/builder/src/insert-panel.tsx @@ -50,8 +50,8 @@ import { groupByCategory, insertionPointFor, nodeForEntry, + registrySlotSource, type InsertionPoint, - type SlotSource, type InsertEntry, } from "./inserter"; @@ -105,22 +105,6 @@ function placementLabel(point: InsertionPoint, label?: string): string { : "Adds at the end of the page"; } -/** - * The registry as a slot source. - * - * Reads the DEFINITION's declared slots rather than the node's, because a - * container inserted from the palette has no `slots` key until something is put - * in it — which is precisely the container that needs filling. - */ -function registrySlotSource(): SlotSource { - return { - slotsOf: type => { - const declared = getBlock(type)?.slots; - return declared === undefined ? undefined : Object.keys(declared); - }, - }; -} - export function InsertPanel({ editor, definitions, diff --git a/packages/builder/src/inserter.ts b/packages/builder/src/inserter.ts index 1abd5106cc..5dfac9ed10 100644 --- a/packages/builder/src/inserter.ts +++ b/packages/builder/src/inserter.ts @@ -29,6 +29,7 @@ import { canNest, canNestInSlot, findNode, + getBlock, locateNode, makeNode, type AnyBlockDefinition, @@ -288,10 +289,36 @@ export function entryAllowedAt( target: InsertTarget, source: NestingSource ): NestingVerdict { - if (target.at === "root") return canBeRoot(entry.blockName, source); - const child = canNest(entry.blockName, target.parentType, source); + return blockAllowedAt(entry.blockName, target, source); +} + +/** + * Whether a block type may be placed at a target, by NAME. + * + * The same rule as {@link entryAllowedAt} and the same code, reached without a + * palette entry. A drag moves a node that already exists, so the only thing it + * has is a type name — and asking the question a second way is how the palette + * and the canvas come to disagree about where a block may go, with the palette + * offering what the drop refuses. + * + * Both halves are asked, because a placement needs both to agree and neither is + * derivable from the other: `parent` is the child saying where it makes sense, + * a slot's `allow` is the container saying what it holds. + * + * The child's half runs first so its reason survives when both would refuse. + * "This block belongs inside a Columns" tells an author where to go; "this + * region does not take that block" leaves them looking for a region, which is + * the less actionable of the two sentences. + */ +export function blockAllowedAt( + blockName: string, + target: InsertTarget, + source: NestingSource +): NestingVerdict { + if (target.at === "root") return canBeRoot(blockName, source); + const child = canNest(blockName, target.parentType, source); if (!child.allowed) return child; - return canNestInSlot(entry.blockName, target.parentType, target.slot, source); + return canNestInSlot(blockName, target.parentType, target.slot, source); } /** @@ -484,3 +511,24 @@ export function insertionPointFor( export function nodeForEntry(entry: InsertEntry): BlockNode { return makeNode(entry.blockName, entry.version, structuredClone(entry.props)); } + +/** + * The registry as a slot source. + * + * Reads the DEFINITION's declared slots rather than the node's, because a + * container inserted from the palette has no `slots` key until something is put + * in it — which is precisely the container that needs filling. + * + * Here rather than beside either caller. The palette asks it where an insert + * would land and the canvas asks it which regions a drag can aim at, and those + * two have to agree: a container the palette will fill but the drag cannot see + * is a block that behaves differently depending on how an author reached it. + */ +export function registrySlotSource(): SlotSource { + return { + slotsOf: type => { + const declared = getBlock(type)?.slots; + return declared === undefined ? undefined : Object.keys(declared); + }, + }; +} diff --git a/packages/builder/src/shell.ts b/packages/builder/src/shell.ts index 9b2ee91cbb..4011e3e95b 100644 --- a/packages/builder/src/shell.ts +++ b/packages/builder/src/shell.ts @@ -115,3 +115,25 @@ export type { BlockKeyboardActionsOptions } from "./keyboard-actions"; */ export { MAX_HISTORY, useEditorState } from "./editor-state"; export type { EditorState, UseEditorStateArgs } from "./editor-state"; + +/** + * Dragging blocks on the canvas, behind the same client banner: the hook holds + * a gesture and the indicator draws its answer. + * + * Published together because neither is useful alone — the hook's whole output + * is something to draw, and the indicator has nothing to draw without it. + * + * The RULES the drag obeys are not here. Which positions exist, which the + * nesting rule permits and which one a pointer means are decided in + * `drop-targets`, and when the answer may change is decided in `target-switch`; + * both are plain functions over numbers and ship from the root entry, where a + * caller can reach them without loading React. + */ +export { DropIndicator, useCanvasDrag } from "./canvas-drag"; +export type { + CanvasDrag, + CanvasDragHandlers, + CanvasDragState, + DropIndicatorProps, + UseCanvasDragOptions, +} from "./canvas-drag"; diff --git a/packages/builder/src/styles/builder-chrome.css b/packages/builder/src/styles/builder-chrome.css index 5735f8efbe..4f16e3c9ef 100644 --- a/packages/builder/src/styles/builder-chrome.css +++ b/packages/builder/src/styles/builder-chrome.css @@ -361,3 +361,70 @@ .nx-inspector__field[data-unsupported] { opacity: 0.7; } + +/* + * The canvas root is the positioning context for editor chrome drawn over the + * page. + * + * The drop indicator is placed in the canvas's own CONTENT coordinates — the + * same space its blocks were measured in — and an absolutely positioned child + * resolves against the nearest positioned ancestor's padding box, scroll + * included. So this one declaration is what makes a measured coordinate and a + * drawn one the same number, and without it the indicator resolves against + * whatever ancestor happens to be positioned and lands somewhere unrelated. + */ +.nx-canvas { + position: relative; + /* + * Fills the region rather than stopping at its content. + * + * Without this the canvas is exactly as tall as the blocks in it, so the area + * an author can aim at ENDS at the last block — and the position past it, the + * end of the page, has no pixels to point at. A pointer there is outside the + * canvas entirely, which correctly resolves to no drop target at all, so + * dragging a block to the end of a page is impossible however good the rules + * above it are. Measured on a three-block page: content 50px, region 700px. + * + * `min-height` rather than `height` so a page taller than the region still + * scrolls instead of being clipped to it. + */ + min-height: 100%; +} + +/* + * Where a dragged block will land. + * + * The same accent as the selection ring, because both answer "this is what the + * editor currently means" and two colours for one idea reads as two states. + * + * Sized on ONE axis only: the other comes from the target's own extent, set + * inline, because how far the line runs is a measurement rather than a style. + * A region stacking children downward gets a horizontal rule; a row gets a + * vertical one. + */ +.nx-drop-indicator { + position: absolute; + z-index: 2; + background: var(--nx-primary); + border-radius: 1px; + /* + * Never a drop target itself. The element sits under the pointer by + * construction — it marks where the pointer is aiming — so a hit-test that + * could land on it would resolve the drag to the indicator and lose the block + * beneath it. + */ + pointer-events: none; +} + +.nx-drop-indicator[data-axis="y"] { + height: 2px; + /* Centred on the line it names rather than hanging below it: the coordinate + * is a boundary between two blocks, and a 2px bar drawn from that coordinate + * downward sits inside the block after it. */ + transform: translateY(-1px); +} + +.nx-drop-indicator[data-axis="x"] { + width: 2px; + transform: translateX(-1px); +} diff --git a/packages/plugin-page-builder/src/admin/BlocksField.tsx b/packages/plugin-page-builder/src/admin/BlocksField.tsx index 6fd0b3320a..00a22d67eb 100644 --- a/packages/plugin-page-builder/src/admin/BlocksField.tsx +++ b/packages/plugin-page-builder/src/admin/BlocksField.tsx @@ -36,15 +36,19 @@ import { hasBlock, registerBlocks, + registryNestingSource, type BlockDocument, } from "@nextlyhq/blocks-engine"; import { CORE_CATEGORIES, coreBlocks } from "@nextlyhq/blocks-react/blocks"; +import { registrySlotSource } from "@nextlyhq/builder"; import { BlockKeyboardActions, BuilderShell, Canvas, + DropIndicator, InsertPanel, InspectorPanel, + useCanvasDrag, useEditorState, } from "@nextlyhq/builder/shell"; import { useSuppressAdminChrome } from "@nextlyhq/plugin-sdk/admin"; @@ -193,6 +197,19 @@ function BlocksEditor({ ); const editor = useEditorState({ initialDocument }); + /* + * Dragging blocks on the canvas. + * + * The registry answers both questions, and it is the SAME registry the + * inserter reads — so a container the palette will put a block into is a + * container a drag can aim at. Given separate sources the two would disagree, + * and a block would behave differently depending on how the author reached + * it. + */ + const slots = useMemo(registrySlotSource, []); + const nesting = useMemo(registryNestingSource, []); + const drag = useCanvasDrag({ editor, slots, nesting }); + /* * Writing back on the way out rather than on every keystroke. * @@ -260,6 +277,12 @@ function BlocksEditor({ siteStyles={siteSheet()} selectedId={editor.selectedId} onSelect={editor.select} + dragHandlers={drag.handlers} + // The indicator is the only chrome drawn over the page today. It goes + // through the canvas rather than beside it because it is positioned in + // the canvas's own content coordinates, which the canvas root is what + // establishes. + overlay={} />