@pilotiq/tiptap 3.10.5 → 3.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/package.json +4 -3
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -228
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -603
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -777
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# @pilotiq/tiptap
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Tiptap rich-text adapter for `@pilotiq/pilotiq`. Adds a `RichTextField` to the form-field catalog with always-on toolbar, selection-anchored floating toolbar, slash menu (`/`), draggable blocks, mention/merge-tag chips, and a custom-block API for embedding inline forms inside a document.
|
|
6
|
+
|
|
7
|
+
Separate package because Tiptap's extension set is modular and not every app needs long-form content. Mirrors `@pilotiq/codemirror` — small adapter, opt-in registration.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @pilotiq/tiptap \
|
|
13
|
+
@tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/suggestion \
|
|
14
|
+
@tiptap/extension-link @tiptap/extension-placeholder \
|
|
15
|
+
@tiptap/extension-underline @tiptap/extension-subscript @tiptap/extension-superscript \
|
|
16
|
+
@tiptap/extension-text-align @tiptap/extension-text-style @tiptap/extension-color \
|
|
17
|
+
@tiptap/extension-highlight @tiptap/extension-image @tiptap/extension-table \
|
|
18
|
+
@tiptap/extension-details
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Register the plugin on the panel:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// app/Pilotiq/AdminPanel.ts
|
|
25
|
+
import { Pilotiq } from '@pilotiq/pilotiq'
|
|
26
|
+
import { tiptap } from '@pilotiq/tiptap'
|
|
27
|
+
|
|
28
|
+
export const adminPanel = Pilotiq.make('Admin')
|
|
29
|
+
.path('/admin')
|
|
30
|
+
.plugins([
|
|
31
|
+
tiptap(),
|
|
32
|
+
])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The plugin form is sugar over `registerTiptap()` — it runs on both server and client through the panel module. Without registration, `RichTextField` form fields render as nothing because `SchemaRenderer` can't find a renderer for `fieldType: 'richtext'`.
|
|
36
|
+
|
|
37
|
+
## Key Patterns
|
|
38
|
+
|
|
39
|
+
### Basic usage
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { RichTextField } from '@pilotiq/tiptap'
|
|
43
|
+
|
|
44
|
+
Resource.make('Article').form((form) => form.schema([
|
|
45
|
+
TextField.make('title').required(),
|
|
46
|
+
RichTextField.make('body')
|
|
47
|
+
.label('Body')
|
|
48
|
+
.placeholder('Start writing…')
|
|
49
|
+
.required(),
|
|
50
|
+
]))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The field stores Tiptap JSON by default. Use `.storage('html')` for serialized HTML if your column type is text-only.
|
|
54
|
+
|
|
55
|
+
### Custom blocks
|
|
56
|
+
|
|
57
|
+
`Block` defines a reusable embed — a card with its own form schema. Users insert via the slash menu (`/`), edit via the side panel.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Block, RichTextField } from '@pilotiq/tiptap'
|
|
61
|
+
import { TextField, TextareaField, SelectField, FileUpload } from '@pilotiq/pilotiq'
|
|
62
|
+
|
|
63
|
+
RichTextField.make('body')
|
|
64
|
+
.blocks([
|
|
65
|
+
Block.make('callout')
|
|
66
|
+
.label('Callout')
|
|
67
|
+
.icon('💡') // emoji or @pilotiq/pilotiq icon registry name
|
|
68
|
+
.schema([
|
|
69
|
+
SelectField.make('variant').options({ info: 'Info', warning: 'Warning', danger: 'Danger' }),
|
|
70
|
+
TextField.make('title').required(),
|
|
71
|
+
TextareaField.make('content').required(),
|
|
72
|
+
]),
|
|
73
|
+
|
|
74
|
+
Block.make('youtube')
|
|
75
|
+
.label('YouTube embed')
|
|
76
|
+
.icon('youtube')
|
|
77
|
+
.schema([
|
|
78
|
+
TextField.make('url').required().placeholder('https://www.youtube.com/watch?v=…'),
|
|
79
|
+
]),
|
|
80
|
+
|
|
81
|
+
Block.make('cta')
|
|
82
|
+
.label('Call to action')
|
|
83
|
+
.icon('zap')
|
|
84
|
+
.schema([
|
|
85
|
+
TextField.make('heading').required(),
|
|
86
|
+
TextField.make('label').default('Learn more'),
|
|
87
|
+
TextField.make('href').required(),
|
|
88
|
+
]),
|
|
89
|
+
])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Behavior:
|
|
93
|
+
|
|
94
|
+
- Inserting a block via `/` opens the side panel with the block's `.schema([…])` mounted as a real pilotiq form.
|
|
95
|
+
- Edits write back into the block's `attrs.blockData` on every keystroke — no save button.
|
|
96
|
+
- The side panel uses `<FormFields>` from `@pilotiq/pilotiq/react`, so every field type from `pilotiq-fields` works (TextField / SelectField / Toggle / Repeater / Builder / FileUpload / etc.).
|
|
97
|
+
- `Mod-E` (Cmd/Ctrl-E) on a selected block opens its side panel. `ESC` closes.
|
|
98
|
+
|
|
99
|
+
The block name (`'callout'`, `'youtube'`, `'cta'`) is the discriminator — **never use `'block'` as a name**, it collides with ProseMirror's schema GROUP and breaks `contentMatchAt`. The framework's built-in node is `pilotiqBlock` so user-supplied names are safe.
|
|
100
|
+
|
|
101
|
+
### Toolbar customization
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
RichTextField.make('body')
|
|
105
|
+
.toolbarButtons([
|
|
106
|
+
['bold', 'italic', 'underline', 'strike', 'link'],
|
|
107
|
+
['h2', 'h3'],
|
|
108
|
+
['textColor', 'highlight'],
|
|
109
|
+
['bulletList', 'orderedList'],
|
|
110
|
+
['attachFiles', 'table', 'details', 'grid', 'gridDelete'],
|
|
111
|
+
['undo', 'redo'],
|
|
112
|
+
])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Or use the incremental setters against the defaults:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
RichTextField.make('body')
|
|
119
|
+
.enableToolbarButtons(['lead', 'small']) // append to last group
|
|
120
|
+
.disableToolbarButtons(['table']) // drop from every group
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Default layout:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
[
|
|
127
|
+
['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'],
|
|
128
|
+
['h2', 'h3'],
|
|
129
|
+
['alignStart', 'alignCenter', 'alignEnd'],
|
|
130
|
+
['blockquote', 'codeBlock', 'bulletList', 'orderedList'],
|
|
131
|
+
['undo', 'redo'],
|
|
132
|
+
]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Recognized button ids:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Inline marks bold italic underline strike subscript superscript code lead small
|
|
139
|
+
Headings paragraph h1 h2 h3 h4 h5 h6
|
|
140
|
+
Alignment alignStart alignCenter alignEnd alignJustify
|
|
141
|
+
Block prims blockquote codeBlock bulletList orderedList horizontalRule
|
|
142
|
+
Style textColor highlight clearFormatting
|
|
143
|
+
Files attachFiles
|
|
144
|
+
Tables table tableAddColumnBefore tableAddColumnAfter tableDeleteColumn
|
|
145
|
+
tableAddRowBefore tableAddRowAfter tableDeleteRow
|
|
146
|
+
tableMergeCells tableSplitCell tableDelete
|
|
147
|
+
tableToggleHeaderRow tableToggleHeaderCell
|
|
148
|
+
Disclosure details
|
|
149
|
+
Layout grid gridDelete
|
|
150
|
+
Editing link undo redo
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Unknown ids are silently dropped — the union is intentionally forward-compatible.
|
|
154
|
+
|
|
155
|
+
Hide the toolbar entirely with `.toolbar(false)`. Disable the floating selection toolbar with `.floatingToolbar(false)`. Disable the slash menu with `.slashCommand(false)`.
|
|
156
|
+
|
|
157
|
+
### Merge tags
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
RichTextField.make('body')
|
|
161
|
+
.mergeTags(['firstName', 'lastName', 'company', 'unsubscribeUrl'])
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Each id appears under the **Merge tags** group of the slash menu and inserts a `{{ firstName }}` chip in the editor. On the server, the chip serializes verbatim as `{{ firstName }}` text — your render-time substitution handles the replacement.
|
|
165
|
+
|
|
166
|
+
### Mentions
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { RichTextField, MentionProvider } from '@pilotiq/tiptap'
|
|
170
|
+
|
|
171
|
+
RichTextField.make('body')
|
|
172
|
+
.mentions([
|
|
173
|
+
MentionProvider.make('@')
|
|
174
|
+
.items([
|
|
175
|
+
{ id: 'alice', label: 'Alice', subtitle: 'Engineering' },
|
|
176
|
+
{ id: 'bob', label: 'Bob', subtitle: 'Design' },
|
|
177
|
+
]),
|
|
178
|
+
|
|
179
|
+
MentionProvider.make('#')
|
|
180
|
+
.itemsUsing(async (query, ctx) => {
|
|
181
|
+
const tags = await Tag.query()
|
|
182
|
+
.where('name', 'LIKE', `%${query}%`)
|
|
183
|
+
.paginate(1, 10)
|
|
184
|
+
return tags.data.map(t => ({ id: t.slug, label: t.name }))
|
|
185
|
+
}),
|
|
186
|
+
])
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Static items render immediately. Async items POST to a per-form `_mentions/:provider` endpoint with the typed query and the form's render context. Async resolvers see the current user + record + parent (when inside a `Repeater` / `Builder` row).
|
|
190
|
+
|
|
191
|
+
The chip serializes as `@alice` / `#performance` in HTML output and as a node attr in JSON. Your display layer chooses how to resolve / link the chip.
|
|
192
|
+
|
|
193
|
+
### File attachments
|
|
194
|
+
|
|
195
|
+
`attachFiles` toolbar button uploads via the panel's registered `UploadAdapter`:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
RichTextField.make('body')
|
|
199
|
+
.fileAttachmentsAcceptedFileTypes(['image/*', 'application/pdf'])
|
|
200
|
+
.fileAttachmentsMaxSize(5 * 1024 * 1024) // 5 MB cap
|
|
201
|
+
.fileAttachmentsDirectory('articles') // sub-directory hint
|
|
202
|
+
.fileAttachmentsVisibility('public') // adapter-dependent
|
|
203
|
+
.resizableImages() // drag-handle resize on images
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The upload route also enforces `maxSize` server-side (tampered client can't bypass). Without an `UploadAdapter` registered on the panel, the `attachFiles` button silently hides — wire one with `panel.uploads({ adapter: localUpload(...) })`.
|
|
207
|
+
|
|
208
|
+
### Storage format
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
RichTextField.make('body')
|
|
212
|
+
.storage('json') // default: Tiptap JSON document
|
|
213
|
+
.storage('html') // serialized HTML string
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use JSON for editor-round-trip fidelity (lossless across save/load). Use HTML when the column is plain `TEXT` and you don't need the editor to read it back perfectly (the editor parses HTML back but loses some node-level attrs).
|
|
217
|
+
|
|
218
|
+
### Server-side rendering
|
|
219
|
+
|
|
220
|
+
For display surfaces (`TextEntry`, `Column` with `.markdown()` / `.html()`), use the pure renderer:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { renderRichTextToHtml } from '@pilotiq/tiptap'
|
|
224
|
+
|
|
225
|
+
const html = renderRichTextToHtml(article.body)
|
|
226
|
+
// safe to call from any server context — no DOM, no Tiptap runtime
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The renderer is a pure function in `src/render.ts` with zero Tiptap runtime deps — usable in workers, edge functions, etc.
|
|
230
|
+
|
|
231
|
+
### Floating toolbar + slash menu
|
|
232
|
+
|
|
233
|
+
These are on by default. The floating toolbar mounts on text selection with the most-used inline marks (bold / italic / link / clearFormatting); the slash menu opens cursor-anchored on `/` with groups: **Insert**, **Style**, **Format**, **Merge tags** (when set), plus any user `blocks([…])`.
|
|
234
|
+
|
|
235
|
+
Both can be disabled per field:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
RichTextField.make('body')
|
|
239
|
+
.floatingToolbar(false)
|
|
240
|
+
.slashCommand(false)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Collab mode
|
|
244
|
+
|
|
245
|
+
When the host panel's `collab` slot is wired (via `@pilotiq-pro/collab`), `RichTextField` automatically participates in record-room collaborative editing — peers see each other's cursors, edits merge via Y.js CRDT, no opt-in needed on the field itself. The pro package handles the awareness + sync wiring.
|
|
246
|
+
|
|
247
|
+
## Common Pitfalls
|
|
248
|
+
|
|
249
|
+
- **Forgetting `registerTiptap()` / `.plugins([tiptap()])`** — `RichTextField` form fields render as nothing because `SchemaRenderer` can't find a renderer for `fieldType: 'richtext'`. The plugin form is the recommended path; register from `AdminPanel.ts` (loads on both server + client).
|
|
250
|
+
- **`Block.make('block')` collides with PM's schema GROUP** — never use the literal name `'block'`. Any other name is fine.
|
|
251
|
+
- **Drag handles missing the snap-back-to-origin three steps** — if you're writing a custom block with external drag UX, the drop handler must `setNodeSelection(pos)` AND set `view.dragging` AND call `serializeForClipboard`. Missing any of the three is the snap-back bug.
|
|
252
|
+
- **Mentions inside a `Repeater` / `Builder` row** need the form-state URL stamper to recognize the row-relative dotted path. The framework handles `items.0.body` (Repeater) and `blocks.0.data.body` (Builder) automatically; non-standard nesting needs manual `tagRichTextMentionUrls` walker extension.
|
|
253
|
+
- **`storage('html')` loses some node-level attrs on round-trip.** HTML doesn't preserve the full Tiptap JSON node tree — custom block attrs survive (they serialize to `data-*` attributes) but ordering of marks in edge cases can change. Use `'json'` if perfect round-trip matters.
|
|
254
|
+
- **`@pilotiq/tiptap` peer dep** — the package declares `@pilotiq/pilotiq` as a peer with the literal range `">=0.7.0 <1.0.0"` (not `workspace:^`). Pre-1.0 caret on workspace:^ would break on every pilotiq minor bump. devDep stays on `workspace:^` for local resolution.
|
|
255
|
+
- **Tiptap module identity** — `@tiptap/core` and `@tiptap/pm` keep state on module-level singletons. Multiple copies break the editor. Add them to `resolve.dedupe` in your Vite config: `dedupe: ['@tiptap/core', '@tiptap/pm', '@tiptap/react']`.
|
|
256
|
+
|
|
257
|
+
## Key Imports
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import {
|
|
261
|
+
RichTextField, // the form field
|
|
262
|
+
Block, // custom block primitive
|
|
263
|
+
MentionProvider, // mention dropdown source
|
|
264
|
+
registerTiptap, // installs the renderer (alternative to .plugins([tiptap()]))
|
|
265
|
+
renderRichTextToHtml, // pure server-side JSON → HTML serializer
|
|
266
|
+
tiptap, // plugin factory for .plugins([])
|
|
267
|
+
} from '@pilotiq/tiptap'
|
|
268
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pilotiq-tiptap-blocks
|
|
3
|
+
description: Custom blocks, slash menu, mentions, and toolbar customization for @pilotiq/tiptap's RichTextField — the side-panel form-in-block UX plus the pitfalls that bite when you customize beyond defaults
|
|
4
|
+
license: MIT
|
|
5
|
+
appliesTo:
|
|
6
|
+
- '@pilotiq/tiptap'
|
|
7
|
+
trigger: defining `Block.make(...)` types for a `RichTextField`, wiring mentions / merge tags, customizing the toolbar, or touching slash-menu / drag-handle behavior
|
|
8
|
+
skip: just using `RichTextField.make('body').required()` with defaults — the always-loaded `boost/guidelines.md` already covers the basic surface
|
|
9
|
+
metadata:
|
|
10
|
+
author: pilotiq
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Pilotiq Tiptap Blocks
|
|
14
|
+
|
|
15
|
+
## When to use this skill
|
|
16
|
+
|
|
17
|
+
Load when you're:
|
|
18
|
+
|
|
19
|
+
- Defining `Block.make(...)` types so users can insert form-driven embeds (callouts, YouTube embeds, CTAs, media cards) into a `RichTextField`
|
|
20
|
+
- Wiring `MentionProvider`s — especially the async `itemsUsing` variant that hits the DB
|
|
21
|
+
- Customizing the toolbar layout (`toolbarButtons`, `enableToolbarButtons`, `disableToolbarButtons`) or hiding chrome (`toolbar(false)`, `floatingToolbar(false)`, `slashCommand(false)`)
|
|
22
|
+
- Debugging slash-menu keyboard behavior, drag-handle snap-back, or side-panel keyboard shortcuts (`Mod-E` / `Esc` / focus trap)
|
|
23
|
+
- Opting into the non-default block primitives — `details` (collapsible), `grid` (2/3-column), `lead` / `small` size marks
|
|
24
|
+
|
|
25
|
+
For just the basic `RichTextField.make('body').required().placeholder(...)` surface — the always-loaded `boost/guidelines.md` covers it. Server-side HTML rendering (`renderRichTextToHtml`) is in guidelines too.
|
|
26
|
+
|
|
27
|
+
## Quick Reference
|
|
28
|
+
|
|
29
|
+
| Task | Open |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Custom blocks — `Block.make().schema([…])` form-in-block embeds, side panel UX, keyboard shortcuts (`Mod-E` / `Esc`), field-type coverage inside blocks | `rules/custom-blocks.md` |
|
|
32
|
+
| Slash menu, mentions, merge tags — `MentionProvider` (static + async), `{{ merge_tag }}` chips, slash-menu groups | `rules/slash-menu-and-mentions.md` |
|
|
33
|
+
| Toolbar customization + opt-in primitives — toolbar groups, `lead` / `small` / `details` / `grid`, drag-handle gotcha, node-naming pitfalls | `rules/toolbar-and-extensibility.md` |
|
|
34
|
+
|
|
35
|
+
## Key concepts (load once)
|
|
36
|
+
|
|
37
|
+
- **A `Block` is a form embedded in a document.** `Block.make('callout').schema([TextField, …])` registers a slash-menu entry; inserting it stamps a `pilotiqBlock` node with `attrs.blockType='callout'` + `attrs.blockData={}`; clicking **Edit** (or `Mod-E` on a selected block) opens the right-docked side panel with that schema mounted as a real pilotiq form. Edits write back into `attrs.blockData` on every keystroke — no save button.
|
|
38
|
+
- **The side panel uses every pilotiq field renderer.** TagsInput / KeyValue / FileUpload (JSON-encoded), Repeater / Builder (dotted-path nested), markdown (raw source), all primitives — they all work inside a block schema because the panel snapshots `new FormData(formEl)` → `parseFormDataToNested` → per-fieldType coerce, no `FormStateProvider` mount required.
|
|
39
|
+
- **Block names are discriminators.** `Block.make('callout')` registers under that string; `BlockNodeView` and `BlockSidePanel` look up the active block by that name against `RichTextField.toMeta().blocks`. **Never name a block `'block'`** — it collides with ProseMirror's schema GROUP and breaks `contentMatchAt`. Any other name is fine; the framework's own node is `pilotiqBlock` so user names are safe.
|
|
40
|
+
- **Toolbar ids are forward-compatible.** Unknown ids in `.toolbarButtons([...])` are silently dropped — adding a new button id later won't break existing field configs. The recognized id union is documented in `boost/guidelines.md` (`Recognized button ids` block).
|
|
41
|
+
- **`Mod-E` / `Esc` / focus trap / width memory all ship.** Mod-E opens the panel for a selected block; Esc closes; Tab/Shift-Tab cycles within (soft trap — outside clicks still work); width persists to `localStorage` under `pilotiq.tiptap.sidePanel.width` clamped `[240, 600]`.
|
|
42
|
+
- **Slash menu listens capture-phase.** So does the mention menu. The side panel's Esc listener is bubble-phase + `stopPropagation` inside menus — so pressing Esc inside an open slash menu only closes the menu, not the panel.
|
|
43
|
+
- **Async mentions inside Repeater / Builder rows work out of the box.** The dispatcher parses `items.0.body` (Repeater) and `blocks.0.data.body` (Builder) dotted paths and looks the leaf up against the row template. Non-standard nesting (Repeater-inside-Repeater) needs manual `tagRichTextMentionUrls` walker extension.
|
|
44
|
+
|
|
45
|
+
## Examples
|
|
46
|
+
|
|
47
|
+
- `playground/app/Pilotiq/Articles/Schemas/ArticleForm.ts` — `RichTextField.make('body')` with `.blocks([…])`, `.mergeTags([…])`, and a couple `MentionProvider`s.
|
|
48
|
+
- `playground/app/Pilotiq/pages/BuilderDemo.ts` — heterogeneous Builder using Tiptap blocks alongside other field types.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Custom Blocks
|
|
2
|
+
|
|
3
|
+
A `Block` is a reusable, form-driven embed inside a `RichTextField` document. Users insert one via the slash menu (`/`), see an inline summary card in the editor, and edit its data in a right-docked side panel that mounts the block's schema as a real pilotiq form.
|
|
4
|
+
|
|
5
|
+
This is **the** primitive that sets `@pilotiq/tiptap` apart from a plain rich-text editor — long-form documents become composable surfaces (callouts, embeds, CTAs, structured media) without leaving the form.
|
|
6
|
+
|
|
7
|
+
## Defining a block
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Block, RichTextField } from '@pilotiq/tiptap'
|
|
11
|
+
import { TextField, TextareaField, SelectField } from '@pilotiq/pilotiq'
|
|
12
|
+
|
|
13
|
+
Block.make('callout') // discriminator — see "Naming"
|
|
14
|
+
.label('Callout') // slash-menu display label
|
|
15
|
+
.icon('💡') // emoji OR pilotiq icon registry name
|
|
16
|
+
.schema([
|
|
17
|
+
SelectField.make('variant').options({
|
|
18
|
+
info: 'Info',
|
|
19
|
+
warning: 'Warning',
|
|
20
|
+
danger: 'Danger',
|
|
21
|
+
}).default('info'),
|
|
22
|
+
TextField.make('title').required(),
|
|
23
|
+
TextareaField.make('content').required(),
|
|
24
|
+
])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Attach to a field:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
RichTextField.make('body')
|
|
31
|
+
.blocks([
|
|
32
|
+
calloutBlock,
|
|
33
|
+
youtubeBlock,
|
|
34
|
+
ctaBlock,
|
|
35
|
+
])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each block appears under its own slash-menu entry below the **Insert / Style / Format / Merge tags** built-in groups. The order in `.blocks([…])` is the order in the menu.
|
|
39
|
+
|
|
40
|
+
## Naming — pick anything but `'block'`
|
|
41
|
+
|
|
42
|
+
The block name is the discriminator stored in `attrs.blockType`. Both `BlockNodeView` and `BlockSidePanel` look up the active block against `RichTextField.toMeta().blocks` by that string.
|
|
43
|
+
|
|
44
|
+
**Never use `'block'`.** Tiptap's underlying ProseMirror schema reserves the name `'block'` as a *schema GROUP* — defining a node with that name breaks `contentMatchAt` and silently corrupts the editor's content matching. Any other identifier is safe; the framework's own node type is `pilotiqBlock` so it can't collide with user blocks.
|
|
45
|
+
|
|
46
|
+
Use kebab-case or camelCase: `'callout'`, `'youtube-embed'`, `'productCard'` all fine. Don't change a block's name after data exists in production — old documents will render through the unknown-block fallback path (a placeholder card preserving `attrs.blockData` verbatim — config rollbacks never lose content, but the user can't edit until the name is restored or the data is migrated).
|
|
47
|
+
|
|
48
|
+
## How the side panel works
|
|
49
|
+
|
|
50
|
+
When the user clicks the **Edit** button on a `BlockNodeView` (or hits `Mod-E` with a block selected):
|
|
51
|
+
|
|
52
|
+
1. `BlockNodeView` calls `extension.options.onEdit(getPos())` — the callback was plumbed in via `BlockNodeExtension.configure({ onEdit })`. Tiptap mounts NodeViews in a separate React tree, so `useContext` doesn't cross that boundary — the bridge has to ride extension options.
|
|
53
|
+
2. The host (`TiptapEditor`) opens `<BlockSidePanel>`, keyed on `pos:blockType` so swapping blocks fully remounts.
|
|
54
|
+
3. The panel renders `<FormFields elements={block.schema} values={initialBlockData} />` inside a `<form>` — same renderers pilotiq uses everywhere else.
|
|
55
|
+
4. A container-level `onChange`/`onInput` handler snapshots the **entire form**: `new FormData(formEl)` → `parseFormDataToNested(...)` (rebuilds nested arrays/objects from dotted-path inputs like `items.0.title`) → `coerceBlockValues(raw, schema)` (per-fieldType JSON parse / boolean / number coerce).
|
|
56
|
+
5. The coerced object dispatches as `state.tr.setNodeMarkup(pos, null, { blockType, blockData })` directly through the editor view — every keystroke updates the document.
|
|
57
|
+
6. The panel listens to every editor `transaction` and remaps its tracked `pos` so concurrent edits elsewhere in the doc don't desync. If the node disappears at the mapped pos (different type, or null), the panel closes itself.
|
|
58
|
+
|
|
59
|
+
## Field-type coverage inside a block
|
|
60
|
+
|
|
61
|
+
Everything in pilotiq's field catalog works inside a block. Each field serializes through hidden inputs in the form DOM, which `parseFormDataToNested` + per-fieldType coerce captures.
|
|
62
|
+
|
|
63
|
+
- **Primitives:** text, textarea, select, radio, toggleButtons, date, dateTime, email, color, number, slider, toggle, checkbox.
|
|
64
|
+
- **JSON-encoded:** tagsInput (`string[]`), checkboxList (`string[]`), keyValue (`Record<string, unknown>`), fileUpload (URL string, or `string[]` when `.multiple()`).
|
|
65
|
+
- **Plain text:** markdown (raw markdown source — server-renders via `marked` if you display it through a `Markdown` element).
|
|
66
|
+
- **Nested array fields:** repeater (array of subschema rows; each row's children coerce recursively against `field.template`) and builder (heterogeneous `{type, data}` rows; `row.data` coerces against the block matching `row.type` from `field.blocks[]`; unknown block types pass through verbatim).
|
|
67
|
+
|
|
68
|
+
`coerceBlockValues(raw, schema)` is exported from `@pilotiq/tiptap` for testing — a pure helper with no DOM, no React.
|
|
69
|
+
|
|
70
|
+
## Keyboard, focus, and width
|
|
71
|
+
|
|
72
|
+
These all ship out of the box — don't try to wire them yourself:
|
|
73
|
+
|
|
74
|
+
- **`Mod-E`** (Cmd+E on macOS / Ctrl+E elsewhere) — when the current selection is a `NodeSelection` on a `pilotiqBlock`, opens its side panel. Wired via `BlockNodeExtension.addKeyboardShortcuts()`. Returns `false` (yields to browser default) when no block is selected, so Safari's *Use Selection for Find* still works in plain text.
|
|
75
|
+
- **`Esc`** closes the panel via a bubble-phase `document` listener. Slash and mention menus listen capture-phase + `stopPropagation`, so `Esc` inside an open slash menu only closes the menu — never bubbles down to the panel.
|
|
76
|
+
- **Focus management.** On open, the previously focused element is captured and the first focusable inside the panel is focused. While the panel is mounted, `Tab` / `Shift+Tab` cycles within the panel's focusables (soft trap — clicks elsewhere still work). On close, the previously focused element is re-focused.
|
|
77
|
+
- **Width memory.** The panel has a 1-px hover-highlighted left-edge resize handle and persists its width to `localStorage` under `pilotiq.tiptap.sidePanel.width`, clamped `[240, 600]` (default 320). The pure helper `clampPanelWidth(value)` is exported for tests; it falls back to the default for `null` / `undefined` / empty-string / non-finite values, otherwise clamps numeric strings + numbers into range.
|
|
78
|
+
|
|
79
|
+
## Common authoring mistakes
|
|
80
|
+
|
|
81
|
+
- **Forgetting `.schema([…])`** — a block with no schema renders as a card with no editable fields. Always declare at least one field, even if it's just `TextField.make('content')`.
|
|
82
|
+
- **Reusing field names across blocks.** Each block has its own isolated schema — `Block.make('a').schema([TextField.make('title')])` and `Block.make('b').schema([TextField.make('title')])` are completely independent. Inside a single block, `name` collisions break the form like anywhere else.
|
|
83
|
+
- **Mutating `blockData` directly from outside the side panel** — don't. The panel is the single writer (via `setNodeMarkup`). If you need to programmatically update a block's data, dispatch a Tiptap transaction.
|
|
84
|
+
- **Async work inside a field's `live()` hook in a block.** It works, but every keystroke fires a partial-resolve to the panel's enclosing form `stateUrl`. Heavy DB queries should debounce (`.live({ debounce: 300 })`). The panel re-mounts the field on the response — keep the round-trip fast.
|
|
85
|
+
|
|
86
|
+
## See also
|
|
87
|
+
|
|
88
|
+
- `slash-menu-and-mentions.md` — how blocks appear in the slash menu alongside built-ins, mentions, and merge tags.
|
|
89
|
+
- `toolbar-and-extensibility.md` — toolbar customization, drag-handle gotcha, opt-in primitives (`details`, `grid`, `lead` / `small`).
|
|
90
|
+
- Pilotiq side: [[pilotiq-fields]] (`pilotiq-fields/rules/field-catalog.md`) for the inner-field types you can use inside `Block.schema([…])`.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Slash Menu, Mentions, and Merge Tags
|
|
2
|
+
|
|
3
|
+
Three closely-related surfaces share the same editor real estate — the cursor-anchored popover menu. Slash menu opens on `/`; mention dropdowns open on a configurable trigger char (`@`, `#`, etc.); merge tags surface as a *group within* the slash menu rather than their own dropdown.
|
|
4
|
+
|
|
5
|
+
## Slash menu (`/`)
|
|
6
|
+
|
|
7
|
+
Type `/` at the start of a line (or in an otherwise-empty inline position) to open the slash menu. The menu groups, in order:
|
|
8
|
+
|
|
9
|
+
1. **Insert** — paragraph, headings, lists, code block, blockquote, horizontal rule, plus opt-in primitives (`details`, `Two-column grid`, `Three-column grid`) when enabled in toolbar/slash config.
|
|
10
|
+
2. **Style** — text-size marks (`lead`, `small`), text color, highlight (when those buttons are in the active toolbar).
|
|
11
|
+
3. **Format** — alignment options (when alignment buttons are in the toolbar).
|
|
12
|
+
4. **Merge tags** — every id in `.mergeTags([…])`. Selecting one inserts a `{{ id }}` chip.
|
|
13
|
+
5. **User blocks** — one entry per `Block` in `.blocks([…])`, in declaration order. Each shows the block's `.icon(…)` + `.label(…)`.
|
|
14
|
+
|
|
15
|
+
Disable the slash menu per-field:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
RichTextField.make('body').slashCommand(false)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Slash menu internals (relevant when debugging)
|
|
22
|
+
|
|
23
|
+
- `SlashCommandExtension.ts` registers a Tiptap `Suggestion` plugin that listens on document-level **capture-phase** key events. Capture-phase is intentional — it has to win against any bubble-phase handlers in surrounding code (including the side panel's `Esc` listener — see `custom-blocks.md` § Keyboard).
|
|
24
|
+
- The menu mounts as a Base UI Popover anchored to a virtual element at the caret position. Cursor anchoring means the menu follows the user's typing position even mid-line, but it also means autoplacement / flip logic can collide with other Base UI popovers — see [[feedback-baseui-popover-cursor-anchor]] for the established pattern.
|
|
25
|
+
- Items derive from `extension.options.blocks` plus the framework's built-ins. The blocks array is plumbed at editor mount and updated when the field's meta changes.
|
|
26
|
+
|
|
27
|
+
## Mentions (`@`, `#`, custom triggers)
|
|
28
|
+
|
|
29
|
+
`MentionProvider` powers @-mention dropdowns. Each provider has a single trigger char and a static or async item resolver:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { RichTextField, MentionProvider } from '@pilotiq/tiptap'
|
|
33
|
+
|
|
34
|
+
RichTextField.make('body')
|
|
35
|
+
.mentions([
|
|
36
|
+
MentionProvider.make('@')
|
|
37
|
+
.items([
|
|
38
|
+
{ id: 'alice', label: 'Alice', subtitle: 'Engineering' },
|
|
39
|
+
{ id: 'bob', label: 'Bob', subtitle: 'Design' },
|
|
40
|
+
]),
|
|
41
|
+
|
|
42
|
+
MentionProvider.make('#')
|
|
43
|
+
.itemsUsing(async (query, ctx) => {
|
|
44
|
+
const tags = await Tag.query()
|
|
45
|
+
.where('name', 'LIKE', `%${query}%`)
|
|
46
|
+
.paginate(1, 10)
|
|
47
|
+
return tags.data.map(t => ({ id: t.slug, label: t.name }))
|
|
48
|
+
}),
|
|
49
|
+
])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Item shape
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
{
|
|
56
|
+
id: string // required — what serializes in the chip
|
|
57
|
+
label: string // required — what the user sees
|
|
58
|
+
subtitle?: string // optional — second line in the dropdown
|
|
59
|
+
icon?: string // optional — pilotiq icon registry name
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The chip serializes to HTML as `@alice` / `#performance` text (the literal trigger + id) and to JSON as a node attr — your render-time substitution layer chooses how to resolve the chip (link, badge, lookup, etc).
|
|
64
|
+
|
|
65
|
+
### Static vs async
|
|
66
|
+
|
|
67
|
+
- **`.items([…])`** — synchronous, renders immediately when the trigger char is typed. Best for small, static sets (status labels, sentiment markers).
|
|
68
|
+
- **`.itemsUsing(async (query, ctx) => …)`** — POSTs to a per-form `_mentions/:provider` endpoint with the typed query string and the form's render context. Async resolvers see `ctx.user`, `ctx.record`, `ctx.parent` (when inside a `Repeater` / `Builder` row), so you can scope results to the current panel/record/row.
|
|
69
|
+
|
|
70
|
+
Async items go through `pageData.findRichTextFieldByName(form, dottedName)` to resolve which `MentionProvider` matches the request. The dotted path the editor posts is row-relative when the field is inside an array row (`items.0.body` for Repeater, `blocks.0.data.body` for Builder). The dispatcher walks the appropriate template — both shapes are first-class.
|
|
71
|
+
|
|
72
|
+
### Mentions inside Repeater / Builder rows
|
|
73
|
+
|
|
74
|
+
Works out of the box for the standard nesting. The stamper (`tagRichTextMentionUrls`) walks Builder block schemas explicitly because `BuilderField.getChildren()` returns `undefined` to keep generic field walkers from treating heterogeneous rows as flat children — see [[project-pilotiq-builder]] for the broader walker pattern.
|
|
75
|
+
|
|
76
|
+
**Non-standard nesting** (Repeater-inside-Repeater, custom container) needs a manual `tagRichTextMentionUrls` walker extension — there's no automatic depth recursion yet. Confirm a request works end-to-end by submitting a mention from inside the nested row and checking the network panel for a 200 on the `_mentions/:provider` endpoint.
|
|
77
|
+
|
|
78
|
+
## Merge tags (`{{ id }}`)
|
|
79
|
+
|
|
80
|
+
For server-side substitution placeholders — typically email templates, document templates, transactional content where you write text like `Hi {{ firstName }}` and the rendering layer substitutes at send time:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
RichTextField.make('body')
|
|
84
|
+
.mergeTags(['firstName', 'lastName', 'company', 'unsubscribeUrl'])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each id surfaces under the **Merge tags** group of the slash menu and inserts a `{{ id }}` chip. The chip serializes verbatim as `{{ firstName }}` literal text in HTML output — your downstream substitution (mail send, doc generation) handles the actual replacement.
|
|
88
|
+
|
|
89
|
+
Difference from mentions: merge tags are a fixed set known at form-definition time, no DB lookup, no user search. Use mentions when the set is dynamic (users, tags, records); use merge tags when it's a closed enum of template variables.
|
|
90
|
+
|
|
91
|
+
## Pitfalls
|
|
92
|
+
|
|
93
|
+
- **Async mention failures** — if `itemsUsing` throws, the dropdown shows an empty result silently. Wrap risky DB calls in try/catch and return `[]` on error if you'd rather not surface server logs to the user; or let the request 500 and the dropdown will degrade gracefully.
|
|
94
|
+
- **Trigger char collision** — `@` and `#` are common; `:` collides with emoji shortcodes if you also wire those upstream. Pick something unambiguous when adding custom triggers (`+` for assignees, `&` for accounts).
|
|
95
|
+
- **`mentions([…])` resets on every meta resolve** — providers are recreated server-side every time the form re-resolves. Don't store mutable state inside a `MentionProvider` instance; treat it as a value object.
|
|
96
|
+
- **Capture-phase keys.** If you mount your own keyboard handler near the editor and it doesn't fire when the slash or mention menu is open, that's by design — the menus stop propagation on capture. To handle keys that pass through, listen on the editor's `view.dom` directly rather than at `document`.
|
|
97
|
+
|
|
98
|
+
## See also
|
|
99
|
+
|
|
100
|
+
- `custom-blocks.md` — how `Block.make(...)` entries land in the slash menu's user-blocks section.
|
|
101
|
+
- `toolbar-and-extensibility.md` — how toolbar config affects which Style / Format groups appear in the slash menu (slash entries are derived from active toolbar buttons).
|