feat(editor): select a container as a block-object via a spacer#2863
Draft
christianhg wants to merge 2 commits into
Draft
feat(editor): select a container as a block-object via a spacer#2863christianhg wants to merge 2 commits into
christianhg wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 7898bb9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📦 Bundle Stats —
|
| 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.
53fb7c1 to
8dcadd6
Compare
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.
8dcadd6 to
7898bb9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onisLeafObject, which excludes editable containers.This gives a container the same spacer, adapted for an editable body.
Userland
defineContainer'srendernow receives aspacer. 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.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
selectableflag to keep in sync.The spacer fills its region rather than being a zero-height anchor. A void's spacer can be
height:0because 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 isposition: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 nearestdata-pt-blockbeing acontainer.toSelectionPointresolves a point in such a spacer to{path: container, offset: 0}(void spacers fall through to the existing void handling).toDOMPointrenders a container selection back onto that container's own spacer, scoped past nested containers and void children.onDOMSelectionChangeaccepts 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.
resolveSelectionis left completely untouched: it still drills a container point to the first leaf, which is correct everywhere content gets positioned,insertata 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 byevent.insert.block,event.drag, and thebehavior-apidelete-then-insert scenario). So instead of teachingresolveSelectionan exception, the spacer selection simply never goes through it:onDOMSelectionChangecommits 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
unsetthe container, ArrowUp/ArrowDown move the caret to the adjacent block.unsetis used rather thandelete.blockso the whole container is removed without re-resolving through the drill.container-spacer.test.tsxpins 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
defineContainerrender callbacks now receive an optionalspacerelement. Placing it in the container chrome (with editablechildrenlayered above) lets users select the whole container like a block object; omittingspacerkeeps today’s caret-only behavior.The engine wires DOM ↔ model for container spacers:
toSelectionPoint/toDOMPoint,onDOMSelectionChangetreats container spacers as selectable anchors, and commits container selections viaapplySelectsoresolveSelectiondoes not drill into the first inner leaf. New core behaviorsunsetthe container on Backspace/Delete and move selection with ArrowUp/ArrowDown when the container path is selected.The playground code-block plugin demonstrates
spacer+z-10stacking;container-spacer.test.tsxcovers 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.