@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/package.json +4 -3
  8. package/src/Block.ts +0 -75
  9. package/src/MentionProvider.ts +0 -153
  10. package/src/PlainTextEditor.dom.test.ts +0 -111
  11. package/src/PlainTextEditor.test.ts +0 -158
  12. package/src/PlainTextEditor.ts +0 -229
  13. package/src/RichTextField.test.ts +0 -447
  14. package/src/RichTextField.ts +0 -508
  15. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  16. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  17. package/src/extensions/AiSuggestionExtension.ts +0 -522
  18. package/src/extensions/BlockNodeExtension.ts +0 -134
  19. package/src/extensions/DragHandleExtension.ts +0 -184
  20. package/src/extensions/GridExtension.test.ts +0 -31
  21. package/src/extensions/GridExtension.ts +0 -138
  22. package/src/extensions/MentionExtension.ts +0 -248
  23. package/src/extensions/MergeTagExtension.ts +0 -75
  24. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  25. package/src/extensions/SlashCommandExtension.ts +0 -332
  26. package/src/extensions/TextSizeMarks.ts +0 -73
  27. package/src/index.ts +0 -62
  28. package/src/markdownExtension.ts +0 -19
  29. package/src/markdownStorage.ts +0 -49
  30. package/src/plugin.test.ts +0 -19
  31. package/src/plugin.ts +0 -26
  32. package/src/react/AiSuggestionBanner.tsx +0 -185
  33. package/src/react/BlockNodeView.tsx +0 -99
  34. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  35. package/src/react/BlockSidePanel.test.ts +0 -412
  36. package/src/react/BlockSidePanel.tsx +0 -451
  37. package/src/react/CollabTextRenderer.tsx +0 -228
  38. package/src/react/FloatingToolbar.tsx +0 -304
  39. package/src/react/MarkdownEditor.tsx +0 -603
  40. package/src/react/MentionMenu.tsx +0 -120
  41. package/src/react/Palette.tsx +0 -86
  42. package/src/react/SlashMenu.tsx +0 -129
  43. package/src/react/TableFloatingToolbar.tsx +0 -154
  44. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  45. package/src/react/TiptapEditor.tsx +0 -777
  46. package/src/react/Toolbar.tsx +0 -438
  47. package/src/react/toolbarButtons.tsx +0 -579
  48. package/src/react/useAiInlineDiff.ts +0 -342
  49. package/src/react/useAiSuggestionBridge.ts +0 -223
  50. package/src/register.test.ts +0 -14
  51. package/src/register.ts +0 -42
  52. package/src/render.test.ts +0 -745
  53. package/src/render.ts +0 -480
  54. package/src/surgicalOps.ts +0 -205
  55. package/src/test/setup.ts +0 -64
