@pilotiq/tiptap
Tiptap rich-text adapter for @pilotiq/pilotiq. Adds a RichTextField with always-on toolbar, selection-anchored quick-format toolbar, slash menu (/), draggable blocks, and a custom-block API for embedding inline forms inside the document.
#Why a separate package?
Tiptap is modular — extensions, themes, and node views are picked at integration time. Pulling them into @pilotiq/pilotiq would penalize apps that don't write long-form content. Mirrors the @pilotiq/codemirror pattern: small adapter package, opt-in registration.
#Installation
pnpm add @pilotiq/tiptap \
@tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/suggestion \
@tiptap/extension-link @tiptap/extension-placeholder \
@tiptap/extension-underline @tiptap/extension-subscript @tiptap/extension-superscript \
@tiptap/extension-text-align @tiptap/extension-text-style @tiptap/extension-color \
@tiptap/extension-highlight @tiptap/extension-image @tiptap/extension-table#Setup
Register the plugin on your Pilotiq.make(...) panel (typically app/Pilotiq/AdminPanel.ts):
import { Pilotiq } from '@pilotiq/pilotiq'
import { tiptap } from '@pilotiq/tiptap'
Pilotiq.make('Admin').plugins([
tiptap(),
])The plugin tells pilotiq's SchemaRenderer how to render fieldType: 'richtext' and wires a server-side renderer for finished HTML on display surfaces. The panel module is imported on both server and client, so a single registration covers both.
Prefer to register without going through the panel?
import { registerTiptap } from '@pilotiq/tiptap'and callregisterTiptap()from your client entry (pages/+Layout.tsx). The plugin form is just sugar over this call.
Without one of the two, RichTextField form fields render as nothing — SchemaRenderer can't find a renderer for the 'richtext' type.
#Tailwind setup
@pilotiq/tiptap ships Tailwind utility class names, not compiled CSS, so your
app's Tailwind build must scan the package — the same requirement as
@pilotiq/pilotiq. Skip it and any class not already used elsewhere in your
project silently won't render.
Tailwind v4 — add an @source alongside the one for @pilotiq/pilotiq:
@import "tailwindcss";
@source "../node_modules/@pilotiq/pilotiq/dist";
@source "../node_modules/@pilotiq/tiptap/dist";(Adjust the relative path to resolve to the installed dist; workspace setups
can point at src.) Tailwind v3 — add './node_modules/@pilotiq/tiptap/dist/**/*.js' to content. The editor's prose styles also expect @plugin "@tailwindcss/typography" (or the v3 plugin).
#Usage
import { RichTextField, Block } from '@pilotiq/tiptap'
Resource.make('Article').form((form) => form.schema([
RichTextField.make('body')
.label('Body')
.placeholder('Start writing…')
.blocks([
Block.make('callout').label('Callout').icon('💡').schema([
TextField.make('title'),
TextareaField.make('content').required(),
]),
]),
]))#Builder API
| Method | Default | Notes |
|---|---|---|
.placeholder(text) |
'Start writing…' |
Inherited from Field. |
.required() |
off | Inherited from Field. |
.storage('json' | 'html') |
'json' |
'json' stores Tiptap JSON; 'html' stores serialized HTML. |
.toolbar(false) |
true |
Hides the always-on top-level toolbar. |
.toolbarButtons([groups]) |
default groups | Replace the layout. Pass null to hide. |
.enableToolbarButtons([ids]) |
— | Append ids to the last group. |
.disableToolbarButtons([ids]) |
— | Drop ids from every group. |
.floatingToolbar(false) |
true |
Hides the selection-anchored quick-format toolbar. |
.slashCommand(false) |
true |
Disables the / menu. |
.blocks([Block...]) |
[] |
Custom-block schemas reachable via /. |
.textColors([{value, label, dark?}]) |
bundled palette | Replace the swatches in the textColor button. |
.customTextColors() |
off | Enable the free-form color picker below the swatches. |
.highlightColors([{value, label}]) |
bundled palette | Replace the swatches in the highlight button. |
.resizableImages() |
off | Drag-resize handle on inserted images (preserves aspect ratio). |
.fileAttachmentsAcceptedFileTypes(['image/*']) |
['image/*'] |
MIME-type allowlist for the attachFiles picker. |
.fileAttachmentsMaxSize(bytes) |
unlimited | Per-file size cap. The upload route also enforces it. |
.fileAttachmentsDirectory('articles') |
— | Sub-directory hint forwarded to the panel's UploadAdapter. |
.fileAttachmentsVisibility('public' | 'private') |
— | Adapter-defined visibility hint. |
.mergeTags(['firstName', 'company']) |
[] |
Identifiers surfaced under "Merge tags" in the slash menu — each becomes a {{ id }} chip in the editor. |
.mentions([MentionProvider.make('@').items([…])]) |
[] |
One mention provider per trigger character (@, #, …). Items can be static (.items([…])) or async (.itemsUsing(async (query, ctx) => […])); see "Mentions" below. |
#Toolbar buttons
Recognized button ids (use them in toolbarButtons / enableToolbarButtons / disableToolbarButtons):
Inline marks bold italic underline strike subscript superscript code
Size variants lead small
Headings paragraph h1 h2 h3 h4 h5 h6
Alignment alignStart alignCenter alignEnd alignJustify
Block prims blockquote codeBlock bulletList orderedList horizontalRule
Style textColor highlight clearFormatting
Files attachFiles
Tables table
tableAddColumnBefore tableAddColumnAfter tableDeleteColumn
tableAddRowBefore tableAddRowAfter tableDeleteRow
tableMergeCells tableSplitCell
tableToggleHeaderRow tableToggleHeaderCell
tableDelete
Disclosure details
Layout grid gridDelete
Editing link undo redolead and small are inline marks: lead wraps the selection in <span class="lead">…</span> (style with your own .lead rule — typically the lede paragraph treatment), small wraps in the semantic <small>…</small> element. They compose freely with bold/italic/color and are surfaced under the Style group of the slash menu (/lead, /small).
Default layout (matches the reference admin):
[
['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'],
['h2', 'h3'],
['alignStart', 'alignCenter', 'alignEnd'],
['blockquote', 'codeBlock', 'bulletList', 'orderedList'],
['undo', 'redo'],
]Custom palette / palette-popover button ids beyond the ones listed above are silently dropped — the union is intentionally forward-compatible.
#attachFiles
The attachFiles button opens a Base UI dialog with a file picker, alt-text input, and inline error messages. On submit, the dialog posts multipart to the panel's _uploads route (the same route FileUpload uses); on success the returned { ok, url } is fed to editor.chain().setImage({ src, alt }) for image MIME types, or inserted as a link mark on the filename for non-images.
The button is stripped from the toolbar server-side when no UploadAdapter is registered with Pilotiq.uploads({ adapter }), so apps without uploads never see a broken affordance — the same posture as MarkdownField's attachFiles. Pair .enableToolbarButtons(['attachFiles']) with field options for shape control:
RichTextField.make('body')
.enableToolbarButtons(['attachFiles'])
.resizableImages()
.fileAttachmentsAcceptedFileTypes(['image/*'])
.fileAttachmentsMaxSize(2_000_000)
.fileAttachmentsDirectory('articles')#Slash menu
Opens on /. Built-in items:
- Basic — Text, Quote, Code block, Divider, Clear formatting
- Headings — Heading 1 to 6
- Lists — Bullet list, Numbered list
- Align — Align left, Align center, Align right
- Insert — Table (3×3 with header row), Collapsible block (
<details>), Two-column grid, Three-column grid, Image (only when anUploadAdapteris registered viaPilotiq.uploads({ adapter })) - Style — Lead, Small
- Blocks — every entry in
.blocks([...]) - Merge tags — every entry in
.mergeTags([...])
The Image entry shares the same upload dialog as the toolbar's attachFiles button — picking it deletes the slash range, then opens the dialog (which handles the actual upload + image insertion). The dialog is mounted at the editor level, so the slash entry works even when the toolbar is hidden via .toolbar(false). The Table entry inserts a 3×3 table with a header row at the cursor; the table-floating-toolbar handles row / column / merge / delete operations once the cursor is inside.
Each registered Block becomes a slash item that inserts an inline form. The block's schema([fields]) defines the form layout — use any pilotiq Field type. The result lives in the document as a single ProseMirror node with attrs.blockType and attrs.blockData.
#Storage
The hidden form input carries either a JSON string or an HTML fragment, depending on .storage(...). The form lifecycle's coerceFormValues('richtext') parses JSON before save; HTML mode passes through as a string. Both formats round-trip through Prisma String columns.
#Read-side rendering
registerTiptap() also wires a server-side renderer so display surfaces (TextEntry on Resource.detail(), default-text columns in Table) auto-render Tiptap content to HTML — without shipping the editor to read-only pages.
import { renderRichTextToHtml, isRichTextValue } from '@pilotiq/tiptap'
// or for a server-only import path:
// import { renderRichTextToHtml, isRichTextValue } from '@pilotiq/tiptap/render'
renderRichTextToHtml({ type: 'doc', content: [...] })
// '<p>Hello <strong>world</strong></p>'The renderer is a pure function — no DOM, no Tiptap runtime, no React. Safe to call from any server context. Coverage:
- Nodes: doc / paragraph / heading (1-6) / blockquote / codeBlock / bulletList / orderedList / listItem / horizontalRule / hardBreak / image / table / tableRow / tableCell / tableHeader / details / detailsSummary / detailsContent / grid / gridColumn / mergeTag / mention.
- Marks: bold / italic / strike / underline / subscript / superscript / code / link / textStyle (color) / highlight (color).
- Attrs: heading.level / orderedList.start / codeBlock.language / textAlign on paragraph + heading / image.src + alt + title + width + height / tableCell.colspan + rowspan + colwidth (also on tableHeader) / mergeTag.id / mention.id + label + trigger.
- Custom blocks: anything not built-in renders to
<div data-type="..." data-attrs="...">so consumers can replay or restyle bydata-type. Override withrenderRichTextToHtml(content, { renderBlock: (node) => ... }). - Sanitization: text content is HTML-escaped; link hrefs reject
javascript:/data:/vbscript:(fall back to#); image srcs with the same schemes drop the<img>entirely (no brokensrc="#"re-fetch); image dimensions parse to integers and silently drop bad / non-finite / negative values; color values are allowlisted to hex / rgb / hsl / oklch / named. Surrounding markup is constructed by us, not parsed from user input — the posture matchesMarkdown/Htmldisplay primes (admin-trusted authors).
#Auto-render on TextEntry and Table columns
Once registerTiptap() runs, no extra wiring is needed — the registry-aware display surfaces detect rich-text content and render finished HTML:
Resource.make('Article').detail((record) => [
TextEntry.make('body'), // auto-renders Tiptap JSON
TextEntry.make('publishedAt').since(), // built-in formatter wins
TextEntry.make('summary').formatStateUsing(plain), // user formatter wins
])
Resource.make('Article').table((table) => table.columns([
Column.make('body').lineClamp(3), // auto-renders + clamps
Column.make('publishedAt').dateTime(), // skipped (has format)
]))The auto-detect is conservative: it only matches the canonical { type: 'doc', content: [...] } shape (object or JSON-encoded string). Plain text, raw HTML strings, and arbitrary JSON columns fall through to the default formatter. Without registerTiptap(), the registry has no renderer and these surfaces behave exactly as before.
#Floating toolbars
The selection-anchored toolbar shows when text is selected and offers the inline marks (B / I / Strike / Code / Link). Toggle via .floatingToolbar(false). The top-level toolbar covers the rest.
A separate table toolbar appears above the enclosing <table> whenever the cursor is inside one — five logical groups: column ops (add before/after, delete), row ops (add before/after, delete), merge/split, header-row + header-cell toggles, and delete-table. The buttons read editor.can().<command>() for their disabled state, so options that don't apply to the current cell render greyed out instead of crashing on a no-op chain. The table toolbar is independent of .floatingToolbar(...) (which gates the inline-mark variant) and shows whenever @tiptap/extension-table is wired.
#Tables
Insert a 3×3 table with a header row via the table toolbar button (or by adding it to a toolbarButtons group). While the cursor is inside a table:
- The table toolbar floats above the table with cell-management buttons.
- All
table*ids in the toolbar union (tableAddColumnBefore,tableAddRowAfter,tableMergeCells,tableToggleHeaderCell,tableDelete, …) work in the top-level toolbar too. - Drag a column-divider to resize. Resize state is stored on the cell as
colwidth: number[]; the read-side renderer turns it into a<colgroup>so<table>HTML matches the editor's column proportions. lastColumnResizable: falseis on by default — the right-edge handle won't grow the table beyond its container.
Tables are best for small tabular data inline with the article body. For records-as-rows, use a Resource — its Table page has filters, sorting, pagination, and editable columns.
#Collapsible blocks
Insert a native <details> block via / → Collapsible block in the slash menu, or add 'details' to a toolbarButtons group. Each block is a node trio (details / detailsSummary / detailsContent); the editor renders a click-to-toggle disclosure widget, and the read-side renderer emits standard HTML:
<details><summary>Click to expand</summary><p>Hidden content</p></details>The open / closed state is persisted on the document — Details.configure({ persist: true }) is the default so SSR + reload pick up the state the author left it in. When attrs.open === true, the renderer adds the platform open attribute to the <details> tag.
#Multi-column grids
Insert a 2- or 3-column grid via the slash menu (/ → Two-column grid / Three-column grid) or add 'grid' to a toolbarButtons group. Pair 'gridDelete' to unwrap the enclosing grid back into a flat sequence of paragraphs. Schema constrains column count to 2 or 3 — there's no path to a 1-col or 4+-col grid through any surface (toolbar, slash, paste).
<div class="pilotiq-grid pilotiq-grid-cols-2"><div><p>Left</p></div><div><p>Right</p></div></div>The package ships no CSS — pair the class names with a Tailwind rule (.pilotiq-grid { display: grid; gap: 1rem } .pilotiq-grid-cols-2 { grid-template-columns: repeat(2, 1fr) } .pilotiq-grid-cols-3 { grid-template-columns: repeat(3, 1fr) }) or whatever your stylesheet shape prefers. Same posture as lead / small size marks: consumer owns the styling.
The toolbar's grid button defaults to 2 columns when clicked — most-common-case UX. The slash entries cover both column counts directly.
#Merge tags
Surface a {{ tag }} placeholder for each identifier in the slash menu. Picking one inserts a mergeTag inline atom node ({ type: 'mergeTag', attrs: { id: 'firstName' } }) that renders in the editor as a small chip:
RichTextField.make('body').mergeTags(['firstName', 'company', 'unsubscribeUrl'])Read-time substitution happens through renderRichTextToHtml(content, { mergeTags }). Pass a Record<string, string> and each placeholder is replaced with the value (HTML-escaped):
renderRichTextToHtml(article.body, {
mergeTags: {
firstName: user.firstName,
company: user.company,
unsubscribeUrl: `https://example.com/u/${user.id}/unsubscribe`,
},
})
// '<p>Hi Sleman, …</p>'When no map (or no key for a given id) is supplied, the renderer emits <span class="merge-tag" data-id="...">{{ id }}</span> so server-rendered previews stay informative — useful for "draft" surfaces that show what the message will look like, not what it does for a specific recipient.
#Mentions
Wire one or more mention providers — each owns a single trigger character (@, #, …) and a static item list. Typing the trigger opens a popover anchored to the cursor; picking an item inserts a mention inline atom node carrying id, label, and trigger.
import { MentionProvider } from '@pilotiq/tiptap'
RichTextField.make('body').mentions([
MentionProvider.make('@').items([
{ id: 'sleman', label: 'Sleman' },
{ id: 'admin', label: 'Admin' },
]),
MentionProvider.make('#').items([
{ id: 'general', label: 'general', group: 'Channels' },
{ id: 'random', label: 'random', group: 'Channels' },
]),
])Items can declare an optional group string — the popover renders matching items together under that heading.
Read-time rendering uses the cached label by default (the editor stamps it at insert, so static snapshots stay self-contained). Pass resolveMention to refresh stale display names from a directory:
renderRichTextToHtml(article.body, {
resolveMention: (trigger, id) => directory.get(`${trigger}${id}`)?.displayName,
})The renderer always emits a styled <span class="mention" data-trigger="@" data-id="sleman">@Sleman</span> — the wrapping span carries the structural attributes so consumers can re-style or rewrite each chip downstream.
#Async items — MentionProvider.itemsUsing(async (query, ctx) => …)
Static items are declared once at form-build time. For larger or live-changing item sets — search a users table, hit a directory service, gate items by tenant — pass an async resolver instead. The closure runs server-side on each keystroke; the editor fetches a tiny per-form endpoint and renders the response.
MentionProvider.make('@').itemsUsing(async (query, ctx) => {
const matches = await db.users.search(query, { limit: 10 })
return matches.map(u => ({ id: u.id, label: u.name }))
})ctx carries { user, record?, request? } — the same opaque user object configured via Pilotiq.user(req => …), the loaded record on edit-mode forms, and the raw request for adapters that need cookie / header access.
items() and itemsUsing() are mutually exclusive; the last call wins and a console.warn fires when the previously-set items list is silently dropped. Mixing static + async providers on the same field is supported (@ async for users, # static for a fixed channel list).
The wire path is POST {scope}/_form/{formId}/mentions with body { field, trigger, query }. Pilotiq stamps the URL onto the field meta when at least one provider has itemsUsing(fn) — fields with only static providers stay URL-less and the client never makes a network call.
The endpoint reuses each scope's existing auth gate: resource-create routes through R.canAccess + R.canCreate, resource-edit through R.canAccess + R.canEdit, global-edit through G.canAccess + G.canEdit, custom pages through Page.canAccess. A throwing resolver returns 422 with the error message; the popover degrades to "no matches" rather than crashing.
Async-mention providers inside a Repeater or Builder row are supported. The client posts the row-relative dotted path (
items.0.bodyfor a Repeater leaf,blocks.0.data.bodyfor a Builder leaf); the route handler parses the prefix and looks up the field against the Repeater's template / each Builder block's schema. Field config (providers + resolver) is shared across rows, so the<index>segment is informational — any row resolves to the same template field. Builder caveat: when two blocks define a RichTextField with the same leaf name and different async providers, only the first block in declaration order is reachable from the dispatcher. Give them distinct names if you need per-block resolution.
#Custom blocks
Block.make('callout').label('Callout').icon('💡').schema([
TextField.make('title'),
TextareaField.make('content').required(),
SelectField.make('tone').options([
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
]),
])Each inserted block renders as a compact summary card inside the document — block icon, label, and a one-line preview of the filled fields. Clicking Edit opens a side panel docked to the right of the editor; the panel mounts the block's schema as a real pilotiq form (via the <FormFields> renderer from @pilotiq/pilotiq/react) so every field type that pilotiq supports works consistently across the rest of your forms. Editing writes back into attrs.blockData on every keystroke — no save button, no roundtrip.
The panel tracks its position through every editor transaction, so live edits elsewhere in the document don't desync the panel. If the block is deleted while the panel is open, the panel closes itself.
Field-type coverage: every pilotiq field type works inside a custom block. Flat fields (TextField, TextareaField, SelectField, ToggleField, CheckboxField, RadioField, ToggleButtonsField, DateField, DateTimePickerField, EmailField, NumberField, SliderField, ColorPickerField) flow through directly. Nested-shape fields (RepeaterField, BuilderField, FileUploadField, MarkdownField, KeyValueField, TagsInputField, CheckboxListField) round-trip too: the panel snapshots the entire form's DOM state, rebuilds nested arrays / objects from dotted-path inputs, and coerces JSON-encoded hidden inputs (TagsInput, KeyValue, FileUpload-multi) back to their canonical wire shapes before writing into attrs.blockData.
Layout caveat: the panel is positioned absolute to the right of the editor wrapper. On narrow form layouts it may extend into adjacent content; this is intentional — the panel doesn't push the editor sideways or reflow the page.
#AI suggestions
@pilotiq/tiptap ships an always-on AiSuggestionExtension that turns
any range in the document into an inline-diff hunk: strikethrough on
the original text, a chip-widget at the range end carrying a preview of
the replacement, and per-hunk Approve / Reject buttons. The extension
is idle until the host calls editor.commands.addAiSuggestion(...) or
the cross-package suggestion bridge pushes one in.
editor.commands.addAiSuggestion({
id: 'seo-1',
from: 12,
to: 18,
replacement: 'better',
source: { agentLabel: 'SEO' },
})
// User clicks ✓ on the chip, or the host calls programmatically:
editor.commands.approveAiSuggestion('seo-1')#Command surface
| Command | What it does |
|---|---|
addAiSuggestion(s) |
Add or replace a suggestion (matched by id). |
addAiSuggestions(s[]) |
Add or replace many in one transaction. |
approveAiSuggestion(id) |
Replace the range with the suggestion's text + drop from state. |
rejectAiSuggestion(id) |
Drop from state without touching the document. |
approveAllAiSuggestions() |
Apply every replacement in highest-from-first order so earlier replacements don't shift later positions. |
rejectAllAiSuggestions() |
Drop every suggestion. |
clearAiSuggestions() |
Alias for rejectAllAiSuggestions. |
#Suggestion shape
import type { AiSuggestion } from '@pilotiq/tiptap'
interface AiSuggestion {
id: string // stable; re-adding replaces
from: number // inclusive document position
to: number // exclusive position; from===to = pure insertion
replacement: string // plain text in v1
source?: { agentSlug?: string; agentLabel?: string }
}Plain-text replacement only in v1 — marks and structure are not carried on the inserted text node. Document marks at the original range are preserved by ProseMirror when the chip is approved.
#Range remapping
Suggestion ranges remap through every doc transaction's tr.mapping
(left-bias from, right-bias to). Ranges that collapse past each
other (to < from) drop automatically — if the user types over the
suggested range and obliterates it, the chip vanishes silently.
#Styling
The package stays CSS-free — consumers ship the matching styles. Default classes:
| Class | Where |
|---|---|
pilotiq-ai-suggestion-original |
Inline decoration on the original from..to range (typically strikethrough + muted color). |
pilotiq-ai-suggestion-chip |
Widget root at to. |
pilotiq-ai-suggestion-replacement |
Inline preview of the suggested text inside the chip. |
pilotiq-ai-suggestion-accept |
The ✓ button. |
pilotiq-ai-suggestion-reject |
The ✕ button. |
The class prefix is configurable via the extension's classPrefix
option ('pilotiq-ai-suggestion' by default).
#Cross-package bridge — useAiSuggestionBridge(editor, fieldName)
For the typical AI use case, the editor's suggestion list is mirrored
to/from pilotiq core's PendingSuggestionsContext
— that context is what the chat-sidebar pending pill, FieldShell overlay,
and out-of-tree approve actions read. TiptapEditor mounts the bridge
automatically, so the typical pilotiq install gets the round-trip for
free.
For a custom editor mount (e.g. a standalone Tiptap instance outside pilotiq's field renderer), wire the bridge yourself:
import { useAiSuggestionBridge } from '@pilotiq/tiptap'
function MyEditor() {
const editor = useEditor({ /* … */ })
useAiSuggestionBridge(editor ?? null, 'myField')
return <EditorContent editor={editor} />
}The bridge:
- Context → editor: every queue entry whose
meta.editorRange = { from, to }is set and whosesuggestedValueis a string gets pushed into the editor as an inline-diff hunk viaaddAiSuggestion. Entries that leave the queue are removed viarejectAiSuggestion(no document edit). - Editor → context: when a chip's Approve / Reject button removes
a hunk from the editor's plugin state, the matching id is dismissed
from the queue (
dismiss(id)) so other surfaces (chat-pill, FieldShell overlay registered by another plugin) clear in lock-step.
The bridge is cycle-protected via an internal pushedRef: Set<string>
so direct editor.commands.addAiSuggestion(...) calls (host code) don't
echo back through a context that never knew about them.
#Pure helpers (testing)
For consumers that want to unit-test producer logic without spinning up an editor, the extension's pure helpers are exported:
| Helper | Purpose |
|---|---|
upsertSuggestion(list, next) |
Append or replace by id. |
upsertSuggestions(list, nexts) |
Fold multi-add over upsertSuggestion. |
removeSuggestion(list, id) |
Filter by id. |
remapSuggestions(list, mapFn) |
Drop collapsed ranges; remap survivors. |
sortForApproveAll(list) |
Highest-from-first order. |
clampPos(pos, max) |
Bound a position into [0, max]. |