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..5d41de79c7 100644
--- a/apps/playground/src/plugins/plugin.code-block.tsx
+++ b/apps/playground/src/plugins/plugin.code-block.tsx
@@ -6,13 +6,19 @@ import {DragHandle} from './drag-handle'
const codeBlockContainer = defineContainer({
type: 'code-block',
arrayField: 'lines',
- render: ({attributes, children, readOnly, selected}) => (
+ render: ({attributes, children, readOnly, selected, spacer}) => (
- {children}
+ {/* 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}
+
),
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',
+ })
+ })
+ })
+})