@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
package/src/PlainTextEditor.ts
DELETED
|
@@ -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
|
-
})
|