From 01cfc9879eadc772ca6fa2caa8c02682354256e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 24 Aug 2026 17:16:29 +0200 Subject: [PATCH] docs: add a drag-and-drop concepts page The dnd model had no home: the plugin README carries the recipe, and Rendering and Containers carry fragments. The page states the ownership story, the editor owns event translation, move semantics, destination fitting, self-drop cancellation, and the drag ghost; registrations own draggable markup; `@portabletext/plugin-dnd` owns optional indicator presentation, and unpacks it: the seven `drag.*` events and their payloads, split versus snap versus no-op with the mixed-fit outcome stated, internal versus external drags (dragging out writes the same formats copying does), draggable markup for block objects and text-block drag handles, and a `dragover` observer that forwards the event it handles. Sidebar order 5, after Clipboard; one cross-link from Rendering's drop-indicators bullet. --- apps/docs/astro.config.mjs | 1 + .../src/content/docs/editor/concepts/dnd.mdx | 114 ++++++++++++++++++ .../content/docs/editor/concepts/index.mdx | 4 + .../docs/editor/concepts/rendering.mdx | 2 +- 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/editor/concepts/dnd.mdx diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index e18e962c6..b951e78fa 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -119,6 +119,7 @@ export default defineConfig({ {slug: 'editor/concepts/containers'}, {slug: 'editor/concepts/traversal'}, {slug: 'editor/concepts/clipboard'}, + {slug: 'editor/concepts/dnd'}, ], }, { diff --git a/apps/docs/src/content/docs/editor/concepts/dnd.mdx b/apps/docs/src/content/docs/editor/concepts/dnd.mdx new file mode 100644 index 000000000..4365cab39 --- /dev/null +++ b/apps/docs/src/content/docs/editor/concepts/dnd.mdx @@ -0,0 +1,114 @@ +--- +title: Drag and drop +description: How the editor turns native drag events into behavior events, what a drop does to the document, and who owns draggable markup and drop indicators. +sidebar: + order: 5 +--- + +Dragging runs through [behaviors](/editor/concepts/behavior/): the editor translates the browser's drag events into `drag.*` behavior events and owns what a drop does to the document. Two things are yours: the `draggable` markup that lets content be picked up, and any drop indicator you want to show, because the engine draws none. + +## Draggable blocks + +A block object's own non-editable chrome carries `draggable`, gated on `readOnly`: + +```tsx +defineBlockObject({ + type: 'image', + render: (props) => + typeof props.node.src === 'string' ? ( +
+
+ +
+ {props.children} +
+ ) : ( + props.renderDefault(props) + ), +}) +``` + +Picking that chrome up drags the whole block. A text block gets a drag handle the same way, `draggable` on non-editable chrome inside its render, and when the user's selection spans several blocks and covers the handle, the drag carries them all. + +See [Containers](/editor/concepts/containers/) for the full `draggable={!readOnly}` contract, including the same wrapper on a container's own chrome, and [Rendering](/editor/concepts/rendering/#markup-ownership) for who owns which markup in general. + +## The `drag.*` events + +Seven events carry the native drag lifecycle into behaviors. Behaviors read three payload pieces off them, `originEvent`, `dragOrigin`, and `position`: + +- `originEvent` is a reduced object, not the browser `DragEvent`: `{clientX, clientY, dataTransfer}` on `dragstart`, `{dataTransfer}` on the rest. `dataTransfer` holds the data being dragged; the rest of the browser event is not exposed. +- `dragOrigin` is the selection captured at `dragstart`. It's present only on `dragover` and `drop`, and only for a drag that started in this editor; its presence is what makes a drag internal (see [Internal and external drags](#internal-and-external-drags)). +- `position` is the pointer's location translated into model terms. A full `position` carries four fields: + - `selection`, the model selection at the pointer. + - `block`, which side of the hovered block the pointer resolves to, `'start'` or `'end'`. + - `isEditor`, whether the pointer is over the editor's own root rather than a block inside it. + - `isContainer`, whether the pointer is over a [container](/editor/concepts/containers/)'s own chrome (a code block, a table cell) rather than a block inside it. + +| Event | `dragOrigin` | `position` | +| ----------- | --------------------------- | ---------------- | +| `dragstart` | not present | `selection` only | +| `drag` | not present | not present | +| `dragend` | not present | not present | +| `dragenter` | not present | full | +| `dragover` | present on an internal drag | full | +| `drop` | present on an internal drag | full | +| `dragleave` | not present | not present | + +`dragstart` carries only `position.selection`: the drag hasn't moved yet, so the other fields aren't meaningful there. + +`drag.*` events are behavior events like any other: a behavior that handles one owns it, so an observer that wants the drag to keep working has to forward it. This observer tracks the position a drop would use, the first half of a drop-indicator implementation (the clearing half is described under [Drop indicators](#drop-indicators)): + +```tsx +defineBehavior({ + on: 'drag.dragover', + guard: ({event}) => ({ + selection: event.position.selection, + side: event.position.block, + }), + actions: [ + ({event}, dropPosition) => [ + effect(() => { + onDropPositionChange(dropPosition) + }), + forward(event), + ], + ], +}) +``` + +Forgetting `forward(event)` doesn't just mute other observers: the editor's own dragover handling never runs and the drag breaks. The same ownership is also the veto: a behavior that handles `drag.drop` without forwarding it cancels the drop. + +## Internal and external drags + +Whether `dragOrigin` is present on `dragover` and `drop` is the whole distinction between an internal move and an external drop. Present, and the drop is a move: the editor deletes the dragged content from where it was and inserts it at the drop position. Absent, and the drop is external, text from another application, a file, a link, and the dropped data goes through the same deserialization pipeline as a paste, picking the richest format the drag carries. See [Clipboard](/editor/concepts/clipboard/) for that pipeline in full. + +`dragOrigin` lives per editor, so a drop into a different editor instance is external to that instance: the second editor never sees the first one's drag origin. + +Dragging content out of the editor writes the same formats a copy does, through the same `serialize` pipeline; see [Clipboard](/editor/concepts/clipboard/) for the format list. The drag ghost the browser shows under the pointer is built from the dragged content's own DOM. + +## What a drop does + +A drop inserts at the drop position; mid-text, that's wherever the native drag caret points. What shape the insert takes is decided by the drag's captured selection, not by what was under the pointer when it started. + +A captured selection that stops short of a block edge, and any external drop, inserts the way a paste would, including splitting a paragraph mid-text when the drop lands there. + +A captured selection that covers entire blocks, start to end, resolves to one of three outcomes: + +- **Split.** The drop splits the hovered text block. Every condition must hold: + - the drop position is collapsed and lands strictly inside a non-empty text block's own characters, + - it is not on an inline object and not at the block's start or end, + - and at least one of the dragged blocks fits the destination schema. +- **Snap.** Otherwise, the dragged blocks snap before or after the hovered block, following `position.block`. +- **No-op.** When none of the dragged blocks fit the destination schema, the drop does nothing and the source is left where it is. + +What "fits" means is the schema at the destination: the top-level schema, or a [container](/editor/concepts/containers/)'s sub-schema when the drop lands inside one. When only some of the dragged blocks fit, the drop goes ahead with just those; a container the destination rejects still contributes the children the destination does accept, without the container around them. The source is still cleared of the entire original drag selection, so the blocks that didn't fit are discarded, not left behind at the origin. + +A drop landing back on its own drag origin is cancelled outright. Hovering the origin shows no native drag caret either: the editor claims `dragover` over it without forwarding. + +An entire-block move is a single history step: one `history.undo` restores the document to what it was before the drop, delete and insert together. + +## Drop indicators + +The engine draws no drop-indicator chrome. A drop indicator is pointer-driven UI, not document structure, so drawing it is left to you. + +[`@portabletext/plugin-dnd`](https://github.com/portabletext/editor/tree/main/packages/plugin-dnd) tracks the drop position from the `drag.*` events above: mount its `DndProvider` inside `EditorProvider` and read the hovered edge, `'start'` or `'end'`, with `useDropPosition` from a component your block render returns. It resolves the position at the hovered block's own nesting depth, including blocks inside a container, and hides the native drag caret while an edge indicator is active, so you don't get both at once. It clears the tracked position on every drag event other than `dragover`, and on any `dragover` with nothing to indicate: hovering the dragged block itself, or a mid-text position where the native drag caret takes over. An implementation of your own clears on the same triggers. The plugin's README carries the mounting recipe and a reference `DropIndicator` implementation. diff --git a/apps/docs/src/content/docs/editor/concepts/index.mdx b/apps/docs/src/content/docs/editor/concepts/index.mdx index ef294e6c5..11d6cbd65 100644 --- a/apps/docs/src/content/docs/editor/concepts/index.mdx +++ b/apps/docs/src/content/docs/editor/concepts/index.mdx @@ -28,3 +28,7 @@ Pure functions that answer questions about the editor's value tree, given a path ### [Clipboard](/editor/concepts/clipboard/) How copy and paste run through behaviors: the events in each direction and how several behaviors compose around the same clipboard. + +### [Drag and drop](/editor/concepts/dnd/) + +How the editor owns drag semantics while registrations own draggable markup and an optional plugin owns drop-indicator presentation. diff --git a/apps/docs/src/content/docs/editor/concepts/rendering.mdx b/apps/docs/src/content/docs/editor/concepts/rendering.mdx index 4c2dcb1da..78a4e623d 100644 --- a/apps/docs/src/content/docs/editor/concepts/rendering.mdx +++ b/apps/docs/src/content/docs/editor/concepts/rendering.mdx @@ -172,7 +172,7 @@ The `contentEditable={false}`/`draggable` wrapper is the block-object render con Two rendering concerns ship as plugins instead of registration props: - List numbering: your text-block render reads `node.listItem` and `node.level` for list markup, and [`@portabletext/plugin-list-index`](https://github.com/portabletext/editor/tree/main/packages/plugin-list-index) computes the 1-based list index at any path, correct across nesting, remote edits, and non-list blocks interrupting a list. -- Drop indicators: pointer-driven UI rendered by you. [`@portabletext/plugin-dnd`](https://github.com/portabletext/editor/tree/main/packages/plugin-dnd) tracks the drop position from the editor's public `drag.*` events. +- Drop indicators: pointer-driven UI rendered by you. [`@portabletext/plugin-dnd`](https://github.com/portabletext/editor/tree/main/packages/plugin-dnd) tracks the drop position from the editor's public `drag.*` events. See [Drag and drop](/editor/concepts/dnd/) for what those events carry. Both plugins follow the same pattern: a provider inside `EditorProvider`, and a hook read from a component your `render` returns, not inline in the `render` callback, since hooks can't run there: