`) so it stays valid markup
+ * inside a `
` text block.
+ */
+export function BlockDropIndicator({path}: {path: Path}): JSX.Element | null {
+ const position = useDropPosition(path)
+
+ if (position === undefined) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/playground/src/plugins/container-text-block.tsx b/apps/playground/src/plugins/container-text-block.tsx
new file mode 100644
index 0000000000..1e46854783
--- /dev/null
+++ b/apps/playground/src/plugins/container-text-block.tsx
@@ -0,0 +1,54 @@
+import type {TextBlockRenderProps} from '@portabletext/editor'
+import {useListIndex} from '@portabletext/plugin-list-index'
+import {createElement, type JSX} from 'react'
+import {BlockDropIndicator} from './block-drop-indicator'
+
+type StyleConfig = {tag: keyof JSX.IntrinsicElements; className: string}
+
+/**
+ * The text-block render shared by every container (callout, fact-box, cell).
+ * Containers render through the `defineTextBlock` pipeline, which emits only
+ * `data-pt-*` attributes and no drop-indicator chrome, so each container would
+ * otherwise repeat the same three concerns: re-emit the `data-list-*`
+ * attributes the playground's counter CSS keys off (with the index from
+ * `useListIndex`, the one value `node` cannot supply), pick the element for the
+ * block style, and draw the {@link BlockDropIndicator}. They differ only in
+ * which styles they allow, so that is the single parameter.
+ *
+ * The block element is `relative` so the absolutely-positioned indicator
+ * aligns to it.
+ */
+export function ContainerTextBlock(props: {
+ attributes: TextBlockRenderProps['attributes']
+ children: TextBlockRenderProps['children']
+ node: TextBlockRenderProps['node']
+ path: TextBlockRenderProps['path']
+ styles: {normal: StyleConfig} & Record
+}): JSX.Element {
+ const listIndex = useListIndex(props.path)
+
+ if (props.node.listItem !== undefined) {
+ return (
+
+ {props.children}
+
+
+ )
+ }
+
+ const style =
+ props.styles[props.node.style ?? 'normal'] ?? props.styles.normal
+
+ return createElement(
+ style.tag,
+ {...props.attributes, className: `relative ${style.className}`.trim()},
+ props.children,
+ ,
+ )
+}
diff --git a/apps/playground/src/plugins/list-item-block.tsx b/apps/playground/src/plugins/list-item-block.tsx
deleted file mode 100644
index 64bd8dc4a4..0000000000
--- a/apps/playground/src/plugins/list-item-block.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type {TextBlockRenderProps} from '@portabletext/editor'
-import {useListIndex} from '@portabletext/plugin-list-index'
-import type {JSX} from 'react'
-
-/**
- * Renders a list-item text block inside a container. Containers go through the
- * `defineTextBlock` pipeline, which emits only `data-pt-*` attributes, so the
- * playground's pure-CSS list counters in `editor.css` (keyed off
- * `data-list-item`/`data-level`/`data-list-index`) never fire. This re-emits
- * those attributes, with the index supplied by `useListIndex` since it is the
- * one value the consumer cannot derive from `node` alone.
- */
-export function ListItemBlock(props: {
- attributes: TextBlockRenderProps['attributes']
- node: TextBlockRenderProps['node']
- path: TextBlockRenderProps['path']
- children: TextBlockRenderProps['children']
-}): JSX.Element {
- const listIndex = useListIndex(props.path)
- return (
-
- {props.children}
-
- )
-}
diff --git a/apps/playground/src/plugins/plugin.callout.tsx b/apps/playground/src/plugins/plugin.callout.tsx
index 4cc883e603..824ad62639 100644
--- a/apps/playground/src/plugins/plugin.callout.tsx
+++ b/apps/playground/src/plugins/plugin.callout.tsx
@@ -12,8 +12,21 @@ import {
TriangleAlertIcon,
} from 'lucide-react'
import type {JSX} from 'react'
+import {BlockDropIndicator} from './block-drop-indicator'
+import {ContainerTextBlock} from './container-text-block'
import {DragHandle} from './drag-handle'
-import {ListItemBlock} from './list-item-block'
+
+const calloutTextStyles = {
+ normal: {tag: 'p', className: 'my-1'},
+ h1: {tag: 'h1', className: 'my-2 font-bold text-2xl'},
+ h2: {tag: 'h2', className: 'my-2 font-bold text-xl'},
+ h3: {tag: 'h3', className: 'my-2 font-bold text-lg'},
+ blockquote: {
+ tag: 'blockquote',
+ className:
+ 'my-1 border-l-2 border-amber-600 pl-2 italic dark:border-amber-300',
+ },
+} as const
const toneClassName: Record = {
note: 'border-sky-400 bg-sky-50 text-sky-900 dark:bg-sky-950/40 dark:text-sky-100',
@@ -76,7 +89,7 @@ const calloutImageLeaf = defineBlockObject({
export const calloutContainer = defineContainer({
type: 'callout',
arrayField: 'content',
- render: ({attributes, children, node, readOnly, selected}) => {
+ render: ({attributes, children, node, path, readOnly, selected}) => {
const tone = typeof node.tone === 'string' ? node.tone : 'note'
const toneStyle = toneClassName[tone] ?? defaultToneClassName
return (
@@ -93,60 +106,22 @@ export const calloutContainer = defineContainer({
{children}
+
)
},
of: [
defineTextBlock({
type: 'block',
- render: ({attributes, children, node, path}) => {
- if (node.listItem !== undefined) {
- return (
-
- )
- }
-
- switch (node.style) {
- case 'h1':
- return (
-
- {children}
-
- )
- case 'h2':
- return (
-
- {children}
-
- )
- case 'h3':
- return (
-
- {children}
-
- )
- case 'blockquote':
- return (
-
- {children}
-
- )
- default:
- return (
-
- {children}
-
- )
- }
- },
+ render: ({attributes, children, node, path}) => (
+
+ ),
}),
calloutImageLeaf,
],
diff --git a/apps/playground/src/plugins/plugin.fact-box.tsx b/apps/playground/src/plugins/plugin.fact-box.tsx
index 745396a635..016124598f 100644
--- a/apps/playground/src/plugins/plugin.fact-box.tsx
+++ b/apps/playground/src/plugins/plugin.fact-box.tsx
@@ -1,13 +1,29 @@
import {defineContainer, defineTextBlock} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
import type {JSX} from 'react'
+import {BlockDropIndicator} from './block-drop-indicator'
+import {ContainerTextBlock} from './container-text-block'
import {DragHandle} from './drag-handle'
-import {ListItemBlock} from './list-item-block'
+
+const factBoxTextStyles = {
+ normal: {tag: 'p', className: 'my-1'},
+ h1: {tag: 'h1', className: 'my-2 font-bold text-2xl'},
+ h2: {tag: 'h2', className: 'my-2 font-bold text-xl'},
+ h3: {tag: 'h3', className: 'my-2 font-bold text-lg'},
+ h4: {tag: 'h4', className: 'my-2 font-bold'},
+ h5: {tag: 'h5', className: 'my-2 font-semibold'},
+ h6: {tag: 'h6', className: 'my-2 font-semibold'},
+ blockquote: {
+ tag: 'blockquote',
+ className:
+ 'my-1 border-l-2 border-stone-500 pl-2 italic dark:border-stone-300',
+ },
+} as const
const factBoxContainer = defineContainer({
type: 'fact-box',
arrayField: 'content',
- render: ({attributes, children, readOnly, selected}) => (
+ render: ({attributes, children, path, readOnly, selected}) => (
),
of: [
defineTextBlock({
type: 'block',
- render: ({attributes, children, node, path}) => {
- if (node.listItem !== undefined) {
- return (
-
- )
- }
-
- switch (node.style) {
- case 'h1':
- return (
-
- {children}
-
- )
- case 'h2':
- return (
-
- {children}
-
- )
- case 'h3':
- return (
-
- {children}
-
- )
- case 'h4':
- return (
-
- {children}
-
- )
- case 'h5':
- return (
-
- {children}
-
- )
- case 'h6':
- return (
-
- {children}
-
- )
- case 'blockquote':
- return (
-
- {children}
-
- )
- default:
- return (
-
- {children}
-
- )
- }
- },
+ render: ({attributes, children, node, path}) => (
+
+ ),
}),
],
})
diff --git a/apps/playground/src/plugins/plugin.table.tsx b/apps/playground/src/plugins/plugin.table.tsx
index 3282c4b64b..e88464c4a6 100644
--- a/apps/playground/src/plugins/plugin.table.tsx
+++ b/apps/playground/src/plugins/plugin.table.tsx
@@ -1,15 +1,18 @@
import {defineContainer, defineTextBlock} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
import type {JSX} from 'react'
+import {BlockDropIndicator} from './block-drop-indicator'
+import {ContainerTextBlock} from './container-text-block'
import {DragHandle} from './drag-handle'
-import {ListItemBlock} from './list-item-block'
import {calloutContainer} from './plugin.callout'
import {cellImageLeaf} from './plugin.image'
+const cellTextStyles = {normal: {tag: 'div', className: ''}} as const
+
const tableContainer = defineContainer({
type: 'table',
arrayField: 'rows',
- render: ({attributes, children, node, readOnly, selected}) => (
+ render: ({attributes, children, node, path, readOnly, selected}) => (
{children}
+
),
of: [
@@ -45,17 +49,15 @@ const tableContainer = defineContainer({
of: [
defineTextBlock({
type: 'block',
- render: ({attributes, children, node, path}) =>
- node.listItem !== undefined ? (
-
- ) : (
- {children}
- ),
+ render: ({attributes, children, node, path}) => (
+
+ ),
}),
cellImageLeaf,
calloutContainer,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c4d8d05bc4..9ece8097a5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -298,6 +298,9 @@ importers:
'@portabletext/patches':
specifier: workspace:*
version: link:../../packages/patches
+ '@portabletext/plugin-dnd':
+ specifier: workspace:*
+ version: link:../../packages/plugin-dnd
'@portabletext/plugin-emoji-picker':
specifier: workspace:*
version: link:../../packages/plugin-emoji-picker
@@ -16679,7 +16682,7 @@ snapshots:
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
- vitest: 4.1.9(@types/node@24.12.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@27.2.0)(vite@7.3.5(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
+ vitest: 4.1.9(@types/node@20.19.25)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@27.2.0)(vite@7.3.5(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)