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={} />