@pilotiq/tiptap 3.10.4 → 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 (69) 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/dist/react/CollabTextRenderer.d.ts.map +1 -1
  8. package/dist/react/CollabTextRenderer.js +4 -4
  9. package/dist/react/CollabTextRenderer.js.map +1 -1
  10. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  11. package/dist/react/MarkdownEditor.js +4 -5
  12. package/dist/react/MarkdownEditor.js.map +1 -1
  13. package/dist/react/TiptapEditor.d.ts.map +1 -1
  14. package/dist/react/TiptapEditor.js +8 -7
  15. package/dist/react/TiptapEditor.js.map +1 -1
  16. package/package.json +6 -3
  17. package/dist/collabShapes.d.ts +0 -22
  18. package/dist/collabShapes.d.ts.map +0 -1
  19. package/dist/collabShapes.js +0 -2
  20. package/dist/collabShapes.js.map +0 -1
  21. package/src/Block.ts +0 -75
  22. package/src/MentionProvider.ts +0 -153
  23. package/src/PlainTextEditor.dom.test.ts +0 -111
  24. package/src/PlainTextEditor.test.ts +0 -158
  25. package/src/PlainTextEditor.ts +0 -229
  26. package/src/RichTextField.test.ts +0 -447
  27. package/src/RichTextField.ts +0 -508
  28. package/src/collabShapes.ts +0 -22
  29. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  30. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  31. package/src/extensions/AiSuggestionExtension.ts +0 -522
  32. package/src/extensions/BlockNodeExtension.ts +0 -134
  33. package/src/extensions/DragHandleExtension.ts +0 -184
  34. package/src/extensions/GridExtension.test.ts +0 -31
  35. package/src/extensions/GridExtension.ts +0 -138
  36. package/src/extensions/MentionExtension.ts +0 -248
  37. package/src/extensions/MergeTagExtension.ts +0 -75
  38. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  39. package/src/extensions/SlashCommandExtension.ts +0 -332
  40. package/src/extensions/TextSizeMarks.ts +0 -73
  41. package/src/index.ts +0 -62
  42. package/src/markdownExtension.ts +0 -19
  43. package/src/markdownStorage.ts +0 -49
  44. package/src/plugin.test.ts +0 -19
  45. package/src/plugin.ts +0 -26
  46. package/src/react/AiSuggestionBanner.tsx +0 -185
  47. package/src/react/BlockNodeView.tsx +0 -99
  48. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  49. package/src/react/BlockSidePanel.test.ts +0 -412
  50. package/src/react/BlockSidePanel.tsx +0 -451
  51. package/src/react/CollabTextRenderer.tsx +0 -230
  52. package/src/react/FloatingToolbar.tsx +0 -304
  53. package/src/react/MarkdownEditor.tsx +0 -606
  54. package/src/react/MentionMenu.tsx +0 -120
  55. package/src/react/Palette.tsx +0 -86
  56. package/src/react/SlashMenu.tsx +0 -129
  57. package/src/react/TableFloatingToolbar.tsx +0 -154
  58. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  59. package/src/react/TiptapEditor.tsx +0 -776
  60. package/src/react/Toolbar.tsx +0 -438
  61. package/src/react/toolbarButtons.tsx +0 -579
  62. package/src/react/useAiInlineDiff.ts +0 -342
  63. package/src/react/useAiSuggestionBridge.ts +0 -223
  64. package/src/register.test.ts +0 -14
  65. package/src/register.ts +0 -42
  66. package/src/render.test.ts +0 -745
  67. package/src/render.ts +0 -480
  68. package/src/surgicalOps.ts +0 -205
  69. package/src/test/setup.ts +0 -64
@@ -1,522 +0,0 @@
1
- import { Extension, type Editor } from '@tiptap/core'
2
- import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
3
- import { Decoration, DecorationSet } from '@tiptap/pm/view'
4
-
5
- /**
6
- * One AI-suggested replacement of `[from, to)` with `replacement`.
7
- *
8
- * `from === to` represents a pure insertion at that position. v1 carries
9
- * plain-text replacement only — marks and structure round-trip through the
10
- * editor as-is when the suggestion is approved (the original range's marks
11
- * are preserved on the inserted text node by ProseMirror).
12
- */
13
- export interface AiSuggestion {
14
- /** Stable id; consumer-provided. Re-adding with the same id replaces the prior entry. */
15
- id: string
16
- /** Inclusive document position the original range starts at. */
17
- from: number
18
- /** Exclusive document position the original range ends at. */
19
- to: number
20
- /** Plain-text replacement. Empty string = pure deletion. */
21
- replacement: string
22
- /** Optional attribution surfaced on the chip widget. */
23
- source?: {
24
- agentSlug?: string
25
- agentLabel?: string
26
- }
27
- }
28
-
29
- export interface AiSuggestionExtensionOptions {
30
- /**
31
- * Class prefix for both decoration spans and chip widgets. The package
32
- * stays CSS-free — consumers ship the matching styles. Default
33
- * `'pilotiq-ai-suggestion'` produces classes:
34
- * - `pilotiq-ai-suggestion-original` (strikethrough on the original range)
35
- * - `pilotiq-ai-suggestion-chip` (root of the inline widget)
36
- * - `pilotiq-ai-suggestion-replacement` (the suggested-text preview span)
37
- * - `pilotiq-ai-suggestion-accept` (Approve button)
38
- * - `pilotiq-ai-suggestion-reject` (Reject button)
39
- */
40
- classPrefix: string
41
- /**
42
- * Fired whenever the suggestion list changes — after `add*`, `approve*`,
43
- * `reject*`, `clear*`, or after a doc edit collapses a range. Lets the host
44
- * mirror state into a React context (e.g. `PendingSuggestionsApi`).
45
- */
46
- onChange?: (suggestions: AiSuggestion[]) => void
47
- }
48
-
49
- declare module '@tiptap/core' {
50
- interface Commands<ReturnType> {
51
- aiSuggestion: {
52
- /** Add or replace a suggestion (matched by id). */
53
- addAiSuggestion: (suggestion: AiSuggestion) => ReturnType
54
- /** Add or replace many suggestions in one transaction. */
55
- addAiSuggestions: (suggestions: AiSuggestion[]) => ReturnType
56
- /** Apply the replacement to the doc and drop the suggestion. */
57
- approveAiSuggestion: (id: string) => ReturnType
58
- /** Drop the suggestion without touching the doc. */
59
- rejectAiSuggestion: (id: string) => ReturnType
60
- /** Apply every replacement in highest-`from`-first order. */
61
- approveAllAiSuggestions: () => ReturnType
62
- /** Drop every suggestion. */
63
- rejectAllAiSuggestions: () => ReturnType
64
- /** Alias for `rejectAllAiSuggestions`. */
65
- clearAiSuggestions: () => ReturnType
66
- }
67
- }
68
- }
69
-
70
- interface PluginState {
71
- suggestions: readonly AiSuggestion[]
72
- }
73
-
74
- interface SetMeta {
75
- type: 'set'
76
- next: readonly AiSuggestion[]
77
- }
78
-
79
- export const aiSuggestionPluginKey = new PluginKey<PluginState>('pilotiqAiSuggestion')
80
-
81
- /**
82
- * Append or replace by id. Pure — exported for tests and so the same dedupe
83
- * shape can drive consumer-side mirror state.
84
- */
85
- export function upsertSuggestion(
86
- current: readonly AiSuggestion[],
87
- next: AiSuggestion,
88
- ): AiSuggestion[] {
89
- const idx = current.findIndex(s => s.id === next.id)
90
- if (idx === -1) return [...current, next]
91
- const copy = current.slice()
92
- copy[idx] = next
93
- return copy
94
- }
95
-
96
- /** Append or replace many — semantically equivalent to a fold over `upsertSuggestion`. */
97
- export function upsertSuggestions(
98
- current: readonly AiSuggestion[],
99
- nexts: readonly AiSuggestion[],
100
- ): AiSuggestion[] {
101
- let acc: AiSuggestion[] = current.slice()
102
- for (const n of nexts) acc = upsertSuggestion(acc, n)
103
- return acc
104
- }
105
-
106
- /** Remove by id. */
107
- export function removeSuggestion(
108
- current: readonly AiSuggestion[],
109
- id: string,
110
- ): AiSuggestion[] {
111
- return current.filter(s => s.id !== id)
112
- }
113
-
114
- /**
115
- * Remap survivors through a PM mapping; drop ranges that collapsed past
116
- * each other (`to < from` after remap). Pure — exported for tests.
117
- */
118
- export function remapSuggestions(
119
- suggestions: readonly AiSuggestion[],
120
- map: (pos: number, side: -1 | 1) => number,
121
- ): AiSuggestion[] {
122
- const out: AiSuggestion[] = []
123
- for (const s of suggestions) {
124
- const from = map(s.from, -1)
125
- const to = map(s.to, 1)
126
- if (to < from) continue
127
- out.push({ ...s, from, to })
128
- }
129
- return out
130
- }
131
-
132
- /**
133
- * Order suggestions for `approveAll` so the highest-`from` runs first;
134
- * earlier-in-doc replacements then can't shift positions of later ones.
135
- * Pure — exported for tests.
136
- */
137
- export function sortForApproveAll(
138
- suggestions: readonly AiSuggestion[],
139
- ): AiSuggestion[] {
140
- return suggestions.slice().sort((a, b) => b.from - a.from)
141
- }
142
-
143
- /**
144
- * Editor extension that tracks AI-suggested edits as inline decorations with
145
- * per-hunk Approve/Reject chips. The package is CSS-free — consumers wire
146
- * styles against the documented class names.
147
- *
148
- * Usage:
149
- * ```ts
150
- * editor.commands.addAiSuggestion({
151
- * id: 'seo-1',
152
- * from: 12,
153
- * to: 18,
154
- * replacement: 'better',
155
- * source: { agentLabel: 'SEO' },
156
- * })
157
- * // …user clicks ✓ on the chip, or:
158
- * editor.commands.approveAiSuggestion('seo-1')
159
- * ```
160
- *
161
- * Mounted by default inside `TiptapEditor`; consumer code reaches it through
162
- * the editor's command surface.
163
- */
164
- export const AiSuggestionExtension = Extension.create<AiSuggestionExtensionOptions>({
165
- name: 'pilotiqAiSuggestion',
166
-
167
- addOptions() {
168
- return {
169
- classPrefix: 'pilotiq-ai-suggestion',
170
- }
171
- },
172
-
173
- onCreate() {
174
- // Inject minimal default styles for the chip + strikethrough on first
175
- // mount so consumers see the visualization without wiring CSS. Idempotent
176
- // via the `data-pilotiq-ai-suggestion-styles` sentinel; consumers who
177
- // want full control just add their own `<style>` with the same class
178
- // names (last wins — the cascade picks user overrides over our defaults
179
- // since the user stylesheet appears AFTER our injected one in `<head>`
180
- // when imported via Vite/Webpack, OR via higher specificity).
181
- if (typeof document === 'undefined') return
182
- const SENTINEL = 'data-pilotiq-ai-suggestion-styles'
183
- if (document.head.querySelector(`style[${SENTINEL}]`)) return
184
- const prefix = this.options.classPrefix
185
- const style = document.createElement('style')
186
- style.setAttribute(SENTINEL, '')
187
- // Colors picked to look right on light + dark surfaces without theme
188
- // overrides (60% alpha on background-color, 100% on text). Tuned to
189
- // match the inline-diff convention used by the Tiptap Pro AI Agent.
190
- style.textContent = `
191
- .${prefix}-original {
192
- text-decoration: line-through;
193
- text-decoration-color: rgba(220, 38, 38, 0.7);
194
- background-color: rgba(254, 226, 226, 0.6);
195
- color: rgb(153, 27, 27);
196
- }
197
- .${prefix}-chip {
198
- display: inline-flex;
199
- align-items: center;
200
- gap: 0.25rem;
201
- margin-left: 0.25rem;
202
- padding: 0 0.25rem;
203
- border-radius: 0.25rem;
204
- background-color: rgba(220, 252, 231, 0.7);
205
- color: rgb(22, 101, 52);
206
- font-size: 0.875em;
207
- line-height: 1.4;
208
- }
209
- .${prefix}-replacement {
210
- padding: 0 0.125rem;
211
- }
212
- .${prefix}-accept,
213
- .${prefix}-reject {
214
- appearance: none;
215
- background: transparent;
216
- border: 0;
217
- padding: 0 0.25rem;
218
- cursor: pointer;
219
- font-size: 0.875em;
220
- line-height: 1;
221
- color: inherit;
222
- }
223
- .${prefix}-accept:hover { color: rgb(21, 128, 61); }
224
- .${prefix}-reject:hover { color: rgb(185, 28, 28); }
225
-
226
- /* Banner — bottom-of-editor strip for whole-field suggestions on rich
227
- surfaces (markdown / richtext). Sibling to the chip styles above;
228
- lives here so both ship via the same extension-mount sentinel.
229
- Class names live under \`pilotiq-ai-banner-*\` (not \`-suggestion-\`)
230
- since the banner is a host-mounted React component, not a PM
231
- decoration. */
232
- .pilotiq-ai-banner {
233
- display: flex;
234
- align-items: center;
235
- gap: 0.5rem;
236
- padding: 0.375rem 0.625rem;
237
- margin-top: 0.375rem;
238
- border-radius: 0.375rem;
239
- background-color: rgba(254, 252, 232, 0.9);
240
- border: 1px solid rgba(234, 179, 8, 0.4);
241
- color: rgb(113, 63, 18);
242
- font-size: 0.875rem;
243
- line-height: 1.4;
244
- }
245
- .pilotiq-ai-banner-icon { flex: 0 0 auto; }
246
- .pilotiq-ai-banner-label { flex: 1 1 auto; }
247
- .pilotiq-ai-banner-actions {
248
- display: inline-flex;
249
- gap: 0.375rem;
250
- flex: 0 0 auto;
251
- }
252
- .pilotiq-ai-banner-reject,
253
- .pilotiq-ai-banner-accept {
254
- appearance: none;
255
- cursor: pointer;
256
- font-size: 0.8125rem;
257
- font-weight: 500;
258
- line-height: 1;
259
- padding: 0.25rem 0.625rem;
260
- border-radius: 0.25rem;
261
- border: 1px solid transparent;
262
- }
263
- .pilotiq-ai-banner-reject {
264
- background-color: transparent;
265
- color: rgb(120, 53, 15);
266
- border-color: rgba(180, 83, 9, 0.4);
267
- }
268
- .pilotiq-ai-banner-reject:hover {
269
- background-color: rgba(254, 215, 170, 0.4);
270
- }
271
- .pilotiq-ai-banner-accept {
272
- background-color: rgb(22, 101, 52);
273
- color: white;
274
- }
275
- .pilotiq-ai-banner-accept:hover { background-color: rgb(21, 128, 61); }
276
- `
277
- document.head.appendChild(style)
278
- },
279
-
280
- addCommands() {
281
- return {
282
- addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
283
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
284
- const next = upsertSuggestion(current, suggestion)
285
- if (dispatch) {
286
- tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
287
- dispatch(tr)
288
- }
289
- return true
290
- },
291
-
292
- addAiSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
293
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
294
- const next = upsertSuggestions(current, suggestions)
295
- if (dispatch) {
296
- tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
297
- dispatch(tr)
298
- }
299
- return true
300
- },
301
-
302
- approveAiSuggestion: (id) => ({ tr, state, dispatch }) => {
303
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
304
- const target = current.find(s => s.id === id)
305
- if (!target) return false
306
- if (dispatch) {
307
- applyApprove(tr, state, target)
308
- tr.setMeta(aiSuggestionPluginKey, {
309
- type: 'set',
310
- next: removeSuggestion(current, id),
311
- } satisfies SetMeta)
312
- dispatch(tr)
313
- }
314
- return true
315
- },
316
-
317
- rejectAiSuggestion: (id) => ({ tr, state, dispatch }) => {
318
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
319
- const target = current.find(s => s.id === id)
320
- if (!target) return false
321
- if (dispatch) {
322
- tr.setMeta(aiSuggestionPluginKey, {
323
- type: 'set',
324
- next: removeSuggestion(current, id),
325
- } satisfies SetMeta)
326
- dispatch(tr)
327
- }
328
- return true
329
- },
330
-
331
- approveAllAiSuggestions: () => ({ tr, state, dispatch }) => {
332
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
333
- if (current.length === 0) return false
334
- if (dispatch) {
335
- for (const s of sortForApproveAll(current)) applyApprove(tr, state, s)
336
- tr.setMeta(aiSuggestionPluginKey, {
337
- type: 'set',
338
- next: [],
339
- } satisfies SetMeta)
340
- dispatch(tr)
341
- }
342
- return true
343
- },
344
-
345
- rejectAllAiSuggestions: () => ({ tr, state, dispatch }) => {
346
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
347
- if (current.length === 0) return false
348
- if (dispatch) {
349
- tr.setMeta(aiSuggestionPluginKey, {
350
- type: 'set',
351
- next: [],
352
- } satisfies SetMeta)
353
- dispatch(tr)
354
- }
355
- return true
356
- },
357
-
358
- clearAiSuggestions: () => ({ tr, state, dispatch }) => {
359
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
360
- if (current.length === 0) return false
361
- if (dispatch) {
362
- tr.setMeta(aiSuggestionPluginKey, {
363
- type: 'set',
364
- next: [],
365
- } satisfies SetMeta)
366
- dispatch(tr)
367
- }
368
- return true
369
- },
370
- }
371
- },
372
-
373
- addProseMirrorPlugins() {
374
- const ext = this
375
- return [
376
- new Plugin<PluginState>({
377
- key: aiSuggestionPluginKey,
378
- state: {
379
- init: (): PluginState => ({ suggestions: [] }),
380
- apply(tr, prev): PluginState {
381
- const meta = tr.getMeta(aiSuggestionPluginKey) as SetMeta | undefined
382
- const base = meta?.type === 'set' ? meta.next : prev.suggestions
383
- if (!tr.docChanged) return { suggestions: base }
384
- return {
385
- suggestions: remapSuggestions(base, (pos, side) =>
386
- tr.mapping.map(pos, side)),
387
- }
388
- },
389
- },
390
- props: {
391
- decorations(state) {
392
- const ps = aiSuggestionPluginKey.getState(state)
393
- if (!ps || ps.suggestions.length === 0) return DecorationSet.empty
394
- return buildDecorations(state, ps.suggestions, ext.options.classPrefix, ext.editor)
395
- },
396
- },
397
- view(view) {
398
- let last = aiSuggestionPluginKey.getState(view.state)?.suggestions
399
- return {
400
- update(updated) {
401
- const next = aiSuggestionPluginKey.getState(updated.state)?.suggestions
402
- if (next === last) return
403
- last = next
404
- const cb = ext.options.onChange
405
- if (cb) cb(next ? [...next] : [])
406
- },
407
- destroy() {},
408
- }
409
- },
410
- }),
411
- ]
412
- },
413
- })
414
-
415
- function applyApprove(tr: Transaction, state: EditorState, target: AiSuggestion): void {
416
- const docSize = state.doc.content.size
417
- const from = clampPos(target.from, docSize)
418
- const to = clampPos(target.to, docSize)
419
- if (from > to) return
420
- if (target.replacement.length > 0) {
421
- tr.replaceWith(from, to, state.schema.text(target.replacement))
422
- } else if (from < to) {
423
- tr.delete(from, to)
424
- }
425
- }
426
-
427
- function buildDecorations(
428
- state: EditorState,
429
- suggestions: readonly AiSuggestion[],
430
- prefix: string,
431
- editor: Editor,
432
- ): DecorationSet {
433
- const docSize = state.doc.content.size
434
- const decos: Decoration[] = []
435
-
436
- for (const s of suggestions) {
437
- const from = clampPos(s.from, docSize)
438
- const to = clampPos(s.to, docSize)
439
- if (from > to) continue
440
-
441
- if (from < to) {
442
- decos.push(
443
- Decoration.inline(from, to, {
444
- class: `${prefix}-original`,
445
- 'data-pilotiq-ai-suggestion-id': s.id,
446
- }),
447
- )
448
- }
449
-
450
- decos.push(
451
- Decoration.widget(to, () => buildChip(s, prefix, editor), {
452
- side: 1,
453
- ignoreSelection: true,
454
- key: `pilotiq-ai-suggestion:${s.id}`,
455
- }),
456
- )
457
- }
458
-
459
- return DecorationSet.create(state.doc, decos)
460
- }
461
-
462
- /** Bound `pos` into `[0, max]`; non-finite or negative input collapses to 0. */
463
- export function clampPos(pos: number, max: number): number {
464
- if (!Number.isFinite(pos)) return 0
465
- if (pos < 0) return 0
466
- if (pos > max) return max
467
- return Math.trunc(pos)
468
- }
469
-
470
- function buildChip(s: AiSuggestion, prefix: string, editor: Editor): HTMLElement {
471
- const root = document.createElement('span')
472
- root.className = `${prefix}-chip`
473
- root.setAttribute('data-pilotiq-ai-suggestion-id', s.id)
474
- root.contentEditable = 'false'
475
-
476
- if (s.replacement.length > 0) {
477
- const insert = document.createElement('span')
478
- insert.className = `${prefix}-replacement`
479
- insert.textContent = s.replacement
480
- root.appendChild(insert)
481
- }
482
-
483
- if (s.source?.agentLabel) {
484
- root.setAttribute('data-pilotiq-ai-suggestion-source', s.source.agentLabel)
485
- }
486
- if (s.source?.agentSlug) {
487
- root.setAttribute('data-pilotiq-ai-suggestion-source-slug', s.source.agentSlug)
488
- }
489
-
490
- root.appendChild(buildButton(prefix, 'accept', '✓', 'Accept suggestion', () => {
491
- editor.chain().focus().approveAiSuggestion(s.id).run()
492
- }))
493
- root.appendChild(buildButton(prefix, 'reject', '✕', 'Reject suggestion', () => {
494
- editor.chain().focus().rejectAiSuggestion(s.id).run()
495
- }))
496
-
497
- return root
498
- }
499
-
500
- function buildButton(
501
- prefix: string,
502
- variant: 'accept' | 'reject',
503
- glyph: string,
504
- title: string,
505
- onClick: () => void,
506
- ): HTMLButtonElement {
507
- const btn = document.createElement('button')
508
- btn.type = 'button'
509
- btn.className = `${prefix}-${variant}`
510
- btn.title = title
511
- btn.textContent = glyph
512
- // Don't steal the editor selection on press — the click handler runs on
513
- // mouseup, but mousedown is what flips focus to the button. Cancelling it
514
- // keeps the cursor in the editor so `editor.chain().focus()` lands cleanly.
515
- btn.addEventListener('mousedown', (e) => e.preventDefault())
516
- btn.addEventListener('click', (e) => {
517
- e.preventDefault()
518
- e.stopPropagation()
519
- onClick()
520
- })
521
- return btn
522
- }
@@ -1,134 +0,0 @@
1
- import { Node, mergeAttributes } from '@tiptap/core'
2
- import { ReactNodeViewRenderer } from '@tiptap/react'
3
- import type { BlockMeta } from '../Block.js'
4
- import { BlockNodeView } from '../react/BlockNodeView.js'
5
-
6
- declare module '@tiptap/core' {
7
- interface Commands<ReturnType> {
8
- customBlock: {
9
- /** Insert a custom-block node by type, with optional initial data. */
10
- insertBlock: (blockType: string, blockData?: Record<string, unknown>) => ReturnType
11
- }
12
- }
13
- }
14
-
15
- export interface BlockNodeOptions {
16
- /**
17
- * Block-meta registry. The NodeView reads from this to find the schema
18
- * for the block-type it's rendering. Stashed on the extension's options
19
- * because Tiptap's ReactNodeViewRenderer mounts NodeViews in a separate
20
- * React tree — `useContext` does NOT reach them, so we can't pass
21
- * registry data via React context.
22
- */
23
- blocks: BlockMeta[]
24
- /**
25
- * Bridge from the NodeView's separate React tree back to the editor's
26
- * own tree, where the side panel lives. Set by `TiptapEditor` so the
27
- * "Edit" button on each block can request the panel open against this
28
- * specific node. `undefined` means no host is listening — the NodeView
29
- * falls back to a no-op (does not render an Edit affordance, or does
30
- * so disabled, depending on the consumer's chrome).
31
- */
32
- onEdit?: (pos: number) => void
33
- }
34
-
35
- /**
36
- * Single ProseMirror node type that represents every custom block. The
37
- * concrete block type ("callout", "image", …) lives in `attrs.blockType`,
38
- * and the per-block data lives in `attrs.blockData`. The React NodeView
39
- * looks the type up in this extension's `options.blocks` and renders the
40
- * matching inline form.
41
- *
42
- * Storing one node type per block name would scale O(n) extensions. This
43
- * approach scales O(1).
44
- */
45
- export const BlockNodeExtension = Node.create<BlockNodeOptions>({
46
- // Avoid `name: 'block'` — ProseMirror's `block` is a schema group name,
47
- // and naming a node identically to a group can collide subtly with
48
- // schema content matching (TrailingNode threw "invalid content" on every
49
- // dispatch with `name: 'block'`).
50
- name: 'pilotiqBlock',
51
- group: 'block',
52
- // Mirrors the canonical Tiptap atom-block pattern (image / horizontalRule):
53
- // omit `atom`/`selectable`, set `draggable: true`, no explicit `content`.
54
- // Setting `atom: true` together with `group: 'block'` was making
55
- // StarterKit's TrailingNode plugin throw "invalid content" on every
56
- // dispatch — even before any block was inserted.
57
- draggable: true,
58
-
59
- addOptions() {
60
- // `onEdit` intentionally omitted — `exactOptionalPropertyTypes` makes
61
- // an explicit `undefined` non-assignable to the optional field, and
62
- // the host wires it via `BlockNodeExtension.configure({ onEdit })`.
63
- return { blocks: [] }
64
- },
65
-
66
- addAttributes() {
67
- return {
68
- blockType: {
69
- default: null,
70
- parseHTML: (el) => el.getAttribute('data-block-type'),
71
- renderHTML: (attrs) => {
72
- if (!attrs['blockType']) return {}
73
- return { 'data-block-type': attrs['blockType'] }
74
- },
75
- },
76
- blockData: {
77
- // Default `null` rather than `{}` — ProseMirror compares attrs by
78
- // reference for some equality checks, and a fresh `{}` per node
79
- // create call breaks them subtly.
80
- default: null,
81
- parseHTML: (el) => {
82
- const raw = el.getAttribute('data-block-data')
83
- if (!raw) return null
84
- try { return JSON.parse(raw) } catch { return null }
85
- },
86
- renderHTML: (attrs) => {
87
- if (!attrs['blockData']) return {}
88
- return { 'data-block-data': JSON.stringify(attrs['blockData']) }
89
- },
90
- },
91
- }
92
- },
93
-
94
- parseHTML() {
95
- return [{ tag: 'div[data-pilotiq-block]' }]
96
- },
97
-
98
- renderHTML({ HTMLAttributes }) {
99
- return ['div', mergeAttributes({ 'data-pilotiq-block': '' }, HTMLAttributes)]
100
- },
101
-
102
- addNodeView() {
103
- return ReactNodeViewRenderer(BlockNodeView)
104
- },
105
-
106
- addCommands() {
107
- return {
108
- insertBlock: (blockType, blockData = {}) => ({ commands }) =>
109
- commands.insertContent({
110
- type: this.name,
111
- attrs: { blockType, blockData },
112
- }),
113
- }
114
- },
115
-
116
- // `Mod-e` opens the side panel for the currently NodeSelected block.
117
- // Returns false when no block is selected so the browser's default
118
- // (Safari "Use Selection for Find", etc.) still applies in plain text.
119
- addKeyboardShortcuts() {
120
- return {
121
- 'Mod-e': () => {
122
- const onEdit = this.options.onEdit
123
- if (!onEdit) return false
124
- const sel = this.editor.state.selection as unknown as {
125
- node?: { type: { name: string } }
126
- from: number
127
- }
128
- if (sel.node?.type.name !== this.name) return false
129
- onEdit(sel.from)
130
- return true
131
- },
132
- }
133
- },
134
- })