BlockEditorInput

Edits a block document of rich text and app-defined blocks in a tiptap editor, stored as tiptap JSON.

Installation

pnpm dlx shadcn@latest add @chelone/block-editor
Loading preview…

<BlockEditorInput> edits a document built out of blocks: rich text (headings, lists, quotes, code) plus discrete blocks — an image, an embed, a callout, or a block your app defines. Each block carries its own attributes and draws itself with its own components. It is an add-on: it is not in the @chelone/react barrel, and tiptap is a set of optional peer dependencies. See Installing the add-on.

When to use it

Use it where the value is a page a user assembles rather than a wall of text: a landing page with an image and a callout, a help article with a video, a release note with a chart. The user inserts a block, edits its attributes, and moves it.

Beside the other text inputs:

  • <RichTextInput> edits one flowing HTML string with a formatting toolbar. It has no blocks and no attributes per block, so pick it for a post body or a comment where bold, links and lists are enough.
  • MdxInput edits a markdown string, which stays plain text you can render anywhere.
  • TextInput is for a short single-line value.

To show a stored block document without editing it, use BlockDocField.

Example

src/resources/PostEdit.tsx
import { Edit } from "@chelone/react";
import { BlockEditorInput } from "@/components/block-editor/block-editor-input";
import { defaultBlocks } from "@/components/block-editor/default-blocks";
import { quoteBlock } from "@/blocks/quote-block";
 
export const PostEdit = () => (
  <Edit>
    <BlockEditorInput
      source="body"
      label="Body"
      blocks={[...defaultBlocks, quoteBlock]}
      placeholder="Type / for blocks"
      helperText="Type / on an empty line to insert a block."
    />
  </Edit>
);

With no blocks prop the editor offers the four default blocks: Callout, Toggle, Image and Embed. blocks replaces that list, so pass the defaults along when you add your own.

Value contract

The value is a block document: tiptap JSON, an object whose type is "doc".

the stored value
{
  "type": "doc",
  "content": [
    {
      "type": "callout",
      "attrs": { "variant": "info" },
      "content": [
        {
          "type": "paragraph",
          "content": [{ "type": "text", "text": "One block per line." }]
        }
      ]
    }
  ]
}
  • An empty document is stored as null, not as a doc holding one empty paragraph, so required() rejects a cleared editor.
  • A value that is not a doc object reads as an empty editor.
  • blocks is read once, when the editor mounts. A document holding a block the list no longer names shows as an unknown block.
  • A form reset or setValue replaces the document.
  • The document goes into the form as it is, so the resource's Zod schema validates it. z.unknown() accepts any document, and a schema that names the node types it expects rejects the rest.

Insert a block: the slash picker and the block bar

Type / on an empty line, or after a space, and a picker opens at the caret, listing the blocks that match what you typed under each block's group. Arrow keys move the highlight, Enter inserts, Escape closes. The same picker opens from the Add block button above the editor, where it also shows a search box.

Once a block is selected, or the caret sits inside one, a bar appears above the editor with the block's label and its actions: Configure, Duplicate, Move up, Move down and Delete. Configure opens a panel under the bar. Moving blocks covers the two ways to move one.

Moving blocks

A grip sits in the left gutter beside the top-level block the pointer is over, or the one the caret sits in. Drag it and a drop line appears at the nearest gap between blocks, including before the first block and after the last; dropping there moves the block. Both adapters draw the grip and move on the drop, on @dnd-kit/react and @dnd-kit/vue.

The bar's Move up and Move down are the keyboard path, and the grip is not in the tab order on purpose: it has no keyboard drag, so a screen reader is not told to press Space on it. Move up and Move down move a block among its siblings at any depth, are disabled at the ends of that run, and each move is one transaction, so one undo puts the block back where it was.

Custom blocks

A block is a Zod schema plus the components that draw it. defineBlock takes both, and types attrs in every component from the schema.

src/blocks/quote-block.tsx
import { z } from "zod";
import { defineBlock } from "@/components/block-editor/block-nodes";
 
export const quoteBlock = defineBlock({
  name: "quote",
  label: "Quote card",
  group: "Media",
  keywords: ["pull quote", "testimonial"],
  description: "Show a quote with its author",
  schema: z.object({
    text: z.string().default(""),
    author: z.string().default(""),
    featured: z.boolean().default(false),
  }),
  config: "auto",
  render: ({ attrs }) => (
    <figure>
      <blockquote>{attrs.text}</blockquote>
      <figcaption>{attrs.author}</figcaption>
    </figure>
  ),
});

In Vue render is a component, and h is enough for a small one:

src/blocks/quote-block.ts
import { z } from "zod";
import { h } from "vue";
import { defineBlock } from "@/components/block-editor/block-nodes";
 
export const quoteBlock = defineBlock({
  name: "quote",
  label: "Quote card",
  group: "Media",
  keywords: ["pull quote", "testimonial"],
  description: "Show a quote with its author",
  schema: z.object({
    text: z.string().default(""),
    author: z.string().default(""),
    featured: z.boolean().default(false),
  }),
  config: "auto",
  render: ({ attrs }) =>
    h("figure", [h("blockquote", attrs.text), h("figcaption", attrs.author)]),
});

Pass a .vue component instead when the block wants a template: render: QuoteBlock with import QuoteBlock from "./quote-block.vue".

What defineBlock takes:

FieldEffect
nameThe node name in the stored document. It cannot be a tiptap built-in such as paragraph, and it must be unique within blocks.
label, description, keywordsWhat the picker shows and matches against. Both label and description are translated against chelone.block_editor.blocks.<name>.
groupThe heading the block sits under in the picker.
schemaA Zod object whose output is the block's attrs. Give every key a .default(): a key without one gets null as its attr.
contentA content expression such as "block+", for a block that holds other blocks. Without it the block is an atom.
renderThe component for both modes. edit and read override it for one mode.
config"auto" for a form built from the schema, a component for a form of your own, or nothing for no Configure button.
iconThe picker's icon.

The auto config form

config: "auto" builds the panel from the schema with describeBlockAttrs, one control per key:

AttrControl
z.boolean()A switch
z.enum(["a", "b"])A select over its choices
z.number()A number input
z.string(), and a string with an email, url, date or datetime formatA text input of the matching type
Anything else, such as an object or an array of objectsNo control

A key with no control is still stored and still round trips; it simply has no field in the panel. Give the block a config component to edit those.

A block the editor does not know

A stored document can hold a block the current blocks list has no schema for: one another app wrote, or one since removed. The editor keeps it as an unknownBlock, shows Unknown block: <type>. It is kept on save. in its place, and writes the node back unchanged when the form saves. Deleting that row is what drops it.

Data blocks

Three shipped blocks store no content of their own. Each names a resource in its attrs and reads it through the data provider when it renders, so it shows what the resource holds now, not what it held when the document was written. dataBlocks is the three together:

src/resources/PostEdit.tsx
import { Edit } from "@chelone/react";
import { BlockEditorInput } from "@/components/block-editor/block-editor-input";
import { dataBlocks } from "@/components/block-editor/data-blocks";
import { defaultBlocks } from "@/components/block-editor/default-blocks";
 
export const PostEdit = () => (
  <Edit>
    <BlockEditorInput
      source="body"
      label="Body"
      blocks={[...defaultBlocks, ...dataBlocks]}
    />
  </Edit>
);

referenceRecordBlock, recordListBlock and chartBlock are exported on their own for an editor that wants some of them. Both entries ship all three, and the registry writes them to @/components/block-editor/data-blocks.

What each block shows, and what its Configure panel asks for:

BlockShowsConfigure
referenceRecordOne record as a card of its title and fields, or as an inline badge of the title alone with layout: "inline"Resource, Record id, Fields, Layout
recordListThe first rows of a resource as a table of up to three columnsResource, Fields, Rows
chartThe first rows grouped by a category field, drawn as a bar, line or pie chartResource, Chart type, Category field, Value field, Aggregate
  • Fields is a comma-separated list. Name none and the block shows every key the record owns but id; a record list cuts that list to three columns.
  • Rows is capped: 1 to 50 for a record list, 5 by default. The chart reads perPage rows too, 100 by default and 1000 at most, but its panel names no Rows field, so a chart reads 100 rows unless the stored attrs say otherwise.
  • A chart draws with the /charts components: <TrendChart> for a line, <DonutChart> for a pie and <BarChart> for a bar, which is TrendChart, DonutChart and BarChart here. React draws them with recharts and Vue with unovis, so /block-editor needs the same chart peers /charts needs.
  • Each read is an ordinary data provider read: useGetOne for a reference record, useGetList for a record list and a chart. A reader whose data provider refuses that resource sees the block's error state instead of data, so embedding a resource in a document is only as private as that resource's own access rules.
  • Before a block is configured, and while its read is on its way, it shows a hint or a skeleton in place of its data. A record list with no rows shows No records.

Stored content

The document is user-supplied, and the editor renders it.

  • An image renders only from a safe source: http, https, blob, or a base64 raster data: URL. A javascript: source or an SVG data URL shows the empty state instead. An uploaded file is stored in the document as a data URL, up to 5 MB.
  • An embed renders only from a YouTube or Vimeo link, and only in a sandboxed iframe whose src is rebuilt from the video id alone. Any other URL shows the empty state.
  • Nodes and marks come from the editor's own schema, so a stored document cannot bring in a node the editor has no component for.

