Skip to content

feat(editor): select a container as a block-object via a spacer#2863

Draft
christianhg wants to merge 2 commits into
mainfrom
feat/selectable-container
Draft

feat(editor): select a container as a block-object via a spacer#2863
christianhg wants to merge 2 commits into
mainfrom
feat/selectable-container

Conversation

@christianhg

@christianhg christianhg commented Jun 26, 2026

Copy link
Copy Markdown
Member

A container (defineContainer, e.g. a code-block, callout, or table) can't be selected, deleted, or dragged as a unit. Clicking its chrome places a caret inside the content; there is no way to select the container itself or Backspace it. Void blocks get this for free, and the reason is narrow: a void is childless, so the hidden selection spacer the engine renders inside it is its one selectable position, and a click anywhere on the void's non-editable body routes to it. A container has an editable body, so a point on it drills to the first leaf, the editable body competes for every click, and the block-object keyboard pipelines gate on isLeafObject, which excludes editable containers.

This gives a container the same spacer, adapted for an editable body.

Userland

defineContainer's render now receives a spacer. Render it in the container's chrome and layer the editable content above it; clicking the chrome then selects the container, clicking the content places a caret. Omit the spacer and the container stays caret-only.

defineContainer({
  type: 'code-block',
  arrayField: 'lines',
  render: ({attributes, spacer, children}) => (
    <pre {...attributes} className="relative">
      {spacer}
      <code className="relative z-10">{children}</code>
    </pre>
  ),
})

The second commit wires exactly this into the playground's code-block.

Design notes

The spacer's presence is the only signal. Nothing registers a container as "selectable"; rendering the spacer is the opt-in, detected at runtime, the same way voids work. There is no selectable flag to keep in sync.

The spacer fills its region rather than being a zero-height anchor. A void's spacer can be height:0 because the void's whole body is non-editable, so the spacer is the only thing a click can land on. A container's body is editable and competes for clicks, so the container spacer is position:absolute; inset:0: it occupies the chrome, and the consumer layers the editable content above it (z-10). A click on the chrome lands on the spacer, a click on the content lands on the content.

The DOM/model mapping mirrors voids and reuses the void spacer markers (data-pt-spacer / data-pt-marks / data-pt-zero-width); the engine tells a container's spacer apart from a void's, or from the spacers/zero-widths throughout the container's editable body, by its nearest data-pt-block being a container. toSelectionPoint resolves a point in such a spacer to {path: container, offset: 0} (void spacers fall through to the existing void handling). toDOMPoint renders a container selection back onto that container's own spacer, scoped past nested containers and void children. onDOMSelectionChange accepts the spacer as a selectable anchor (it may sit in non-editable chrome, so it is neither an editable target nor inside a void) and commits the already-resolved container point as-is.

That "as-is" is the crux, and it's why there is no flag. resolveSelection is left completely untouched: it still drills a container point to the first leaf, which is correct everywhere content gets positioned, insert at a container, a drop target, the selection that resolves after a delete. Removing that drill wholesale breaks exactly three things and they are all content-positioning (proven by event.insert.block, event.drag, and the behavior-api delete-then-insert scenario). So instead of teaching resolveSelection an exception, the spacer selection simply never goes through it: onDOMSelectionChange commits it directly. The block-object intent is born and consumed at the one place that knows, the spacer, and never travels as a flag through the event/operation layers.

While a container is selected, four behaviors act on it: delete backward/forward unset the container, ArrowUp/ArrowDown move the caret to the adjacent block. unset is used rather than delete.block so the whole container is removed without re-resolving through the drill.

container-spacer.test.tsx pins the four observable behaviors (spacer selects the container, body still carets, Backspace deletes it, ArrowDown navigates) with real DOM selection and keyboard. Full unit suite (809) and browser suite (1680) pass with no regressions.

Not covered

How a spacer-less container is treated is unchanged (caret-only). The chrome layout (which regions select vs caret, the stacking) is the consumer's call; the playground demonstrates one. Esc-to-select is separate.


Note

Medium Risk
Touches selection sync, DOM mapping, and keyboard behaviors in the editor core; opt-in via spacer limits blast radius, but incorrect handling could affect caret placement or delete/navigation around containers.

Overview
defineContainer render callbacks now receive an optional spacer element. Placing it in the container chrome (with editable children layered above) lets users select the whole container like a block object; omitting spacer keeps today’s caret-only behavior.

The engine wires DOM ↔ model for container spacers: toSelectionPoint / toDOMPoint, onDOMSelectionChange treats container spacers as selectable anchors, and commits container selections via applySelect so resolveSelection does not drill into the first inner leaf. New core behaviors unset the container on Backspace/Delete and move selection with ArrowUp/ArrowDown when the container path is selected.

