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<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
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".
{
"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, sorequired()rejects a cleared editor. - A value that is not a doc object reads as an empty editor.
blocksis 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
setValuereplaces 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.
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:
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:
| Field | Effect |
|---|---|
name | The 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, keywords | What the picker shows and matches against. Both label and description are translated against chelone.block_editor.blocks.<name>. |
group | The heading the block sits under in the picker. |
schema | A Zod object whose output is the block's attrs. Give every key a .default(): a key without one gets null as its attr. |
content | A content expression such as "block+", for a block that holds other blocks. Without it the block is an atom. |
render | The 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. |
icon | The picker's icon. |
The auto config form
config: "auto" builds the panel from the schema with describeBlockAttrs, one
control per key:
| Attr | Control |
|---|---|
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 format | A text input of the matching type |
| Anything else, such as an object or an array of objects | No 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:
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:
| Block | Shows | Configure |
|---|---|---|
referenceRecord | One 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 |
recordList | The first rows of a resource as a table of up to three columns | Resource, Fields, Rows |
chart | The first rows grouped by a category field, drawn as a bar, line or pie chart | Resource, 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
perPagerows 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
/chartscomponents:<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-editorneeds the same chart peers/chartsneeds. - Each read is an ordinary data provider read:
useGetOnefor a reference record,useGetListfor 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 rasterdata:URL. Ajavascript: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
| Prop | Type | Default | Description |
|---|---|---|---|
| alwaysOn | boolean | — | 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. |
| blocks | readonly BlockDefinition<Attrs>[] | — | Read once, when the editor mounts. |
| className | string | — | |
| defaultValue | NoInfer<SourceValue<TRecord, TPath>> | — | Input-level default, ra precedence (seeds only into an untouched hole). Typed as the value at `source`; `unknown` without a record. |
| disabled | boolean | — | 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. |
| helperText | ReactNode | — | Helper text rendered below the input; a string is translated. |
| id | string | — | Explicit element id; a generated `useId` value is used otherwise. |
| isRequired | boolean | — | Forces the required marker; ORed with `required()` detection on `validate`. |
| label | ReactNode | — | The input label. A string is translated; a ReactNode renders as-is; `false` (or `""`) renders no label. |
| name | string | — | 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. |
| placeholder | string | — | |
| readOnly | boolean | — | Native read-only state. |
| resource | string | — | Override the ambient resource (used to resolve the default label key). |
| source | TPath | — | The field path RELATIVE to the ambient source scope. |
| type | string | — | The DOM input `type` (text inputs default to `"text"`). |
| validate | Validator | 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-editorFor 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/suggestionimport {
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-3import {
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 takesclass.labelis a node in React and a string orfalsein Vue, and Vue'shelper-textis a string. - The Vue input has no
id,resource,isRequired,format,parseoralwaysOnprop and emits no events. Its label is translated against the ambient resource, and arequired()validator sets the required marker. - Both entries ship the same blocks: the four defaults and the three data
blocks (
dataBlocks, made ofreferenceRecordBlock,recordListBlockandchartBlock), 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, takesonChangein React and emitschangein Vue.<BlockDocField>takes anemptynode in React, and theemptystring with a#emptyslot in Vue.
The Vue differences page lists the same changes with the other add-on notes.