@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,412 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { readBlockFieldValue, coerceBlockValues, clampPanelWidth } from './BlockSidePanel.js'
4
- import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
5
-
6
- describe('readBlockFieldValue', () => {
7
- it('passes strings through for text fields', () => {
8
- const target = { type: 'text', value: 'hello world' }
9
- const result = readBlockFieldValue(target, { fieldType: 'text' })
10
- assert.equal(result, 'hello world')
11
- })
12
-
13
- it('passes strings through for textarea fields', () => {
14
- const target = { type: 'textarea', value: 'multi\nline' }
15
- const result = readBlockFieldValue(target, { fieldType: 'textarea' })
16
- assert.equal(result, 'multi\nline')
17
- })
18
-
19
- it('treats toggle as a boolean from `checked`', () => {
20
- assert.equal(readBlockFieldValue({ value: 'on', checked: true }, { fieldType: 'toggle' }), true)
21
- assert.equal(readBlockFieldValue({ value: 'off', checked: false }, { fieldType: 'toggle' }), false)
22
- })
23
-
24
- it('treats checkbox as a boolean from `checked`', () => {
25
- assert.equal(readBlockFieldValue({ value: 'on', checked: true }, { fieldType: 'checkbox' }), true)
26
- assert.equal(readBlockFieldValue({ value: 'off', checked: false }, { fieldType: 'checkbox' }), false)
27
- })
28
-
29
- it('coerces number fields to a Number, null when empty', () => {
30
- assert.equal(readBlockFieldValue({ value: '42', type: 'number' }, { fieldType: 'number' }), 42)
31
- assert.equal(readBlockFieldValue({ value: '3.14', type: 'number' }, { fieldType: 'number' }), 3.14)
32
- assert.equal(readBlockFieldValue({ value: '', type: 'number' }, { fieldType: 'number' }), null)
33
- })
34
-
35
- it('keeps slider values as Number too', () => {
36
- assert.equal(readBlockFieldValue({ value: '7' }, { fieldType: 'slider' }), 7)
37
- })
38
-
39
- it('falls back to raw string when number parse fails (NaN guard)', () => {
40
- // Browsers normally clamp invalid numeric input to '', but defensively
41
- // we must not silently emit NaN — round-trips into `JSON.stringify`
42
- // become `null`, and the next reload would lose the value.
43
- assert.equal(readBlockFieldValue({ value: 'abc' }, { fieldType: 'number' }), 'abc')
44
- })
45
-
46
- it('treats unknown fieldTypes as plain string', () => {
47
- assert.equal(readBlockFieldValue({ value: 'whatever' }, { fieldType: 'futuristic' }), 'whatever')
48
- assert.equal(readBlockFieldValue({ value: 'no type' }, {}), 'no type')
49
- })
50
- })
51
-
52
- describe('coerceBlockValues — flat fields', () => {
53
- it('passes plain strings through for text / textarea / select', () => {
54
- const result = coerceBlockValues(
55
- { title: 'hello', body: 'multi\nline', size: 'lg' },
56
- [
57
- { name: 'title', fieldType: 'text' },
58
- { name: 'body', fieldType: 'textarea' },
59
- { name: 'size', fieldType: 'select' },
60
- ],
61
- )
62
- assert.deepEqual(result, { title: 'hello', body: 'multi\nline', size: 'lg' })
63
- })
64
-
65
- it('coerces toggle / checkbox `true` / `false` strings to booleans', () => {
66
- const result = coerceBlockValues(
67
- { active: 'true', subscribe: 'false' },
68
- [
69
- { name: 'active', fieldType: 'toggle' },
70
- { name: 'subscribe', fieldType: 'checkbox' },
71
- ],
72
- )
73
- assert.equal(result['active'], true)
74
- assert.equal(result['subscribe'], false)
75
- })
76
-
77
- it('coerces number / slider, null on empty, raw string on NaN', () => {
78
- const result = coerceBlockValues(
79
- { count: '42', dial: '7', empty: '', bad: 'abc' },
80
- [
81
- { name: 'count', fieldType: 'number' },
82
- { name: 'dial', fieldType: 'slider' },
83
- { name: 'empty', fieldType: 'number' },
84
- { name: 'bad', fieldType: 'number' },
85
- ],
86
- )
87
- assert.equal(result['count'], 42)
88
- assert.equal(result['dial'], 7)
89
- assert.equal(result['empty'], null)
90
- assert.equal(result['bad'], 'abc')
91
- })
92
- })
93
-
94
- describe('coerceBlockValues — deferred V1 field types', () => {
95
- it('parses tagsInput JSON-encoded hidden input back to string[]', () => {
96
- const result = coerceBlockValues(
97
- { tags: '["alpha","beta"]', empty: '' },
98
- [
99
- { name: 'tags', fieldType: 'tagsInput' },
100
- { name: 'empty', fieldType: 'tagsInput' },
101
- ],
102
- )
103
- assert.deepEqual(result['tags'], ['alpha', 'beta'])
104
- assert.deepEqual(result['empty'], [])
105
- })
106
-
107
- it('returns [] for tagsInput when JSON parse fails (defensive)', () => {
108
- const result = coerceBlockValues(
109
- { tags: 'not json' },
110
- [{ name: 'tags', fieldType: 'tagsInput' }],
111
- )
112
- assert.deepEqual(result['tags'], [])
113
- })
114
-
115
- it('parses keyValue JSON-encoded hidden input back to object', () => {
116
- const result = coerceBlockValues(
117
- { meta: '{"author":"sue","year":2026}' },
118
- [{ name: 'meta', fieldType: 'keyValue' }],
119
- )
120
- assert.deepEqual(result['meta'], { author: 'sue', year: 2026 })
121
- })
122
-
123
- it('returns {} for keyValue when JSON parse fails or value is array', () => {
124
- const r1 = coerceBlockValues({ m: 'bad' }, [{ name: 'm', fieldType: 'keyValue' }])
125
- assert.deepEqual(r1['m'], {})
126
- const r2 = coerceBlockValues({ m: '["a","b"]' }, [{ name: 'm', fieldType: 'keyValue' }])
127
- assert.deepEqual(r2['m'], {})
128
- })
129
-
130
- it('passes single-file fileUpload URL through as a string', () => {
131
- const result = coerceBlockValues(
132
- { hero: '/uploads/cover.png' },
133
- [{ name: 'hero', fieldType: 'fileUpload' }],
134
- )
135
- assert.equal(result['hero'], '/uploads/cover.png')
136
- })
137
-
138
- it('parses multi-file fileUpload JSON-encoded array', () => {
139
- const result = coerceBlockValues(
140
- { gallery: '["/a.png","/b.png"]' },
141
- [{ name: 'gallery', fieldType: 'fileUpload', multiple: true }],
142
- )
143
- assert.deepEqual(result['gallery'], ['/a.png', '/b.png'])
144
- })
145
-
146
- it('passes markdown textarea through as a plain string', () => {
147
- const result = coerceBlockValues(
148
- { body: '# Heading\n\nSome **bold** text.' },
149
- [{ name: 'body', fieldType: 'markdown' }],
150
- )
151
- assert.equal(result['body'], '# Heading\n\nSome **bold** text.')
152
- })
153
-
154
- it('parses checkboxList JSON-encoded hidden input back to string[]', () => {
155
- const result = coerceBlockValues(
156
- { roles: '["admin","editor"]' },
157
- [{ name: 'roles', fieldType: 'checkboxList' }],
158
- )
159
- assert.deepEqual(result['roles'], ['admin', 'editor'])
160
- })
161
- })
162
-
163
- describe('coerceBlockValues — Repeater rows', () => {
164
- it('coerces each row against the field template recursively', () => {
165
- const result = coerceBlockValues(
166
- {
167
- items: [
168
- { title: 'first', qty: '3', enabled: 'true', tags: '["x","y"]' },
169
- { title: 'second', qty: '', enabled: 'false', tags: '[]' },
170
- ],
171
- },
172
- [
173
- {
174
- name: 'items',
175
- fieldType:'repeater',
176
- template: [
177
- { name: 'title', fieldType: 'text' },
178
- { name: 'qty', fieldType: 'number' },
179
- { name: 'enabled', fieldType: 'toggle' },
180
- { name: 'tags', fieldType: 'tagsInput' },
181
- ],
182
- },
183
- ],
184
- )
185
- assert.deepEqual(result['items'], [
186
- { title: 'first', qty: 3, enabled: true, tags: ['x', 'y'] },
187
- { title: 'second', qty: null, enabled: false, tags: [] },
188
- ])
189
- })
190
-
191
- it('returns [] for a repeater whose value is missing or non-array', () => {
192
- const result = coerceBlockValues(
193
- { items: undefined as unknown },
194
- [{ name: 'items', fieldType: 'repeater', template: [] }],
195
- )
196
- assert.deepEqual(result['items'], [])
197
- })
198
-
199
- it('handles a Repeater nested inside another Repeater', () => {
200
- const result = coerceBlockValues(
201
- {
202
- sections: [
203
- {
204
- heading: 'A',
205
- rows: [
206
- { label: 'r1', n: '1' },
207
- { label: 'r2', n: '2' },
208
- ],
209
- },
210
- ],
211
- },
212
- [
213
- {
214
- name: 'sections',
215
- fieldType: 'repeater',
216
- template: [
217
- { name: 'heading', fieldType: 'text' },
218
- {
219
- name: 'rows',
220
- fieldType: 'repeater',
221
- template: [
222
- { name: 'label', fieldType: 'text' },
223
- { name: 'n', fieldType: 'number' },
224
- ],
225
- },
226
- ],
227
- },
228
- ],
229
- )
230
- assert.deepEqual(result['sections'], [
231
- {
232
- heading: 'A',
233
- rows: [
234
- { label: 'r1', n: 1 },
235
- { label: 'r2', n: 2 },
236
- ],
237
- },
238
- ])
239
- })
240
- })
241
-
242
- describe('coerceBlockValues — Builder rows', () => {
243
- it('coerces each row.data against the matching block template', () => {
244
- const result = coerceBlockValues(
245
- {
246
- content: [
247
- { type: 'paragraph', data: { text: 'hello', live: 'true' } },
248
- { type: 'image', data: { src: '/a.png', width: '320' } },
249
- ],
250
- },
251
- [
252
- {
253
- name: 'content',
254
- fieldType: 'builder',
255
- blocks: [
256
- {
257
- name: 'paragraph',
258
- template: [
259
- { name: 'text', fieldType: 'textarea' },
260
- { name: 'live', fieldType: 'toggle' },
261
- ],
262
- },
263
- {
264
- name: 'image',
265
- template: [
266
- { name: 'src', fieldType: 'text' },
267
- { name: 'width', fieldType: 'number' },
268
- ],
269
- },
270
- ],
271
- },
272
- ],
273
- )
274
- assert.deepEqual(result['content'], [
275
- { type: 'paragraph', data: { text: 'hello', live: true } },
276
- { type: 'image', data: { src: '/a.png', width: 320 } },
277
- ])
278
- })
279
-
280
- it('passes unknown Builder block types through verbatim (no schema match)', () => {
281
- const result = coerceBlockValues(
282
- {
283
- content: [
284
- { type: 'unknown', data: { foo: 'bar' } },
285
- ],
286
- },
287
- [{
288
- name: 'content',
289
- fieldType: 'builder',
290
- blocks: [{ name: 'paragraph', template: [] }],
291
- }],
292
- )
293
- // Unknown type: data is preserved as-is, no coercion attempted.
294
- assert.deepEqual(result['content'], [
295
- { type: 'unknown', data: { foo: 'bar' } },
296
- ])
297
- })
298
- })
299
-
300
- describe('BlockNodeExtension options', () => {
301
- it('defaults onEdit to undefined when not configured', () => {
302
- const ext = BlockNodeExtension
303
- // `addOptions` returns the defaults; not exercising the full Tiptap
304
- // configure pipeline here because that requires a live editor.
305
- const defaults = ext.config.addOptions?.call({ name: 'pilotiqBlock', parent: undefined })
306
- assert.deepEqual(defaults, { blocks: [] })
307
- assert.equal((defaults as { onEdit?: unknown } | undefined)?.onEdit, undefined)
308
- })
309
-
310
- it('configure({ onEdit }) round-trips through Tiptap options', () => {
311
- const seen: number[] = []
312
- const onEdit = (p: number): void => { seen.push(p) }
313
- const configured = BlockNodeExtension.configure({ blocks: [], onEdit })
314
- // Tiptap stores configured options on `.options` after configure().
315
- const opts = configured.options as { blocks: unknown[]; onEdit?: (p: number) => void }
316
- assert.equal(typeof opts.onEdit, 'function')
317
- opts.onEdit?.(5)
318
- opts.onEdit?.(11)
319
- assert.deepEqual(seen, [5, 11])
320
- })
321
-
322
- it('configure without onEdit leaves it undefined', () => {
323
- const configured = BlockNodeExtension.configure({ blocks: [] })
324
- const opts = configured.options as { blocks: unknown[]; onEdit?: (p: number) => void }
325
- assert.equal(opts.onEdit, undefined)
326
- })
327
-
328
- it('addKeyboardShortcuts exposes Mod-e', () => {
329
- // The shortcut handler closes over `this.editor` and `this.options` —
330
- // we don't need the live editor to verify the binding key is wired.
331
- const ctx = {
332
- editor: { state: { selection: {} } },
333
- options: { blocks: [] },
334
- name: 'pilotiqBlock',
335
- } as unknown as ThisParameterType<NonNullable<typeof BlockNodeExtension.config.addKeyboardShortcuts>>
336
- const shortcuts = BlockNodeExtension.config.addKeyboardShortcuts?.call(ctx)
337
- assert.ok(shortcuts && typeof shortcuts === 'object')
338
- assert.equal(typeof (shortcuts as { 'Mod-e'?: unknown })['Mod-e'], 'function')
339
- })
340
-
341
- it('Mod-e returns false when onEdit is unset (no host listening)', () => {
342
- const ctx = {
343
- editor: { state: { selection: { node: { type: { name: 'pilotiqBlock' } }, from: 7 } } },
344
- options: { blocks: [] },
345
- name: 'pilotiqBlock',
346
- } as unknown as ThisParameterType<NonNullable<typeof BlockNodeExtension.config.addKeyboardShortcuts>>
347
- const shortcuts = BlockNodeExtension.config.addKeyboardShortcuts?.call(ctx) as Record<string, () => boolean>
348
- assert.equal(shortcuts['Mod-e']?.(), false)
349
- })
350
-
351
- it('Mod-e returns false when the selection is not a block NodeSelection', () => {
352
- const calls: number[] = []
353
- const ctx = {
354
- editor: { state: { selection: { from: 3, to: 3 } } },
355
- options: { blocks: [], onEdit: (p: number) => calls.push(p) },
356
- name: 'pilotiqBlock',
357
- } as unknown as ThisParameterType<NonNullable<typeof BlockNodeExtension.config.addKeyboardShortcuts>>
358
- const shortcuts = BlockNodeExtension.config.addKeyboardShortcuts?.call(ctx) as Record<string, () => boolean>
359
- assert.equal(shortcuts['Mod-e']?.(), false)
360
- assert.deepEqual(calls, [])
361
- })
362
-
363
- it('Mod-e calls onEdit(pos) and returns true when a block is NodeSelected', () => {
364
- const calls: number[] = []
365
- const ctx = {
366
- editor: { state: { selection: { node: { type: { name: 'pilotiqBlock' } }, from: 12 } } },
367
- options: { blocks: [], onEdit: (p: number) => calls.push(p) },
368
- name: 'pilotiqBlock',
369
- } as unknown as ThisParameterType<NonNullable<typeof BlockNodeExtension.config.addKeyboardShortcuts>>
370
- const shortcuts = BlockNodeExtension.config.addKeyboardShortcuts?.call(ctx) as Record<string, () => boolean>
371
- assert.equal(shortcuts['Mod-e']?.(), true)
372
- assert.deepEqual(calls, [12])
373
- })
374
- })
375
-
376
- describe('clampPanelWidth', () => {
377
- it('falls back to the default for non-finite values', () => {
378
- assert.equal(clampPanelWidth(NaN), 320)
379
- assert.equal(clampPanelWidth(Infinity), 320)
380
- assert.equal(clampPanelWidth(-Infinity), 320)
381
- assert.equal(clampPanelWidth(undefined), 320)
382
- assert.equal(clampPanelWidth(null), 320)
383
- assert.equal(clampPanelWidth(''), 320)
384
- assert.equal(clampPanelWidth('garbage'), 320)
385
- })
386
-
387
- it('clamps below the minimum up to 240', () => {
388
- assert.equal(clampPanelWidth(0), 240)
389
- assert.equal(clampPanelWidth(-50), 240)
390
- assert.equal(clampPanelWidth(239), 240)
391
- })
392
-
393
- it('clamps above the maximum down to 600', () => {
394
- assert.equal(clampPanelWidth(601), 600)
395
- assert.equal(clampPanelWidth(2000), 600)
396
- assert.equal(clampPanelWidth(99_999), 600)
397
- })
398
-
399
- it('passes finite values inside the range through unchanged', () => {
400
- assert.equal(clampPanelWidth(240), 240)
401
- assert.equal(clampPanelWidth(320), 320)
402
- assert.equal(clampPanelWidth(450), 450)
403
- assert.equal(clampPanelWidth(600), 600)
404
- })
405
-
406
- it('parses numeric strings (localStorage round-trip)', () => {
407
- assert.equal(clampPanelWidth('320'), 320)
408
- assert.equal(clampPanelWidth('450'), 450)
409
- assert.equal(clampPanelWidth('900'), 600)
410
- assert.equal(clampPanelWidth('100'), 240)
411
- })
412
- })