The playground code-block plugin demonstrates spacer + z-10 stacking; container-spacer.test.tsx covers selection, caret-in-body, delete, and navigation.

Reviewed by Cursor Bugbot for commit 7898bb9. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7898bb9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Jun 26, 2026 7:19am
portable-text-example-basic Ready Ready Preview, Comment Jun 26, 2026 7:19am
portable-text-playground Ready Ready Preview, Comment Jun 26, 2026 7:19am

Request Review

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (1fbedd4b)

@portabletext/editor

Metric Value vs main (1fbedd4)
Internal (raw) 795.9 KB +4.4 KB, +0.6%
Internal (gzip) 151.6 KB +281 B, +0.2%
Bundled (raw) 1.41 MB +4.4 KB, +0.3%
Bundled (gzip) 315.9 KB +646 B, +0.2%
Import time 96ms +2ms, +2.0%

@portabletext/editor/behaviors

Metric Value vs main (1fbedd4)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms +0ms, +3.0%

@portabletext/editor/plugins

Metric Value vs main (1fbedd4)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +0.6%

@portabletext/editor/selectors

Metric Value vs main (1fbedd4)
Internal (raw) 79.3 KB -
Internal (gzip) 14.5 KB +24 B, +0.2%
Bundled (raw) 74.7 KB -
Bundled (gzip) 13.4 KB +11 B, +0.1%
Import time 8ms +0ms, +0.3%

@portabletext/editor/traversal

Metric Value vs main (1fbedd4)
Internal (raw) 25.1 KB -
Internal (gzip) 5.0 KB +19 B, +0.4%
Bundled (raw) 25.0 KB -
Bundled (gzip) 5.0 KB +7 B, +0.1%
Import time 6ms -0ms, -0.8%

@portabletext/editor/utils

Metric Value vs main (1fbedd4)
Internal (raw) 29.3 KB -
Internal (gzip) 6.1 KB -
Bundled (raw) 26.8 KB -
Bundled (gzip) 5.8 KB -
Import time 6ms -0ms, -0.4%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @portabletext/markdown

Compared against main (1fbedd4b)

Metric Value vs main (1fbedd4)
Internal (raw) 53.0 KB -
Internal (gzip) 9.6 KB -
Bundled (raw) 348.2 KB -
Bundled (gzip) 96.1 KB -
Import time 39ms +1ms, +2.9%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

Containers (`defineContainer`) could not be selected as a unit: a
collapsed selection resolving to a container drilled to its first leaf,
and the block-object keyboard pipelines gate on `isLeafObject`, which
excludes editable containers. Void blocks avoid this only because they
are childless: their hidden selection spacer is their one selectable
position, so a selection on it maps cleanly to the void's path.

Give containers a spacer modeled on the void one. `RenderContainer` mints
a `spacer` on the render props, the same `data-pt-marks` /
`data-pt-zero-width` markers a void uses, under a `data-pt-container-spacer`
wrapper. The consumer renders it in the container's chrome to opt in; its
presence is the only signal, nothing registers a container as
"selectable". Unlike a void's anchor-only spacer, the container spacer
fills the region it is placed in (`position: absolute; inset: 0`), because
a container has an editable body that competes for clicks: the consumer
layers the editable content above the spacer, so a click on the chrome
lands on the spacer and a click on the body places a caret.

The DOM<->model mapping mirrors voids. `toSelectionPoint` resolves a point
inside a `data-pt-container-spacer` to `{path: container, offset: 0}` (a
container point is only ever produced from a spacer). `toDOMPoint` renders
a container selection back onto that container's own spacer (scoped past
nested containers). `onDOMSelectionChange` accepts the spacer as a
selectable anchor and commits the already-resolved container point as-is;
routing it through `editor.select` would re-resolve and drill into the
first leaf.

`resolveSelection` is left untouched: it still drills a container point,
which is correct when resolving where to place content (insert `at`, drop
target, post-edit selection). Only the spacer selection bypasses the
drill, by never going through it. There is no flag and no new operation
plumbing.

While a container is selected, four behaviors act on it: delete
backward/forward `unset` the container, ArrowUp/ArrowDown move to the
adjacent block.
Render the `spacer` the engine hands the container's `render` as a child
of the code-block's frame and layer the editable `<code>` above it
(`z-10`). Clicking the frame/padding then selects the code-block as a
block-object, while clicking the code still places a caret. The drag
handle is left alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant