@pilotiq/tiptap 0.1.0

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/Block.d.ts +47 -0
  4. package/dist/Block.d.ts.map +1 -0
  5. package/dist/Block.js +56 -0
  6. package/dist/Block.js.map +1 -0
  7. package/dist/MentionProvider.d.ts +97 -0
  8. package/dist/MentionProvider.d.ts.map +1 -0
  9. package/dist/MentionProvider.js +104 -0
  10. package/dist/MentionProvider.js.map +1 -0
  11. package/dist/RichTextField.d.ts +286 -0
  12. package/dist/RichTextField.d.ts.map +1 -0
  13. package/dist/RichTextField.js +369 -0
  14. package/dist/RichTextField.js.map +1 -0
  15. package/dist/extensions/BlockNodeExtension.d.ts +41 -0
  16. package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
  17. package/dist/extensions/BlockNodeExtension.js +103 -0
  18. package/dist/extensions/BlockNodeExtension.js.map +1 -0
  19. package/dist/extensions/DragHandleExtension.d.ts +19 -0
  20. package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
  21. package/dist/extensions/DragHandleExtension.js +166 -0
  22. package/dist/extensions/DragHandleExtension.js.map +1 -0
  23. package/dist/extensions/GridExtension.d.ts +49 -0
  24. package/dist/extensions/GridExtension.d.ts.map +1 -0
  25. package/dist/extensions/GridExtension.js +105 -0
  26. package/dist/extensions/GridExtension.js.map +1 -0
  27. package/dist/extensions/MentionExtension.d.ts +71 -0
  28. package/dist/extensions/MentionExtension.d.ts.map +1 -0
  29. package/dist/extensions/MentionExtension.js +165 -0
  30. package/dist/extensions/MentionExtension.js.map +1 -0
  31. package/dist/extensions/MergeTagExtension.d.ts +24 -0
  32. package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
  33. package/dist/extensions/MergeTagExtension.js +57 -0
  34. package/dist/extensions/MergeTagExtension.js.map +1 -0
  35. package/dist/extensions/SlashCommandExtension.d.ts +71 -0
  36. package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
  37. package/dist/extensions/SlashCommandExtension.js +244 -0
  38. package/dist/extensions/SlashCommandExtension.js.map +1 -0
  39. package/dist/extensions/TextSizeMarks.d.ts +33 -0
  40. package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
  41. package/dist/extensions/TextSizeMarks.js +47 -0
  42. package/dist/extensions/TextSizeMarks.js.map +1 -0
  43. package/dist/index.d.ts +8 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +8 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/plugin.d.ts +18 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +25 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/react/BlockNodeView.d.ts +19 -0
  52. package/dist/react/BlockNodeView.d.ts.map +1 -0
  53. package/dist/react/BlockNodeView.js +60 -0
  54. package/dist/react/BlockNodeView.js.map +1 -0
  55. package/dist/react/BlockSidePanel.d.ts +105 -0
  56. package/dist/react/BlockSidePanel.d.ts.map +1 -0
  57. package/dist/react/BlockSidePanel.js +339 -0
  58. package/dist/react/BlockSidePanel.js.map +1 -0
  59. package/dist/react/FloatingToolbar.d.ts +13 -0
  60. package/dist/react/FloatingToolbar.d.ts.map +1 -0
  61. package/dist/react/FloatingToolbar.js +113 -0
  62. package/dist/react/FloatingToolbar.js.map +1 -0
  63. package/dist/react/MentionMenu.d.ts +26 -0
  64. package/dist/react/MentionMenu.d.ts.map +1 -0
  65. package/dist/react/MentionMenu.js +64 -0
  66. package/dist/react/MentionMenu.js.map +1 -0
  67. package/dist/react/Palette.d.ts +26 -0
  68. package/dist/react/Palette.d.ts.map +1 -0
  69. package/dist/react/Palette.js +21 -0
  70. package/dist/react/Palette.js.map +1 -0
  71. package/dist/react/SlashMenu.d.ts +24 -0
  72. package/dist/react/SlashMenu.d.ts.map +1 -0
  73. package/dist/react/SlashMenu.js +74 -0
  74. package/dist/react/SlashMenu.js.map +1 -0
  75. package/dist/react/TableFloatingToolbar.d.ts +7 -0
  76. package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
  77. package/dist/react/TableFloatingToolbar.js +108 -0
  78. package/dist/react/TableFloatingToolbar.js.map +1 -0
  79. package/dist/react/TiptapEditor.d.ts +20 -0
  80. package/dist/react/TiptapEditor.d.ts.map +1 -0
  81. package/dist/react/TiptapEditor.js +398 -0
  82. package/dist/react/TiptapEditor.js.map +1 -0
  83. package/dist/react/Toolbar.d.ts +45 -0
  84. package/dist/react/Toolbar.d.ts.map +1 -0
  85. package/dist/react/Toolbar.js +204 -0
  86. package/dist/react/Toolbar.js.map +1 -0
  87. package/dist/react/toolbarButtons.d.ts +36 -0
  88. package/dist/react/toolbarButtons.d.ts.map +1 -0
  89. package/dist/react/toolbarButtons.js +300 -0
  90. package/dist/react/toolbarButtons.js.map +1 -0
  91. package/dist/register.d.ts +20 -0
  92. package/dist/register.d.ts.map +1 -0
  93. package/dist/register.js +27 -0
  94. package/dist/register.js.map +1 -0
  95. package/dist/render.d.ts +89 -0
  96. package/dist/render.d.ts.map +1 -0
  97. package/dist/render.js +439 -0
  98. package/dist/render.js.map +1 -0
  99. package/package.json +92 -0
  100. package/src/Block.ts +75 -0
  101. package/src/MentionProvider.ts +153 -0
  102. package/src/RichTextField.test.ts +447 -0
  103. package/src/RichTextField.ts +508 -0
  104. package/src/extensions/BlockNodeExtension.ts +134 -0
  105. package/src/extensions/DragHandleExtension.ts +184 -0
  106. package/src/extensions/GridExtension.test.ts +31 -0
  107. package/src/extensions/GridExtension.ts +138 -0
  108. package/src/extensions/MentionExtension.ts +248 -0
  109. package/src/extensions/MergeTagExtension.ts +75 -0
  110. package/src/extensions/SlashCommandExtension.test.ts +147 -0
  111. package/src/extensions/SlashCommandExtension.ts +332 -0
  112. package/src/extensions/TextSizeMarks.ts +73 -0
  113. package/src/index.ts +28 -0
  114. package/src/plugin.test.ts +19 -0
  115. package/src/plugin.ts +26 -0
  116. package/src/react/BlockNodeView.tsx +99 -0
  117. package/src/react/BlockSidePanel.test.ts +412 -0
  118. package/src/react/BlockSidePanel.tsx +451 -0
  119. package/src/react/FloatingToolbar.tsx +304 -0
  120. package/src/react/MentionMenu.tsx +120 -0
  121. package/src/react/Palette.tsx +86 -0
  122. package/src/react/SlashMenu.tsx +129 -0
  123. package/src/react/TableFloatingToolbar.tsx +154 -0
  124. package/src/react/TiptapEditor.tsx +535 -0
  125. package/src/react/Toolbar.tsx +438 -0
  126. package/src/react/toolbarButtons.tsx +579 -0
  127. package/src/register.test.ts +14 -0
  128. package/src/register.ts +27 -0
  129. package/src/render.test.ts +745 -0
  130. package/src/render.ts +480 -0
@@ -0,0 +1,99 @@
1
+ import { useEffect } from 'react'
2
+ import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
3
+ import type { BlockMeta } from '../Block.js'
4
+
5
+ /**
6
+ * Generic React NodeView for the `pilotiqBlock` ProseMirror node. Reads
7
+ * the block type from `node.attrs.blockType`, looks up its `BlockMeta`
8
+ * in `BlockNodeExtension.options.blocks`, and renders a compact inline
9
+ * summary card with an "Edit" button.
10
+ *
11
+ * Editing happens in a side panel hosted by `TiptapEditor`, NOT inline.
12
+ * The NodeView fires `BlockNodeExtension.options.onEdit(getPos())` when
13
+ * the Edit button is clicked; the host opens its panel anchored to the
14
+ * editor wrapper. NodeViews live in a separate React tree from the host
15
+ * editor, so the bridge has to go through extension options — context
16
+ * doesn't cross trees.
17
+ *
18
+ * If no `onEdit` is wired (e.g. a consumer that uses `BlockNodeExtension`
19
+ * standalone without `TiptapEditor`'s panel), the Edit button is hidden.
20
+ */
21
+ export function BlockNodeView(props: NodeViewProps) {
22
+ const { editor, node, getPos, deleteNode } = props
23
+ const blockType = String(node.attrs['blockType'] ?? '')
24
+ const blockData = (node.attrs['blockData'] as Record<string, unknown> | undefined) ?? {}
25
+
26
+ // Tiptap mounts NodeViews in a separate React tree, so we can't read the
27
+ // block registry through context. Pull it off the extension's options
28
+ // instead — set by RichTextField via BlockNodeExtension.configure({ blocks }).
29
+ const blockExt = editor.extensionManager.extensions.find((e) => e.name === 'pilotiqBlock')
30
+ const blocks = (blockExt?.options['blocks'] as BlockMeta[] | undefined) ?? []
31
+ const onEdit = blockExt?.options['onEdit'] as ((pos: number) => void) | undefined
32
+ const meta = blocks.find((b) => b.name === blockType)
33
+
34
+ // Self-heal: a block with no `blockType` is malformed — almost always
35
+ // means a stale node from a prior buggy insert. Delete it on mount so
36
+ // the editor doesn't get stuck in an unrecoverable state.
37
+ useEffect(() => {
38
+ if (blockType === '') deleteNode()
39
+ }, [blockType, deleteNode])
40
+
41
+ if (!meta) {
42
+ if (blockType === '') return null
43
+ return (
44
+ <NodeViewWrapper className="my-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
45
+ Unknown block type: <code>{blockType}</code>
46
+ </NodeViewWrapper>
47
+ )
48
+ }
49
+
50
+ const summary = meta.schema
51
+ .map((f) => {
52
+ const v = blockData[f.name]
53
+ return typeof v === 'string' && v ? v : ''
54
+ })
55
+ .filter(Boolean)
56
+ .join(' · ') || meta.label
57
+
58
+ const handleEdit = (): void => {
59
+ if (!onEdit) return
60
+ const pos = getPos()
61
+ if (typeof pos !== 'number') return
62
+ onEdit(pos)
63
+ }
64
+
65
+ return (
66
+ <NodeViewWrapper className="pilotiq-block my-3 rounded-lg border bg-muted/30">
67
+ <div className="flex items-start justify-between gap-2 px-3 py-2">
68
+ <button
69
+ type="button"
70
+ onClick={handleEdit}
71
+ disabled={!onEdit}
72
+ className="flex items-center gap-2 text-left text-sm disabled:cursor-default"
73
+ >
74
+ {meta.icon && <span aria-hidden="true">{meta.icon}</span>}
75
+ <span className="font-medium">{meta.label}</span>
76
+ <span className="text-xs text-muted-foreground line-clamp-1">{summary}</span>
77
+ </button>
78
+ <div className="flex items-center gap-2">
79
+ {onEdit && (
80
+ <button
81
+ type="button"
82
+ onClick={handleEdit}
83
+ className="text-xs text-muted-foreground hover:text-foreground"
84
+ >
85
+ Edit
86
+ </button>
87
+ )}
88
+ <button
89
+ type="button"
90
+ onClick={() => deleteNode()}
91
+ className="text-xs text-destructive hover:underline"
92
+ >
93
+ Remove
94
+ </button>
95
+ </div>
96
+ </div>
97
+ </NodeViewWrapper>
98
+ )
99
+ }
@@ -0,0 +1,412 @@
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
+ })