Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/select-container-via-spacer.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions apps/playground/src/plugins/plugin.code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => (
<pre
{...attributes}
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"
>
<code className="block min-w-0 cursor-text">{children}</code>
{/* 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}
<code className="relative z-10 block min-w-0 cursor-text">
{children}
</code>
<DragHandle readOnly={readOnly} />
</pre>
),
Expand Down
95 changes: 95 additions & 0 deletions packages/editor/src/behaviors/behavior.core.block-objects.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<typeof getFocusBlock>[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,
}
4 changes: 4 additions & 0 deletions packages/editor/src/behaviors/behavior.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions packages/editor/src/editor/render.container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ export function RenderContainer(props: {
// would require threading the `_type` literal through dispatch.
const renderProps: ContainerRenderProps = {
attributes: props.attributes,
spacer: (
<span
// The void selection-spacer markers; the engine tells it apart
// from voids' and the body's spacers by its nearest `data-pt-block`
// being a container. Unlike a void's anchor-only spacer, a
// container has an editable body that competes for clicks, so this
// one fills the region it is placed in (`inset: 0`); the consumer
// layers the editable content above it.
data-pt-spacer=""
style={{
position: 'absolute',
inset: 0,
color: 'transparent',
outline: 'none',
}}
>
<span>
<span data-pt-marks="">
<span data-pt-zero-width="">{'\uFEFF'}</span>
</span>
</span>
</span>
),
children: props.children,
focused,
node: props.element as PortableTextObject,
Expand Down
38 changes: 38 additions & 0 deletions packages/editor/src/engine/dom/plugin/dom-editor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"]',
)
Expand Down
44 changes: 42 additions & 2 deletions packages/editor/src/engine/react/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions packages/editor/src/renderers/renderer.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export type ContainerNodeForType<TType extends string> = TType extends
*/
export type ContainerRenderProps = {
attributes: Record<string, unknown>
/**
* 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
Expand Down Expand Up @@ -352,6 +359,13 @@ export function defineContainer<const TType extends string>(config: {
arrayField: string
render?: (props: {
attributes: Record<string, unknown>
/**
* 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<TType>
Expand Down
Loading
Loading