From fa918d428fd37924f99eaf1f00f21780f4f2d4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Wed, 24 Jun 2026 13:32:21 +0200 Subject: [PATCH] feat(playground): draw drop indicators for container blocks During a block drag the engine tracks a drop position and the legacy top-level renders draw a 1px line for it (`useElementDropPosition` -> `DropIndicator`). The new `defineX` pipeline renders no such chrome by design, so the playground's containers (callout, fact-box, table) and their nested text blocks gave no feedback about where a dragged block would land: dragging a callout over another container showed nothing. Wire `@portabletext/plugin-dnd`, whose `useDropPosition(path)` exposes the same position the engine computes, off the public `drag.*` events. Mount `DndProvider` inside `EditorProvider` and add a shared `BlockDropIndicator` that reads the position for its path and draws a 1px `currentColor` line at the block's top edge for a `'start'` drop, the bottom edge for `'end'`, mirroring the engine's `pt-drop-indicator`. It is a `` so it stays valid inside a `

` text block, and `position: absolute` so it never shifts the text; the block element it renders into is `relative`. To put the indicator on every block type without copying it, the per- container text-block render is consolidated: callout, fact-box, and cell each repeated the same list-item / style-switch / (now) indicator logic, differing only in which styles they allow. That collapses into one `ContainerTextBlock` parameterized by a style map, which subsumes the former `ListItemBlock` (the `data-list-*` re-emission moves into it unchanged). The three top-level container elements render the indicator directly. List rendering output is unchanged: the same `data-list-*` attributes on the same wrapper, the same elements and classNames per style. Row and cell ``/`` get no direct indicator yet (table-internal absolute positioning is fragile and neither is drag-initiated); their content blocks are covered. --- apps/playground/package.json | 1 + apps/playground/src/editor.tsx | 104 ++++++++++-------- .../src/plugins/block-drop-indicator.tsx | 39 +++++++ .../src/plugins/container-text-block.tsx | 54 +++++++++ .../src/plugins/list-item-block.tsx | 31 ------ .../playground/src/plugins/plugin.callout.tsx | 75 +++++-------- .../src/plugins/plugin.fact-box.tsx | 96 +++++----------- apps/playground/src/plugins/plugin.table.tsx | 28 ++--- pnpm-lock.yaml | 5 +- 9 files changed, 223 insertions(+), 210 deletions(-) create mode 100644 apps/playground/src/plugins/block-drop-indicator.tsx create mode 100644 apps/playground/src/plugins/container-text-block.tsx delete mode 100644 apps/playground/src/plugins/list-item-block.tsx diff --git a/apps/playground/package.json b/apps/playground/package.json index f0a606a63a..61ddbf64ac 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -21,6 +21,7 @@ "@portabletext/keyboard-shortcuts": "workspace:*", "@portabletext/markdown": "workspace:*", "@portabletext/patches": "workspace:*", + "@portabletext/plugin-dnd": "workspace:*", "@portabletext/plugin-emoji-picker": "workspace:*", "@portabletext/plugin-list-index": "workspace:*", "@portabletext/plugin-markdown-shortcuts": "workspace:*", diff --git a/apps/playground/src/editor.tsx b/apps/playground/src/editor.tsx index 11dce12efd..ff27446f2e 100644 --- a/apps/playground/src/editor.tsx +++ b/apps/playground/src/editor.tsx @@ -18,6 +18,7 @@ import { type RenderStyleFunction, } from '@portabletext/editor' import {portableTextToMarkdown} from '@portabletext/markdown' +import {DndProvider} from '@portabletext/plugin-dnd' import {ListIndexProvider} from '@portabletext/plugin-list-index' import {MarkdownShortcutsPlugin} from '@portabletext/plugin-markdown-shortcuts' import {OneLinePlugin} from '@portabletext/plugin-one-line' @@ -161,54 +162,63 @@ export function Editor(props: { ) : null} - - {featureFlags.codeBlockPlugin ? : null} - {featureFlags.calloutPlugin ? : null} - {featureFlags.factBoxPlugin ? : null} - {featureFlags.tablePlugin ? : null} - - {featureFlags.emojiPickerPlugin ? : null} - {featureFlags.mentionPickerPlugin ? ( - - ) : null} - {featureFlags.slashCommandPlugin ? ( - - ) : null} - {featureFlags.codeEditorPlugin ? : null} - {featureFlags.linkPlugin ? : null} - {featureFlags.imageDeserializerPlugin ? ( - - ) : null} - {featureFlags.htmlDeserializerPlugin ? ( - - ) : null} - {featureFlags.textFileDeserializerPlugin ? ( - - ) : null} - {featureFlags.markdownDeserializerPlugin ? ( - - ) : null} - {featureFlags.markdownPlugin ? ( - - ) : null} - {featureFlags.oneLinePlugin ? : null} - {featureFlags.typographyPlugin ? ( - - context.schema.decorators.flatMap((decorator) => - decorator.name === 'code' ? [] : [decorator.name], - ), - })} + + + {featureFlags.codeBlockPlugin ? : null} + {featureFlags.calloutPlugin ? : null} + {featureFlags.factBoxPlugin ? : null} + {featureFlags.tablePlugin ? : null} + + {featureFlags.emojiPickerPlugin ? ( + + ) : null} + {featureFlags.mentionPickerPlugin ? ( + + ) : null} + {featureFlags.slashCommandPlugin ? ( + + ) : null} + {featureFlags.codeEditorPlugin ? : null} + {featureFlags.linkPlugin ? : null} + {featureFlags.imageDeserializerPlugin ? ( + + ) : null} + {featureFlags.htmlDeserializerPlugin ? ( + + ) : null} + {featureFlags.textFileDeserializerPlugin ? ( + + ) : null} + {featureFlags.markdownDeserializerPlugin ? ( + + ) : null} + {featureFlags.markdownPlugin ? ( + + ) : null} + {featureFlags.oneLinePlugin ? : null} + {featureFlags.typographyPlugin ? ( + + context.schema.decorators.flatMap((decorator) => + decorator.name === 'code' ? [] : [decorator.name], + ), + })} + /> + ) : null} + - ) : null} - - - + + + diff --git a/apps/playground/src/plugins/block-drop-indicator.tsx b/apps/playground/src/plugins/block-drop-indicator.tsx new file mode 100644 index 0000000000..90fbe33c61 --- /dev/null +++ b/apps/playground/src/plugins/block-drop-indicator.tsx @@ -0,0 +1,39 @@ +import type {Path} from '@portabletext/editor' +import {useDropPosition} from '@portabletext/plugin-dnd' +import type {JSX} from 'react' + +/** + * The drop-indicator line for a new-pipeline block. The engine draws this + * chrome for legacy top-level blocks but deliberately not for `defineX` + * renders, so containers and their nested blocks draw it themselves from the + * position `@portabletext/plugin-dnd` tracks. Mirrors the engine's + * `pt-drop-indicator`: a 1px `currentColor` line at the block's top edge for a + * `'start'` drop, the bottom edge for `'end'`. + * + * `position: absolute` keeps it out of flow, so the block element it renders + * into must be `relative`. A `` (not a `

`) 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)