From 30628055eeac635f26570abd4496266fb65b11f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Fri, 26 Jun 2026 09:11:11 +0200 Subject: [PATCH 1/2] feat(editor): select a container as a block-object via a spacer 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. --- .changeset/select-container-via-spacer.md | 12 ++ .../src/plugins/plugin.code-block.tsx | 3 +- .../behaviors/behavior.core.block-objects.ts | 95 +++++++++++++ .../editor/src/behaviors/behavior.core.ts | 4 + .../editor/src/editor/render.container.tsx | 23 ++++ .../src/engine/dom/plugin/dom-editor.ts | 38 ++++++ .../src/engine/react/components/editable.tsx | 44 +++++- .../editor/src/renderers/renderer.types.ts | 14 ++ .../editor/tests/container-spacer.test.tsx | 127 ++++++++++++++++++ 9 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 .changeset/select-container-via-spacer.md create mode 100644 packages/editor/tests/container-spacer.test.tsx diff --git a/.changeset/select-container-via-spacer.md b/.changeset/select-container-via-spacer.md new file mode 100644 index 0000000000..774969a3a2 --- /dev/null +++ b/.changeset/select-container-via-spacer.md @@ -0,0 +1,12 @@ +--- +'@portabletext/editor': minor +--- + +feat: select a container as a block-object via a spacer + +`defineContainer`'s `render` callback now receives a `spacer`. Render it +anywhere in the container's chrome to make the container selectable as a +block-object: a selection on the spacer selects the container itself, and +while it is selected Backspace/Delete remove the whole container and +ArrowUp/ArrowDown move the caret to the adjacent block. Containers whose +render omits `spacer` stay caret-only, exactly as before. diff --git a/apps/playground/src/plugins/plugin.code-block.tsx b/apps/playground/src/plugins/plugin.code-block.tsx index feca4316c4..e64a6d652b 100644 --- a/apps/playground/src/plugins/plugin.code-block.tsx +++ b/apps/playground/src/plugins/plugin.code-block.tsx @@ -6,12 +6,13 @@ import {DragHandle} from './drag-handle' const codeBlockContainer = defineContainer({ type: 'code-block', arrayField: 'lines', - render: ({attributes, children, readOnly, selected}) => ( + render: ({attributes, children, readOnly, selected, spacer}) => (
+      {spacer}
       {children}
       
     
diff --git a/packages/editor/src/behaviors/behavior.core.block-objects.ts b/packages/editor/src/behaviors/behavior.core.block-objects.ts index a31dedbac3..11e1f95bdd 100644 --- a/packages/editor/src/behaviors/behavior.core.block-objects.ts +++ b/packages/editor/src/behaviors/behavior.core.block-objects.ts @@ -1,6 +1,8 @@ import {isSpan} from '@portabletext/schema' import {defaultKeyboardShortcuts} from '../editor/default-keyboard-shortcuts' import {isTextBlockNode} from '../engine/node/is-text-block-node' +import {isEditableContainer} from '../schema/is-editable-container' +import {getFocusBlock} from '../selectors/selector.get-focus-block' import {getFocusBlockObject} from '../selectors/selector.get-focus-block-object' import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block' import {isSelectionCollapsed} from '../selectors/selector.is-selection-collapsed' @@ -323,12 +325,105 @@ const deletingEmptyTextBlockBeforeBlockObject = defineBehavior({ ], }) +/** + * A container selected as a block-object: a collapsed selection whose focus + * resolves to the container itself (held at the container path by + * `resolveSelectionPoint`), not to a leaf inside it. `getFocusBlock` returns + * the container only in that case; a caret inside resolves to the inner block. + */ +function getSelectedContainer(snapshot: Parameters[0]) { + if (!isSelectionCollapsed(snapshot)) { + return undefined + } + + const focusBlock = getFocusBlock(snapshot) + + if ( + !focusBlock || + !isEditableContainer(snapshot, focusBlock.node, focusBlock.path) + ) { + return undefined + } + + return focusBlock +} + +const deletingSelectedContainerBackward = defineBehavior({ + on: 'delete.backward', + guard: ({snapshot}) => { + const container = getSelectedContainer(snapshot) + return container ? {container} : false + }, + actions: [(_, {container}) => [raise({type: 'unset', at: container.path})]], +}) + +const deletingSelectedContainerForward = defineBehavior({ + on: 'delete.forward', + guard: ({snapshot}) => { + const container = getSelectedContainer(snapshot) + return container ? {container} : false + }, + actions: [(_, {container}) => [raise({type: 'unset', at: container.path})]], +}) + +const arrowDownOnSelectedContainer = defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event}) => { + if (!defaultKeyboardShortcuts.arrowDown.guard(event.originEvent)) { + return false + } + + const container = getSelectedContainer(snapshot) + + if (!container) { + return false + } + + const nextBlock = getSibling(snapshot, container.path, {direction: 'next'}) + + return nextBlock ? {nextBlock} : false + }, + actions: [ + (_, {nextBlock}) => [raise({type: 'select.block', at: nextBlock.path})], + ], +}) + +const arrowUpOnSelectedContainer = defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event}) => { + if (!defaultKeyboardShortcuts.arrowUp.guard(event.originEvent)) { + return false + } + + const container = getSelectedContainer(snapshot) + + if (!container) { + return false + } + + const previousBlock = getSibling(snapshot, container.path, { + direction: 'previous', + }) + + return previousBlock ? {previousBlock} : false + }, + actions: [ + (_, {previousBlock}) => [ + raise({type: 'select.block', at: previousBlock.path, select: 'end'}), + ], + ], +}) + export const coreBlockObjectBehaviors = { arrowDownOnLonelyBlockObject, arrowUpOnLonelyBlockObject, + arrowDownOnSelectedContainer, + arrowUpOnSelectedContainer, breakingBlockObject, clickingAboveLonelyBlockObject, clickingBelowLonelyBlockObject, deletingEmptyTextBlockAfterBlockObject, deletingEmptyTextBlockBeforeBlockObject, + deletingSelectedContainerBackward, + deletingSelectedContainerForward, } diff --git a/packages/editor/src/behaviors/behavior.core.ts b/packages/editor/src/behaviors/behavior.core.ts index 51a3aaa594..d037c8d1b3 100644 --- a/packages/editor/src/behaviors/behavior.core.ts +++ b/packages/editor/src/behaviors/behavior.core.ts @@ -20,6 +20,10 @@ const coreBehaviors = [ coreBlockObjectBehaviors.clickingBelowLonelyBlockObject, coreBlockObjectBehaviors.arrowDownOnLonelyBlockObject, coreBlockObjectBehaviors.arrowUpOnLonelyBlockObject, + coreBlockObjectBehaviors.arrowDownOnSelectedContainer, + coreBlockObjectBehaviors.arrowUpOnSelectedContainer, + coreBlockObjectBehaviors.deletingSelectedContainerBackward, + coreBlockObjectBehaviors.deletingSelectedContainerForward, coreContainerBehaviors.arrowDownOutOfContainer, coreContainerBehaviors.arrowUpOutOfContainer, coreContainerBehaviors.breakingOutOfContainer, diff --git a/packages/editor/src/editor/render.container.tsx b/packages/editor/src/editor/render.container.tsx index 89cfb8b5e0..291545ebae 100644 --- a/packages/editor/src/editor/render.container.tsx +++ b/packages/editor/src/editor/render.container.tsx @@ -33,6 +33,29 @@ export function RenderContainer(props: { // would require threading the `_type` literal through dispatch. const renderProps: ContainerRenderProps = { attributes: props.attributes, + spacer: ( + + + + {'\uFEFF'} + + + + ), children: props.children, focused, node: props.element as PortableTextObject, diff --git a/packages/editor/src/engine/dom/plugin/dom-editor.ts b/packages/editor/src/engine/dom/plugin/dom-editor.ts index b3740a700b..4ba0c3fef4 100644 --- a/packages/editor/src/engine/dom/plugin/dom-editor.ts +++ b/packages/editor/src/engine/dom/plugin/dom-editor.ts @@ -1,6 +1,7 @@ import {getDomNode} from '../../../dom-traversal/get-dom-node' import {getDomNodePath} from '../../../dom-traversal/get-dom-node-path' import {safeStringify} from '../../../internal-utils/safe-json' +import {isEditableContainer} from '../../../schema/is-editable-container' import {getAncestor} from '../../../traversal/get-ancestor' import {getNode} from '../../../traversal/get-node' import {hasNode} from '../../../traversal/has-node' @@ -387,6 +388,26 @@ export const DOMEditor: DOMEditorInterface = { return [el, 0] } + // A container selected as a block-object renders onto its own spacer. + // Of the spacers under `el` (the container's own, plus any from void + // children or nested containers in its body), pick the one whose + // nearest block is this container. + if ( + nodeEntry && + isEditableContainer(editor.snapshot, nodeEntry.node, point.path) + ) { + const spacers = el.querySelectorAll('[data-pt-spacer]') + for (const spacer of Array.from(spacers)) { + if (spacer.closest('[data-pt-path]') === el) { + const domText = spacer.querySelector('[data-pt-zero-width]') + ?.childNodes[0] + if (domText) { + return [domText, 0] + } + } + } + } + // If we're inside an object node, force the offset to 0, otherwise the zero // width spacing character will result in an incorrect offset of 1 const pointPath = editorPath(editor, point) @@ -496,6 +517,23 @@ export const DOMEditor: DOMEditorInterface = { throw new Error('Cannot resolve a DOM node: editor is not mounted') } + // A selection in a spacer whose nearest block is a container selects + // that container as a block-object: resolve to the container's path. + // Void spacers (nearest block `object`) fall through to the void + // handling below. + const spacer = parentNode.closest('[data-pt-spacer]') + if ( + spacer && + containsShadowAware(editorEl, spacer) && + spacer.closest('[data-pt-block]')?.getAttribute('data-pt-block') === + 'container' + ) { + const containerPath = getDomNodePath(spacer) + if (containerPath) { + return {path: containerPath, offset: 0} + } + } + const potentialVoidNode = parentNode.closest( '[data-pt-block="object"], [data-pt-inline="object"]', ) diff --git a/packages/editor/src/engine/react/components/editable.tsx b/packages/editor/src/engine/react/components/editable.tsx index 438dcb4e78..a537e8103d 100644 --- a/packages/editor/src/engine/react/components/editable.tsx +++ b/packages/editor/src/engine/react/components/editable.tsx @@ -18,6 +18,8 @@ import scrollIntoView from 'scroll-into-view-if-needed' import {getDomNode} from '../../../dom-traversal/get-dom-node' import {getDomNodePath} from '../../../dom-traversal/get-dom-node-path' import type {EditorActor} from '../../../editor/editor-machine' +import {applySelect} from '../../../internal-utils/apply-selection' +import {isEditableContainer} from '../../../schema/is-editable-container' import {getAncestor} from '../../../traversal/get-ancestor' import {getNode} from '../../../traversal/get-node' import {getParent} from '../../../traversal/get-parent' @@ -291,9 +293,26 @@ export const Editable = forwardRef( const {anchorNode, focusNode} = domSelection + // A container's spacer lives in non-editable chrome, so it is + // neither an editable target nor inside a void; accept it so a + // selection on it (selecting the container as a block-object) + // syncs, the same way a void's spacer does. + const anchorElement = + anchorNode instanceof Element + ? anchorNode + : (anchorNode?.parentElement ?? null) + const anchorSpacer = anchorElement?.closest('[data-pt-spacer]') + const anchorInContainerSpacer = + anchorSpacer != null && + anchorSpacer + .closest('[data-pt-block]') + ?.getAttribute('data-pt-block') === 'container' && + DOMEditor.hasTarget(editor, anchorNode) + const anchorNodeSelectable = DOMEditor.hasEditableTarget(editor, anchorNode) || - DOMEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) + DOMEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) || + anchorInContainerSpacer const focusNodeInEditor = DOMEditor.hasTarget(editor, focusNode) @@ -304,7 +323,28 @@ export const Editable = forwardRef( }) if (range) { - if ( + // A selection on a container's spacer resolves (via + // `toSelectionPoint`) to a collapsed point on the container + // itself, selecting it as a block-object. It is already + // resolved, so commit it as-is; routing it through + // `editor.select` would re-resolve and drill into the + // container's first leaf. + const containerEntry = + pathEquals(range.anchor.path, range.focus.path) && + range.anchor.offset === range.focus.offset + ? getNode(editor.snapshot, range.focus.path) + : undefined + const selectingContainer = + containerEntry !== undefined && + isEditableContainer( + editor.snapshot, + containerEntry.node, + range.focus.path, + ) + + if (selectingContainer) { + applySelect(editor, range) + } else if ( !editor.composing && !androidInputManager?.hasPendingChanges() && !androidInputManager?.isFlushing() diff --git a/packages/editor/src/renderers/renderer.types.ts b/packages/editor/src/renderers/renderer.types.ts index 9de37f5c6c..aa5ec5a13e 100644 --- a/packages/editor/src/renderers/renderer.types.ts +++ b/packages/editor/src/renderers/renderer.types.ts @@ -33,6 +33,13 @@ export type ContainerNodeForType = TType extends */ export type ContainerRenderProps = { attributes: Record + /** + * A hidden, selectable proxy the engine always provides. Render it + * anywhere in the container's chrome to make the container selectable as a + * block-object: a selection on the spacer selects the container itself. + * Don't render it and the container stays caret-only, as before. + */ + spacer?: ReactElement children: ReactElement focused: boolean node: PortableTextObject @@ -352,6 +359,13 @@ export function defineContainer(config: { arrayField: string render?: (props: { attributes: Record + /** + * A hidden, selectable proxy the engine always provides. Render it in + * the container's chrome to make the container selectable as a + * block-object: a selection on the spacer selects the container itself. + * Don't render it and the container stays caret-only. + */ + spacer?: ReactElement children: ReactElement focused: boolean node: ContainerNodeForType diff --git a/packages/editor/tests/container-spacer.test.tsx b/packages/editor/tests/container-spacer.test.tsx new file mode 100644 index 0000000000..d07f471b4b --- /dev/null +++ b/packages/editor/tests/container-spacer.test.tsx @@ -0,0 +1,127 @@ +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {defineContainer, defineSchema} from '../src' +import {NodePlugin} from '../src/plugins' +import {createTestEditor} from '../src/test/vitest' + +const schema = defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [{name: 'content', type: 'array', of: [{type: 'block'}]}], + }, + ], +}) +const blk = (k: string, t: string) => ({ + _type: 'block', + _key: k, + children: [{_type: 'span', _key: `${k}-s`, text: t, marks: []}], + markDefs: [], + style: 'normal', +}) + +const container = defineContainer({ + type: 'callout', + arrayField: 'content', + render: ({attributes, spacer, children}) => ( +
+ {spacer} +
{children}
+
+ ), +}) + +const calloutPath = [{_key: 'callout'}] +const spanPath = [ + {_key: 'callout'}, + 'content', + {_key: 'inner'}, + 'children', + {_key: 'inner-s'}, +] + +function setup() { + return createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: schema, + initialValue: [ + blk('before', 'before'), + {_type: 'callout', _key: 'callout', content: [blk('inner', 'inside')]}, + blk('after', 'after'), + ], + children: , + }) +} + +function selectSpacer(root: HTMLElement) { + const zw = root.querySelector('[data-pt-spacer] [data-pt-zero-width]') + const textNode = zw?.firstChild + if (!textNode) { + throw new Error('no spacer zero-width found') + } + const sel = window.getSelection()! + const range = document.createRange() + range.setStart(textNode, 0) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) +} + +describe('container spacer selection', () => { + test('selecting the spacer holds the container path', async () => { + const {editor, locator} = await setup() + await userEvent.click(locator.getByText('inside')) // focus the editor + selectSpacer(locator.element() as HTMLElement) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection?.focus.path).toEqual( + calloutPath, + ) + }) + }) + + test('clicking the body still carets', async () => { + const {editor, locator} = await setup() + await userEvent.click(locator.getByText('inside')) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection?.focus.path).toEqual( + spanPath, + ) + }) + }) + + test('Backspace with the container selected deletes it', async () => { + const {editor, locator} = await setup() + await userEvent.click(locator.getByText('before')) + selectSpacer(locator.element() as HTMLElement) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection?.focus.path).toEqual( + calloutPath, + ) + }) + editor.send({type: 'delete.backward', unit: 'character'}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value.map((b) => b._type)).toEqual([ + 'block', + 'block', + ]) + }) + }) + + test('ArrowDown from the selected container moves to the next block', async () => { + const {editor, locator} = await setup() + await userEvent.click(locator.getByText('before')) + selectSpacer(locator.element() as HTMLElement) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection?.focus.path).toEqual( + calloutPath, + ) + }) + await userEvent.keyboard('{ArrowDown}') + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection?.focus.path?.[0]).toEqual({ + _key: 'after', + }) + }) + }) +}) From 7898bb978e4678408c0d2e708b1a4c55f3f70cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Fri, 26 Jun 2026 09:11:12 +0200 Subject: [PATCH 2/2] chore(playground): make the code-block selectable via its spacer Render the `spacer` the engine hands the container's `render` as a child of the code-block's frame and layer the editable `` 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. --- apps/playground/src/plugins/plugin.code-block.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/playground/src/plugins/plugin.code-block.tsx b/apps/playground/src/plugins/plugin.code-block.tsx index e64a6d652b..5d41de79c7 100644 --- a/apps/playground/src/plugins/plugin.code-block.tsx +++ b/apps/playground/src/plugins/plugin.code-block.tsx @@ -12,8 +12,13 @@ const codeBlockContainer = defineContainer({ data-selected={selected ? '' : undefined} className="group relative my-3 overflow-x-auto rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-slate-700 text-sm leading-relaxed transition-shadow data-[selected]:border-slate-400 data-[selected]:shadow-md dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-200 dark:data-[selected]:border-slate-500" > + {/* The spacer fills the frame behind the code; clicking the + frame/padding selects the code-block. The editable code is + layered on top so clicking it still places a caret. */} {spacer} - {children} + + {children} + ),