@@ -1,229 +0,0 @@
1
- /**
2
- * Plain-text editor factory — Tiptap editor config tuned to behave like a
3
- * native `<input>` (single-line) or `<textarea>` (multi-line), with no marks
4
- * and a Document schema restricted to paragraph(s) of inline text.
5
- *
6
- * Built for `@pilotiq/pilotiq`'s collab-text-field path: when collab is on,
7
- * the renderer mounts a Tiptap editor instead of a native input so y-prosemirror
8
- * can anchor selections to Yjs `RelativePosition` items (positional identity).
9
- * This avoids the cursor-jump + concurrent-insert races inherent to the
10
- * `Y.Text` + manual `computeDelta` + heuristic `preserveCursor` path.
11
- *
12
- * Pure config — no React. Caller passes the returned object to `useEditor` or
13
- * `new Editor(...)`. Caller is also responsible for passing in the collab
14
- * extension list (typically `Collaboration` + `CollaborationCursor` from the
15
- * pilotiq collab adapter); we never import `@tiptap/extension-collaboration`
16
- * directly so the open-core package stays free of collab peer deps.
17
- */
18
- import {
19
- Node,
20
- Extension,
21
- mergeAttributes,
22
- type AnyExtension,
23
- type EditorOptions,
24
- type Editor,
25
- } from '@tiptap/core'
26
- import Placeholder from '@tiptap/extension-placeholder'
27
-
28
- /** Block separator used by `getText` — newline matches `<textarea>.value`. */
29
- const BLOCK_SEPARATOR = '\n'
30
-
31
- /**
32
- * Bare paragraph block — the only block the plain-text schema permits.
33
- * No options, no input rules, no toggles. `inline*` content lets any inline
34
- * node (today just `text`) appear inside.
35
- */
36
- const PlainTextParagraph = Node.create({
37
- name: 'paragraph',
38
- group: 'block',
39
- content: 'inline*',
40
- priority: 1000,
41
- parseHTML() {
42
- return [{ tag: 'p' }]
43
- },
44
- renderHTML({ HTMLAttributes }) {
45
- return ['p', mergeAttributes(HTMLAttributes), 0]
46
- },
47
- })
48
-
49
- /** The text node — Tiptap requires this to be defined explicitly. */
50
- const PlainTextText = Node.create({
51
- name: 'text',
52
- group: 'inline',
53
- })
54
-
55
- /**
56
- * Build a Document node with content restricted to either a single paragraph
57
- * (single-line mode) or one-or-more paragraphs (multi-line mode). The schema
58
- * itself blocks paste of incompatible content — ProseMirror will coerce or
59
- * reject non-matching nodes at parse time.
60
- */
61
- function makePlainTextDocument(multiline: boolean) {
62
- return Node.create({
63
- name: 'doc',
64
- topNode: true,
65
- content: multiline ? 'paragraph+' : 'paragraph',
66
- })
67
- }
68
-
69
- /**
70
- * Single-line Enter handler. Tiptap's default `Enter` keymap splits the
71
- * paragraph — meaningless when the schema only allows exactly one — so we
72
- * intercept and either delegate to `onSubmit` (caller-supplied) or blur.
73
- *
74
- * Filament's plain-text fields blur on Enter; matching that default.
75
- */
76
- function makeSingleLineKeymap(onSubmit: ((editor: Editor) => boolean | void) | undefined) {
77
- return Extension.create({
78
- name: 'plainTextSingleLineKeymap',
79
- addKeyboardShortcuts() {
80
- const handleEnter = (): boolean => {
81
- const handled = onSubmit?.(this.editor)
82
- if (handled === true) return true
83
- this.editor.commands.blur()
84
- return true
85
- }
86
- return {
87
- Enter: handleEnter,
88
- 'Mod-Enter': () => true,
89
- 'Shift-Enter': () => true,
90
- }
91
- },
92
- })
93
- }
94
-
95
- export interface PlainTextEditorOptions {
96
- /** If true, allow multiple paragraphs (textarea-like). Default `false` (input-like). */
97
- multiline?: boolean
98
- /** Placeholder text shown while the editor is empty. */
99
- placeholder?: string
100
- /** Editable / disabled state. Default `true`. */
101
- editable?: boolean
102
- /**
103
- * Initial textual content. Ignored when a Collaboration extension is passed
104
- * via `extensions` — Collaboration takes ownership of the document and seeds
105
- * from the Yjs fragment instead. Use the caller's own first-load seed (see
106
- * `@pilotiq/tiptap` README) when collab is on.
107
- */
108
- content?: string
109
- /**
110
- * Extra extensions to merge into the editor — typically the Collaboration +
111
- * CollaborationCursor pair from the pilotiq collab adapter. Pass `[]` (or
112
- * omit) for the non-collab path.
113
- */
114
- extensions?: AnyExtension[]
115
- /**
116
- * Called on every editor update with the editor's plain-text value (blocks
117
- * joined by `'\n'`). Use this to mirror the value into form-state for
118
- * submission via a hidden `<input>`.
119
- */
120
- onUpdate?: (text: string, editor: Editor) => void
121
- /**
122
- * Single-line Enter handler. Return `true` to suppress the default blur
123
- * behavior. When omitted, Enter simply blurs the editor.
124
- */
125
- onSubmit?: (editor: Editor) => boolean | void
126
- /**
127
- * DOM attributes for the editor's contenteditable wrapper — typically
128
- * `{ class: '…tailwind classes…' }` to style the editor like the native
129
- * `<input>` / `<textarea>` it replaces.
130
- */
131
- editorAttributes?: Record<string, string>
132
- }
133
-
134
- /**
135
- * Read the editor's current value as plain text, with paragraphs joined by
136
- * `'\n'`. Mirrors the behavior of `<textarea>.value`.
137
- */
138
- export function plainTextOf(editor: Editor): string {
139
- return editor.getText({ blockSeparator: BLOCK_SEPARATOR })
140
- }
141
-
142
- /**
143
- * Convert a plain-text string into a Tiptap JSON doc that satisfies the
144
- * plain-text schema. Multi-line input splits on `'\n'` into separate
145
- * paragraphs; single-line strips any embedded newlines into a single run.
146
- * Exported for tests — pure, no editor instance required.
147
- */
148
- export function plainTextToDoc(text: string, multiline: boolean): {
149
- type: 'doc'
150
- content: Array<{ type: 'paragraph'; content?: Array<{ type: 'text'; text: string }> }>
151
- } {
152
- if (!multiline) {
153
- const flat = text.replace(/\r?\n/g, '')
154
- return {
155
- type: 'doc',
156
- content: [
157
- flat ? { type: 'paragraph', content: [{ type: 'text', text: flat }] }
158
- : { type: 'paragraph' },
159
- ],
160
- }
161
- }
162
- const lines = text.split(/\r?\n/)
163
- return {
164
- type: 'doc',
165
- content: lines.map((line) =>
166
- line ? { type: 'paragraph', content: [{ type: 'text', text: line }] }
167
- : { type: 'paragraph' },
168
- ),
169
- }
170
- }
171
-
172
- /**
173
- * Build the Tiptap editor config for a plain-text field. Pass the returned
174
- * object to `useEditor` (React) or `new Editor(...)` (vanilla).
175
- *
176
- * The editor schema is deliberately minimal:
177
- * - `doc` → `paragraph` (single-line) or `paragraph+` (multi-line)
178
- * - `paragraph` → `inline*`
179
- * - `text` (inline)
180
- *
181
- * No marks, no input rules, no list items, no code blocks — just text. Pasted
182
- * rich content is stripped to plain text by ProseMirror's schema enforcement.
183
- */
184
- export function createPlainTextEditor(
185
- options: PlainTextEditorOptions = {},
186
- ): Partial<EditorOptions> {
187
- const {
188
- multiline = false,
189
- placeholder,
190
- editable = true,
191
- content = '',
192
- extensions = [],
193
- onUpdate,
194
- onSubmit,
195
- editorAttributes,
196
- } = options
197
-
198
- const schema: AnyExtension[] = [
199
- makePlainTextDocument(multiline),
200
- PlainTextParagraph,
201
- PlainTextText,
202
- ]
203
-
204
- const behavior: AnyExtension[] = []
205
- if (!multiline) behavior.push(makeSingleLineKeymap(onSubmit))
206
- if (placeholder !== undefined) {
207
- behavior.push(Placeholder.configure({ placeholder }))
208
- }
209
-
210
- const allExtensions: AnyExtension[] = [...schema, ...behavior, ...extensions]
211
-
212
- // When Collaboration owns the doc, an explicit `content` would race the
213
- // Yjs sync. Caller is responsible for omitting `content` in that case; we
214
- // pass it through verbatim either way.
215
- const initialContent = content ? plainTextToDoc(content, multiline) : ''
216
-
217
- const config: Partial<EditorOptions> = {
218
- editable,
219
- extensions: allExtensions,
220
- content: initialContent,
221
- }
222
- if (onUpdate) {
223
- config.onUpdate = ({ editor }) => onUpdate(plainTextOf(editor), editor)
224
- }
225
- if (editorAttributes) {
226
- config.editorProps = { attributes: editorAttributes }
227
- }
228
- return config
229
- }
@@ -1,447 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { TextField, TextareaField } from '@pilotiq/pilotiq'
4
-
5
- import {
6
- RichTextField,
7
- DEFAULT_TOOLBAR_GROUPS,
8
- DEFAULT_TEXT_COLORS,
9
- DEFAULT_HIGHLIGHT_COLORS,
10
- } from './RichTextField.js'
11
- import { Block } from './Block.js'
12
- import { MentionProvider } from './MentionProvider.js'
13
-
14
- describe('RichTextField.toMeta', () => {
15
- it('emits fieldType=richtext with empty defaults', () => {
16
- const meta = RichTextField.make('body').toMeta()
17
- assert.equal(meta.fieldType, 'richtext')
18
- assert.equal(meta.name, 'body')
19
- assert.deepEqual(meta.blocks, [])
20
- assert.equal(meta.slashCommand, true)
21
- assert.equal(meta.floatingToolbar, true)
22
- assert.equal(meta.storage, 'json')
23
- assert.deepEqual(meta.toolbarGroups, DEFAULT_TOOLBAR_GROUPS)
24
- })
25
-
26
- it('serializes blocks via Block.toMeta()', () => {
27
- const meta = RichTextField.make('body').blocks([
28
- Block.make('callout').label('Callout').icon('💡').schema([
29
- TextField.make('title'),
30
- TextareaField.make('content').required(),
31
- ]),
32
- ]).toMeta()
33
-
34
- assert.equal(meta.blocks.length, 1)
35
- const block = meta.blocks[0]!
36
- assert.equal(block.name, 'callout')
37
- assert.equal(block.label, 'Callout')
38
- assert.equal(block.icon, '💡')
39
- assert.equal(block.schema.length, 2)
40
- assert.equal(block.schema[0]!.name, 'title')
41
- assert.equal(block.schema[0]!.fieldType, 'text')
42
- assert.equal(block.schema[1]!.name, 'content')
43
- assert.equal(block.schema[1]!.fieldType, 'textarea')
44
- assert.equal(block.schema[1]!.required, true)
45
- })
46
-
47
- it('honors slashCommand(false)', () => {
48
- const meta = RichTextField.make('body').slashCommand(false).toMeta()
49
- assert.equal(meta.slashCommand, false)
50
- })
51
-
52
- it('inherits required + placeholder from base Field', () => {
53
- const meta = RichTextField.make('body')
54
- .label('Article body')
55
- .placeholder('Start writing…')
56
- .required()
57
- .toMeta()
58
-
59
- assert.equal(meta.label, 'Article body')
60
- assert.equal(meta.placeholder, 'Start writing…')
61
- assert.equal(meta.required, true)
62
- })
63
- })
64
-
65
- describe('RichTextField toolbar API', () => {
66
- it('toolbar(false) hides the top-level toolbar', () => {
67
- const meta = RichTextField.make('body').toolbar(false).toMeta()
68
- assert.equal(meta.toolbarGroups, null)
69
- })
70
-
71
- it('toolbar(true) restores the default after toolbar(false)', () => {
72
- const meta = RichTextField.make('body').toolbar(false).toolbar(true).toMeta()
73
- assert.deepEqual(meta.toolbarGroups, DEFAULT_TOOLBAR_GROUPS)
74
- })
75
-
76
- it('floatingToolbar(false) toggles the selection toolbar', () => {
77
- const meta = RichTextField.make('body').floatingToolbar(false).toMeta()
78
- assert.equal(meta.floatingToolbar, false)
79
- })
80
-
81
- it('toolbarButtons([groups]) replaces the default layout', () => {
82
- const meta = RichTextField.make('body')
83
- .toolbarButtons([
84
- ['bold', 'italic'],
85
- ['undo', 'redo'],
86
- ])
87
- .toMeta()
88
- assert.deepEqual(meta.toolbarGroups, [
89
- ['bold', 'italic'],
90
- ['undo', 'redo'],
91
- ])
92
- })
93
-
94
- it('toolbarButtons(null) hides the toolbar', () => {
95
- const meta = RichTextField.make('body').toolbarButtons(null).toMeta()
96
- assert.equal(meta.toolbarGroups, null)
97
- })
98
-
99
- it('disableToolbarButtons removes ids from every group', () => {
100
- const meta = RichTextField.make('body')
101
- .disableToolbarButtons(['italic', 'undo', 'redo'])
102
- .toMeta()
103
- const flat = (meta.toolbarGroups ?? []).flat()
104
- assert.equal(flat.includes('italic'), false)
105
- assert.equal(flat.includes('undo'), false)
106
- assert.equal(flat.includes('redo'), false)
107
- assert.equal(flat.includes('bold'), true)
108
- })
109
-
110
- it('disableToolbarButtons drops a group when it empties out', () => {
111
- const meta = RichTextField.make('body')
112
- .toolbarButtons([
113
- ['bold'],
114
- ['italic'],
115
- ])
116
- .disableToolbarButtons(['italic'])
117
- .toMeta()
118
- assert.deepEqual(meta.toolbarGroups, [['bold']])
119
- })
120
-
121
- it('enableToolbarButtons appends to the last group', () => {
122
- const meta = RichTextField.make('body')
123
- .toolbarButtons([
124
- ['bold', 'italic'],
125
- ['undo', 'redo'],
126
- ])
127
- .enableToolbarButtons(['horizontalRule'])
128
- .toMeta()
129
- assert.deepEqual(meta.toolbarGroups, [
130
- ['bold', 'italic'],
131
- ['undo', 'redo', 'horizontalRule'],
132
- ])
133
- })
134
-
135
- it('enableToolbarButtons obeys disable list', () => {
136
- const meta = RichTextField.make('body')
137
- .toolbarButtons([['bold']])
138
- .enableToolbarButtons(['italic', 'underline'])
139
- .disableToolbarButtons(['underline'])
140
- .toMeta()
141
- assert.deepEqual(meta.toolbarGroups, [['bold', 'italic']])
142
- })
143
-
144
- it('accepts lead + small in custom toolbar groups', () => {
145
- const meta = RichTextField.make('body')
146
- .toolbarButtons([
147
- ['bold', 'italic'],
148
- ['lead', 'small'],
149
- ])
150
- .toMeta()
151
- assert.deepEqual(meta.toolbarGroups, [
152
- ['bold', 'italic'],
153
- ['lead', 'small'],
154
- ])
155
- })
156
- })
157
-
158
- describe('RichTextField color palettes', () => {
159
- it('defaults to the bundled text-color palette', () => {
160
- const meta = RichTextField.make('body').toMeta()
161
- assert.deepEqual(meta.textColors, DEFAULT_TEXT_COLORS)
162
- assert.deepEqual(meta.highlightColors, DEFAULT_HIGHLIGHT_COLORS)
163
- assert.equal(meta.customTextColors, false)
164
- })
165
-
166
- it('textColors([...]) replaces the palette', () => {
167
- const palette = [
168
- { value: '#1e293b', label: 'Slate' },
169
- { value: '#dc2626', label: 'Red', dark: '#fca5a5' },
170
- ]
171
- const meta = RichTextField.make('body').textColors(palette).toMeta()
172
- assert.deepEqual(meta.textColors, palette)
173
- })
174
-
175
- it('customTextColors() opts in to the free-form picker', () => {
176
- const meta = RichTextField.make('body').customTextColors().toMeta()
177
- assert.equal(meta.customTextColors, true)
178
- })
179
-
180
- it('highlightColors([...]) replaces the highlight palette', () => {
181
- const palette = [{ value: '#fef08a', label: 'Yellow' }]
182
- const meta = RichTextField.make('body').highlightColors(palette).toMeta()
183
- assert.deepEqual(meta.highlightColors, palette)
184
- })
185
-
186
- it('passing null restores the defaults', () => {
187
- const meta = RichTextField.make('body')
188
- .textColors([{ value: '#000', label: 'Black' }])
189
- .textColors(null)
190
- .toMeta()
191
- assert.deepEqual(meta.textColors, DEFAULT_TEXT_COLORS)
192
- })
193
- })
194
-
195
- describe('RichTextField file attachments', () => {
196
- it('defaults: resizableImages=false; no attachment options; toolbar strips attachFiles when no adapter', () => {
197
- const meta = RichTextField.make('body')
198
- .toolbarButtons([['bold', 'attachFiles']])
199
- .toMeta()
200
- assert.equal(meta.resizableImages, false)
201
- assert.equal('fileAttachmentsAcceptedFileTypes' in meta, false)
202
- assert.equal('fileAttachmentsMaxSize' in meta, false)
203
- assert.equal('fileAttachmentsDirectory' in meta, false)
204
- assert.equal('fileAttachmentsVisibility' in meta, false)
205
- assert.equal('uploadUrl' in meta, false)
206
- // attachFiles stripped from the resolved groups when no adapter is wired.
207
- assert.deepEqual(meta.toolbarGroups, [['bold']])
208
- })
209
-
210
- it('preserves attachFiles + stamps uploadUrl when adapter is wired', () => {
211
- const meta = RichTextField.make('body')
212
- .toolbarButtons([['bold', 'attachFiles']])
213
- .toMeta({ uploadUrl: '/admin/_uploads', hasUploadAdapter: true })
214
- assert.deepEqual(meta.toolbarGroups, [['bold', 'attachFiles']])
215
- assert.equal(meta.uploadUrl, '/admin/_uploads')
216
- })
217
-
218
- it('drops a toolbar group entirely when attachFiles was its only button', () => {
219
- const meta = RichTextField.make('body')
220
- .toolbarButtons([['bold'], ['attachFiles']])
221
- .toMeta()
222
- assert.deepEqual(meta.toolbarGroups, [['bold']])
223
- })
224
-
225
- it('exposes resize + size + accept + directory + visibility', () => {
226
- const meta = RichTextField.make('body')
227
- .resizableImages()
228
- .fileAttachmentsAcceptedFileTypes(['image/*'])
229
- .fileAttachmentsMaxSize(2_000_000)
230
- .fileAttachmentsDirectory('articles')
231
- .fileAttachmentsVisibility('private')
232
- .toMeta()
233
- assert.equal(meta.resizableImages, true)
234
- assert.deepEqual(meta.fileAttachmentsAcceptedFileTypes, ['image/*'])
235
- assert.equal(meta.fileAttachmentsMaxSize, 2_000_000)
236
- assert.equal(meta.fileAttachmentsDirectory, 'articles')
237
- assert.equal(meta.fileAttachmentsVisibility, 'private')
238
- })
239
- })
240
-
241
- describe('RichTextField merge tags + mentions', () => {
242
- it('mergeTags + mentions default to empty arrays', () => {
243
- const meta = RichTextField.make('body').toMeta()
244
- assert.deepEqual(meta.mergeTags, [])
245
- assert.deepEqual(meta.mentions, [])
246
- })
247
-
248
- it('mergeTags([...]) round-trips through meta', () => {
249
- const meta = RichTextField.make('body')
250
- .mergeTags(['firstName', 'company'])
251
- .toMeta()
252
- assert.deepEqual(meta.mergeTags, ['firstName', 'company'])
253
- })
254
-
255
- it('mentions([...]) serializes each provider via toMeta()', () => {
256
- const meta = RichTextField.make('body')
257
- .mentions([
258
- MentionProvider.make('@').items([
259
- { id: 'sleman', label: 'Sleman' },
260
- { id: 'alex', label: 'Alex', group: 'Team' },
261
- ]),
262
- MentionProvider.make('#').items([
263
- { id: 'general', label: 'general' },
264
- ]),
265
- ])
266
- .toMeta()
267
- assert.equal(meta.mentions.length, 2)
268
- assert.equal(meta.mentions[0]!.trigger, '@')
269
- assert.equal(meta.mentions[0]!.items.length, 2)
270
- assert.equal(meta.mentions[0]!.items[0]!.id, 'sleman')
271
- assert.equal(meta.mentions[0]!.items[1]!.group, 'Team')
272
- assert.equal(meta.mentions[1]!.trigger, '#')
273
- })
274
- })
275
-
276
- describe('MentionProvider', () => {
277
- it('rejects non-single-character triggers', () => {
278
- assert.throws(() => MentionProvider.make(''), /single character/)
279
- assert.throws(() => MentionProvider.make('@@'), /single character/)
280
- })
281
-
282
- it('items() replaces the static list', () => {
283
- const p = MentionProvider.make('@').items([{ id: 'a', label: 'A' }])
284
- assert.equal(p.getTrigger(), '@')
285
- assert.equal(p.getItems().length, 1)
286
- assert.equal(p.getItems()[0]!.id, 'a')
287
- })
288
-
289
- it('toMeta() copies the items array (snapshot, not reference)', () => {
290
- const items = [{ id: 'a', label: 'A' }]
291
- const meta = MentionProvider.make('@').items(items).toMeta()
292
- items.push({ id: 'b', label: 'B' })
293
- assert.equal(meta.items.length, 1)
294
- })
295
-
296
- it('static providers report isAsync=false and emit no async flag', () => {
297
- const p = MentionProvider.make('@').items([{ id: 'a', label: 'A' }])
298
- assert.equal(p.isAsync(), false)
299
- const meta = p.toMeta()
300
- assert.equal('async' in meta, false)
301
- })
302
-
303
- it('itemsUsing(fn) flips isAsync=true and empties the inlined items', () => {
304
- const p = MentionProvider.make('@').itemsUsing(async () => [{ id: 'a', label: 'A' }])
305
- assert.equal(p.isAsync(), true)
306
- const meta = p.toMeta()
307
- assert.equal(meta.async, true)
308
- assert.deepEqual(meta.items, [])
309
- })
310
-
311
- it('runResolver runs the static list when no async fn is set', async () => {
312
- const p = MentionProvider.make('@').items([
313
- { id: 'sleman', label: 'Sleman' },
314
- { id: 'alex', label: 'Alex' },
315
- ])
316
- const items = await p.runResolver('al', { user: null })
317
- // Returns the full list — filtering is the menu's job.
318
- assert.equal(items.length, 2)
319
- })
320
-
321
- it('runResolver awaits an async resolver and forwards query + ctx', async () => {
322
- let seenQuery: string | undefined
323
- let seenUser: unknown
324
- const p = MentionProvider.make('@').itemsUsing(async (query, ctx) => {
325
- seenQuery = query
326
- seenUser = ctx.user
327
- return [{ id: query, label: `Hit: ${query}` }]
328
- })
329
- const items = await p.runResolver('alex', { user: { id: 1 } })
330
- assert.equal(seenQuery, 'alex')
331
- assert.deepEqual(seenUser, { id: 1 })
332
- assert.equal(items.length, 1)
333
- assert.equal(items[0]!.id, 'alex')
334
- assert.equal(items[0]!.label, 'Hit: alex')
335
- })
336
-
337
- it('runResolver coerces non-array returns to []', async () => {
338
- const p = MentionProvider.make('@').itemsUsing((async () => null) as never)
339
- const items = await p.runResolver('q', {})
340
- assert.deepEqual(items, [])
341
- })
342
-
343
- it('items() after itemsUsing() warns and switches to static (last call wins)', () => {
344
- const orig = console.warn
345
- const warnings: string[] = []
346
- console.warn = (...args: unknown[]) => warnings.push(String(args[0]))
347
- try {
348
- const p = MentionProvider.make('@')
349
- .itemsUsing(async () => [{ id: 'x', label: 'X' }])
350
- .items([{ id: 'a', label: 'A' }])
351
- assert.equal(p.isAsync(), false)
352
- assert.equal(warnings.length, 1)
353
- assert.match(warnings[0]!, /MentionProvider.*items\(\) called after.*itemsUsing/)
354
- } finally {
355
- console.warn = orig
356
- }
357
- })
358
-
359
- it('itemsUsing() after items() warns and clears the static list', () => {
360
- const orig = console.warn
361
- const warnings: string[] = []
362
- console.warn = (...args: unknown[]) => warnings.push(String(args[0]))
363
- try {
364
- const p = MentionProvider.make('@')
365
- .items([{ id: 'a', label: 'A' }])
366
- .itemsUsing(async () => [{ id: 'x', label: 'X' }])
367
- assert.equal(p.isAsync(), true)
368
- assert.equal(warnings.length, 1)
369
- assert.match(warnings[0]!, /MentionProvider.*itemsUsing\(\) called after.*items\(\)/)
370
- } finally {
371
- console.warn = orig
372
- }
373
- })
374
- })
375
-
376
- describe('RichTextField mention resolution', () => {
377
- it('hasAsyncMentions() is false when every provider is static', () => {
378
- const f = RichTextField.make('body').mentions([
379
- MentionProvider.make('@').items([{ id: 'a', label: 'A' }]),
380
- ])
381
- assert.equal(f.hasAsyncMentions(), false)
382
- })
383
-
384
- it('hasAsyncMentions() is true when at least one provider is async', () => {
385
- const f = RichTextField.make('body').mentions([
386
- MentionProvider.make('@').items([{ id: 'a', label: 'A' }]),
387
- MentionProvider.make('#').itemsUsing(async () => []),
388
- ])
389
- assert.equal(f.hasAsyncMentions(), true)
390
- })
391
-
392
- it('resolveMention dispatches by trigger char', async () => {
393
- const f = RichTextField.make('body').mentions([
394
- MentionProvider.make('@').itemsUsing(async (q) => [{ id: q, label: `User:${q}` }]),
395
- MentionProvider.make('#').itemsUsing(async (q) => [{ id: q, label: `Channel:${q}` }]),
396
- ])
397
- const userHits = await f.resolveMention('@', 'sleman', {})
398
- const chanHits = await f.resolveMention('#', 'general', {})
399
- assert.equal(userHits?.[0]?.label, 'User:sleman')
400
- assert.equal(chanHits?.[0]?.label, 'Channel:general')
401
- })
402
-
403
- it('resolveMention returns null for unknown triggers', async () => {
404
- const f = RichTextField.make('body').mentions([
405
- MentionProvider.make('@').itemsUsing(async () => []),
406
- ])
407
- const items = await f.resolveMention('!', 'q', {})
408
- assert.equal(items, null)
409
- })
410
-
411
- it('mentionsUrl is omitted from meta until withMentionsUrl stamps it', () => {
412
- const f = RichTextField.make('body').mentions([
413
- MentionProvider.make('@').itemsUsing(async () => []),
414
- ])
415
- assert.equal('mentionsUrl' in f.toMeta(), false)
416
- f.withMentionsUrl('/admin/articles/_form/article-form/mentions')
417
- assert.equal(f.toMeta().mentionsUrl, '/admin/articles/_form/article-form/mentions')
418
- })
419
- })
420
-
421
- describe('RichTextField storage', () => {
422
- it('defaults to json', () => {
423
- const meta = RichTextField.make('body').toMeta()
424
- assert.equal(meta.storage, 'json')
425
- })
426
-
427
- it('storage("html") opts into HTML serialization', () => {
428
- const meta = RichTextField.make('body').storage('html').toMeta()
429
- assert.equal(meta.storage, 'html')
430
- })
431
- })
432
-
433
- describe('Block.toMeta', () => {
434
- it('uses block name as label fallback', () => {
435
- const meta = Block.make('hero').toMeta()
436
- assert.equal(meta.name, 'hero')
437
- assert.equal(meta.label, 'hero')
438
- assert.equal(meta.icon, undefined)
439
- assert.deepEqual(meta.schema, [])
440
- })
441
-
442
- it('preserves icon and label when set', () => {
443
- const meta = Block.make('callout').label('Callout block').icon('💡').toMeta()
444
- assert.equal(meta.label, 'Callout block')
445
- assert.equal(meta.icon, '💡')
446
- })
447
- })