These are browser-side checks, not a trust boundary. Validate the document on the server before you store it: the node types you accept, the image sources and the embed URLs.

Props

PropTypeDefaultDescription
alwaysOnboolean—As a `<FilterForm>`/`<FilterButton>` filter: always shown, never in the add-filter menu. Read off the element by the filter layer, never by the input.
blocksreadonly BlockDefinition<Attrs>[]—Read once, when the editor mounts.
classNamestring—
defaultValueNoInfer<SourceValue<TRecord, TPath>>—Input-level default, ra precedence (seeds only into an untouched hole). Typed as the value at `source`; `unknown` without a record.
disabledboolean—Native disabled state.
format(value: NoInfer<SourceTransformValue<TRecord, TPath>> | null | undefined) => unknown—Read transform (raw form value → input value). Receives the value at `source`, plus the hole an untouched field leaves (a create form with no `defaultValue` reads `undefined`); `unknown` without a record.
helperTextReactNode—Helper text rendered below the input; a string is translated.
idstring—Explicit element id; a generated `useId` value is used otherwise.
isRequiredboolean—Forces the required marker; ORed with `required()` detection on `validate`.
labelReactNode—The input label. A string is translated; a ReactNode renders as-is; `false` (or `""`) renders no label.
namestring—Legacy field-name alias (ra parity); the wiring uses `source`.
onBlur(event: unknown) => void—Chained AFTER the blur is recorded.
onChange(eventOrValue: unknown) => void—Chained AFTER the parsed value is written to the form.
parse(value: unknown) => NoInfer<SourceTransformValue<TRecord, TPath>>—Write transform (input value → stored value). Must return the value at `source`; `unknown` without a record.
placeholderstring—
readOnlyboolean—Native read-only state.
resourcestring—Override the ambient resource (used to resolve the default label key).
sourceTPath—The field path RELATIVE to the ambient source scope.
typestring—The DOM input `type` (text inputs default to `"text"`).
validateValidator | readonly Validator[]—ra-contract validators — composed, first error wins.

Installing the add-on

There are two ways in. Both give you <BlockEditorInput>, <BlockDocField> and defineBlock.

The registry copies the components into your project, and the code samples on these pages import them from the files it writes, @/components/block-editor/block-editor-input, @/components/block-editor/block-doc-field and @/components/block-editor/block-nodes. There is no index.ts in that folder:

npx shadcn add @chelone/block-editor

For Vue the item is the same name under the Vue registry: npx shadcn-vue add @chelone/block-editor. Either command installs the tiptap packages for you.

Or import from the npm subpath, and install the optional peers yourself. For React:

pnpm add @tiptap/core @tiptap/extensions @tiptap/pm @tiptap/react \
  @tiptap/starter-kit @tiptap/suggestion
import {
  BlockDocField,
  BlockEditorInput,
  defineBlock,
} from "@chelone/react/block-editor";

The React entry also needs @dnd-kit/react and @dnd-kit/dom for the block drag handle, and recharts for the chart block.

For Vue:

pnpm add @tiptap/core @tiptap/extensions @tiptap/pm @tiptap/starter-kit \
  @tiptap/suggestion @tiptap/vue-3
import {
  BlockDocField,
  BlockEditorInput,
  defineBlock,
} from "@chelone/vue/block-editor";

The Vue entry also needs @dnd-kit/vue and @dnd-kit/dom for the block drag handle, and @unovis/vue and @unovis/ts for the chart block.

The main @chelone/react and @chelone/vue entries never import tiptap, so an app that does not use the add-on does not need these packages.

React and Vue

The same components, the same value contract and the same block definitions, with these differences:

  • React takes className, Vue takes class. label is a node in React and a string or false in Vue, and Vue's helper-text is a string.
  • The Vue input has no id, resource, isRequired, format, parse or alwaysOn prop and emits no events. Its label is translated against the ambient resource, and a required() validator sets the required marker.
  • Both entries ship the same blocks: the four defaults and the three data blocks (dataBlocks, made of referenceRecordBlock, recordListBlock and chartBlock), and both move a block with the gutter grip and with the bar's Move up and Move down. What differs is what draws a chart: recharts in React, unovis in Vue.
  • <BlockEditor>, the editor the input builds on, takes onChange in React and emits change in Vue. <BlockDocField> takes an empty node in React, and the empty string with a #empty slot in Vue.

The Vue differences page lists the same changes with the other add-on notes.