@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,286 +0,0 @@
1
- /**
2
- * Inline-diff visualization for whole-field AI suggestions.
3
- *
4
- * Sibling to `AiSuggestionExtension`, which handles producer-supplied
5
- * range suggestions (surgical edits with `meta.editorRange`) via the
6
- * inline chip widget. This extension handles the *whole-field* case:
7
- * the AI proposes a new document and the user reviews the structural
8
- * delta (added paragraphs, deleted text, mark changes, etc.) before
9
- * accepting or rejecting via the host-mounted `<AiSuggestionBanner>`.
10
- *
11
- * Architecture:
12
- * 1. `startAiInlineDiff(id, newDoc)` captures the current doc as the
13
- * baseline, replaces the doc body with `newDoc`'s content (so the
14
- * editor surface IS the proposed state), and initializes a
15
- * `prosemirror-changeset` tracking the original-to-current
16
- * transition.
17
- * 2. The plugin appendTransaction hook keeps the changeset in sync
18
- * with any further transactions while the diff is pending — e.g.
19
- * y-prosemirror remote edits arriving during review. (Rare on
20
- * whole-field flows but free.)
21
- * 3. A decorations spec walks `ChangeSet.changes` and emits:
22
- * - inline green-background decorations on inserted ranges
23
- * - widget decorations rendering the *deleted* text struck
24
- * through next to the insert point (the deleted content
25
- * isn't in the current doc, so a widget is the only way to
26
- * surface it)
27
- * 4. `acceptAiInlineDiff()` clears the plugin state — the current
28
- * doc is the accepted state.
29
- * 5. `rejectAiInlineDiff()` replaces the doc back to the baseline
30
- * via a single transaction and clears state.
31
- *
32
- * For Tiptap Pro parity. See `[[project_pilotiq_text_field_tiptap_rules]]`.
33
- */
34
-
35
- import { Extension } from '@tiptap/core'
36
- import { Plugin, PluginKey } from '@tiptap/pm/state'
37
- import type { EditorState, Transaction } from '@tiptap/pm/state'
38
- import { Decoration, DecorationSet } from '@tiptap/pm/view'
39
- import type { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
40
- import { ChangeSet } from 'prosemirror-changeset'
41
-
42
- declare module '@tiptap/core' {
43
- interface Commands<ReturnType> {
44
- aiInlineDiff: {
45
- /**
46
- * Start the inline-diff review session. Snapshots the current
47
- * doc as the baseline, replaces the doc with `newDocSlice`'s
48
- * content, and shows the diff overlay.
49
- *
50
- * `id` is the host-side `PendingSuggestion.id` — used so the
51
- * banner / approve handlers can correlate the editor state with
52
- * the queue entry.
53
- */
54
- startAiInlineDiff: (id: string, newDocSlice: Slice) => ReturnType
55
- /**
56
- * Start the inline-diff review session for a surgical edit.
57
- * Snapshots the current doc as the baseline, then runs
58
- * `applyFn(tr)` to mutate the transaction with a precise change
59
- * (e.g. replace one block, insert before a position, set a mark
60
- * on a range). The plugin folds the resulting steps into the
61
- * changeset, so decorations land exactly on the modified ranges
62
- * — no whole-doc replacement.
63
- *
64
- * Use this for `replace_block` / `insert_block_before` /
65
- * `delete_block` / `update_block_mark` AI ops. Returns false (no
66
- * dispatch) when `applyFn` produced no doc change.
67
- */
68
- applySurgicalAiInlineDiff: (id: string, applyFn: (tr: Transaction) => void) => ReturnType
69
- /** Clear diff state. Current doc IS the accepted state. */
70
- acceptAiInlineDiff: () => ReturnType
71
- /** Revert doc to the captured baseline and clear diff state. */
72
- rejectAiInlineDiff: () => ReturnType
73
- }
74
- }
75
- }
76
-
77
- interface DiffState {
78
- id: string
79
- /** Original doc captured at `startAiInlineDiff` time — used for revert. */
80
- baseline: ProseMirrorNode
81
- /** ChangeSet accumulating diffs since baseline. */
82
- changeset: ChangeSet
83
- }
84
-
85
- export const aiInlineDiffPluginKey = new PluginKey<DiffState | null>('pilotiqAiInlineDiff')
86
-
87
- /** Read the active diff state, if any. Public for hosts that want to
88
- * branch their banner UI on "diff active" vs "diff inactive". */
89
- export function getAiInlineDiffState(state: EditorState): DiffState | null {
90
- return aiInlineDiffPluginKey.getState(state) ?? null
91
- }
92
-
93
- interface StartMeta { type: 'start'; id: string; baseline: ProseMirrorNode }
94
- interface ClearMeta { type: 'clear' }
95
- type DiffMeta = StartMeta | ClearMeta
96
-
97
- export interface AiInlineDiffExtensionOptions {
98
- /**
99
- * Class prefix for inline-diff decorations. Defaults to
100
- * `'pilotiq-ai-diff'`, producing:
101
- * - `pilotiq-ai-diff-inserted` (green-background span on new ranges)
102
- * - `pilotiq-ai-diff-deleted` (widget DOM root for deleted text)
103
- * - `pilotiq-ai-diff-deleted-text` (the strikethrough span inside)
104
- */
105
- classPrefix?: string
106
- }
107
-
108
- export const AiInlineDiffExtension = Extension.create<AiInlineDiffExtensionOptions>({
109
- name: 'pilotiqAiInlineDiff',
110
-
111
- addOptions() {
112
- return { classPrefix: 'pilotiq-ai-diff' }
113
- },
114
-
115
- onCreate() {
116
- // Mirror the chip CSS injection pattern. Idempotent via sentinel.
117
- if (typeof document === 'undefined') return
118
- const SENTINEL = 'data-pilotiq-ai-diff-styles'
119
- if (document.head.querySelector(`style[${SENTINEL}]`)) return
120
- const prefix = this.options.classPrefix
121
- const style = document.createElement('style')
122
- style.setAttribute(SENTINEL, '')
123
- style.textContent = `
124
- .${prefix}-inserted {
125
- background-color: rgba(187, 247, 208, 0.55);
126
- color: rgb(20, 83, 45);
127
- text-decoration: none;
128
- }
129
- .${prefix}-deleted {
130
- display: inline;
131
- margin-right: 0.125em;
132
- }
133
- .${prefix}-deleted-text {
134
- text-decoration: line-through;
135
- text-decoration-color: rgba(220, 38, 38, 0.7);
136
- background-color: rgba(254, 226, 226, 0.55);
137
- color: rgb(153, 27, 27);
138
- padding: 0 0.125em;
139
- }
140
- `
141
- document.head.appendChild(style)
142
- },
143
-
144
- addCommands() {
145
- return {
146
- startAiInlineDiff: (id, newDocSlice) => ({ tr, state, dispatch }) => {
147
- const baseline = state.doc
148
- const docEnd = state.doc.content.size
149
- // Replace the whole doc body with the proposed content. The
150
- // schema enforces validity — if the slice doesn't fit, ProseMirror
151
- // throws (callers should pre-validate via `editor.schema`).
152
- tr.replaceRange(0, docEnd, newDocSlice)
153
- const meta: StartMeta = { type: 'start', id, baseline }
154
- tr.setMeta(aiInlineDiffPluginKey, meta)
155
- if (dispatch) dispatch(tr)
156
- return true
157
- },
158
- applySurgicalAiInlineDiff: (id, applyFn) => ({ tr, state, dispatch }) => {
159
- const baseline = state.doc
160
- applyFn(tr)
161
- if (!tr.docChanged) return false
162
- const meta: StartMeta = { type: 'start', id, baseline }
163
- tr.setMeta(aiInlineDiffPluginKey, meta)
164
- if (dispatch) dispatch(tr)
165
- return true
166
- },
167
- acceptAiInlineDiff: () => ({ tr, dispatch }) => {
168
- const meta: ClearMeta = { type: 'clear' }
169
- tr.setMeta(aiInlineDiffPluginKey, meta)
170
- if (dispatch) dispatch(tr)
171
- return true
172
- },
173
- rejectAiInlineDiff: () => ({ tr, state, dispatch }) => {
174
- const ds = aiInlineDiffPluginKey.getState(state)
175
- if (!ds) return false
176
- const docEnd = state.doc.content.size
177
- // Replace whole body with the baseline's content via a slice that
178
- // spans the baseline's open boundaries (always 0 for a top-level
179
- // doc replace).
180
- tr.replaceWith(0, docEnd, ds.baseline.content)
181
- const meta: ClearMeta = { type: 'clear' }
182
- tr.setMeta(aiInlineDiffPluginKey, meta)
183
- if (dispatch) dispatch(tr)
184
- return true
185
- },
186
- }
187
- },
188
-
189
- addProseMirrorPlugins() {
190
- const ext = this
191
- return [
192
- new Plugin<DiffState | null>({
193
- key: aiInlineDiffPluginKey,
194
- state: {
195
- init() { return null },
196
- apply(tr, value) {
197
- const meta = tr.getMeta(aiInlineDiffPluginKey) as DiffMeta | undefined
198
- if (meta?.type === 'start') {
199
- // Baseline captured BEFORE the replaceRange step in this
200
- // same transaction. The changeset's `addSteps` consumes
201
- // the transaction's step list to compute the diff between
202
- // the baseline doc and the post-transaction doc.
203
- const cs = ChangeSet.create(meta.baseline).addSteps(tr.doc, tr.mapping.maps, null)
204
- return { id: meta.id, baseline: meta.baseline, changeset: cs }
205
- }
206
- if (meta?.type === 'clear') return null
207
- if (!value) return value
208
- // No explicit meta — a regular transaction landed while the
209
- // diff was active. Fold its steps into the changeset so any
210
- // further edits (e.g. y-prosemirror remote ops) are reflected.
211
- if (tr.docChanged) {
212
- const cs = value.changeset.addSteps(tr.doc, tr.mapping.maps, null)
213
- return { ...value, changeset: cs }
214
- }
215
- return value
216
- },
217
- },
218
- props: {
219
- decorations(state) {
220
- const ds = aiInlineDiffPluginKey.getState(state)
221
- if (!ds) return DecorationSet.empty
222
- return buildDiffDecorations(state, ds, ext.options.classPrefix ?? 'pilotiq-ai-diff')
223
- },
224
- },
225
- }),
226
- ]
227
- },
228
- })
229
-
230
- function buildDiffDecorations(
231
- state: EditorState,
232
- ds: DiffState,
233
- prefix: string,
234
- ): DecorationSet {
235
- const decos: Decoration[] = []
236
- const docSize = state.doc.content.size
237
-
238
- for (const change of ds.changeset.changes) {
239
- // `fromB..toB` is the range in the CURRENT doc that holds the
240
- // inserted content. `fromA..toA` is the range in the BASELINE doc
241
- // that was removed. `inserted` / `deleted` are Span[] arrays whose
242
- // `length` sums to (toB - fromB) / (toA - fromA) respectively.
243
- const fromB = Math.max(0, Math.min(change.fromB, docSize))
244
- const toB = Math.max(fromB, Math.min(change.toB, docSize))
245
-
246
- if (toB > fromB) {
247
- decos.push(
248
- Decoration.inline(fromB, toB, {
249
- class: `${prefix}-inserted`,
250
- 'data-pilotiq-ai-diff-id': ds.id,
251
- }),
252
- )
253
- }
254
-
255
- // Deleted text — pull from the baseline using the `fromA..toA` range.
256
- // Render via a widget at the change's insert-point (or end of insert)
257
- // so the deleted text appears immediately before / after the new run.
258
- // Empty deletions (pure inserts) skip the widget.
259
- if (change.toA > change.fromA) {
260
- const deletedText = ds.baseline.textBetween(change.fromA, change.toA, '\n', ' ')
261
- if (deletedText.length > 0) {
262
- decos.push(
263
- Decoration.widget(fromB, () => buildDeletedWidget(deletedText, prefix, ds.id), {
264
- side: -1,
265
- ignoreSelection: true,
266
- key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
267
- }),
268
- )
269
- }
270
- }
271
- }
272
-
273
- return DecorationSet.create(state.doc, decos)
274
- }
275
-
276
- function buildDeletedWidget(text: string, prefix: string, id: string): HTMLElement {
277
- const root = document.createElement('span')
278
- root.className = `${prefix}-deleted`
279
- root.setAttribute('data-pilotiq-ai-diff-id', id)
280
- root.contentEditable = 'false'
281
- const inner = document.createElement('span')
282
- inner.className = `${prefix}-deleted-text`
283
- inner.textContent = text
284
- root.appendChild(inner)
285
- return root
286
- }
@@ -1,141 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {
4
- upsertSuggestion,
5
- upsertSuggestions,
6
- removeSuggestion,
7
- remapSuggestions,
8
- sortForApproveAll,
9
- clampPos,
10
- type AiSuggestion,
11
- } from './AiSuggestionExtension.js'
12
-
13
- const make = (id: string, from: number, to: number, replacement = '…'): AiSuggestion => ({
14
- id, from, to, replacement,
15
- })
16
-
17
- describe('upsertSuggestion', () => {
18
- it('appends a new suggestion when id is unseen', () => {
19
- const list = [make('a', 0, 4)]
20
- const out = upsertSuggestion(list, make('b', 8, 12))
21
- assert.equal(out.length, 2)
22
- assert.equal(out[1]!.id, 'b')
23
- })
24
-
25
- it('replaces in place when id already exists', () => {
26
- const list = [make('a', 0, 4, 'old'), make('b', 8, 12, 'old')]
27
- const out = upsertSuggestion(list, make('a', 0, 4, 'new'))
28
- assert.equal(out.length, 2)
29
- assert.equal(out[0]!.replacement, 'new')
30
- assert.equal(out[1]!.id, 'b')
31
- })
32
-
33
- it('does not mutate the input array', () => {
34
- const list = [make('a', 0, 4)]
35
- upsertSuggestion(list, make('b', 8, 12))
36
- assert.equal(list.length, 1)
37
- })
38
- })
39
-
40
- describe('upsertSuggestions', () => {
41
- it('folds multiple inserts and replacements', () => {
42
- const list = [make('a', 0, 4, 'old')]
43
- const out = upsertSuggestions(list, [
44
- make('a', 0, 4, 'new'),
45
- make('b', 8, 12),
46
- make('c', 16, 20),
47
- ])
48
- assert.deepEqual(out.map(s => s.id), ['a', 'b', 'c'])
49
- assert.equal(out[0]!.replacement, 'new')
50
- })
51
- })
52
-
53
- describe('removeSuggestion', () => {
54
- it('drops only the matching id', () => {
55
- const list = [make('a', 0, 4), make('b', 8, 12)]
56
- const out = removeSuggestion(list, 'a')
57
- assert.deepEqual(out.map(s => s.id), ['b'])
58
- })
59
-
60
- it('returns the same shape when id is unseen', () => {
61
- const list = [make('a', 0, 4)]
62
- const out = removeSuggestion(list, 'unseen')
63
- assert.deepEqual(out, list)
64
- })
65
- })
66
-
67
- describe('remapSuggestions', () => {
68
- it('shifts ranges through a forward-shift mapping', () => {
69
- const list = [make('a', 10, 14)]
70
- const out = remapSuggestions(list, (pos) => pos + 5)
71
- assert.equal(out[0]!.from, 15)
72
- assert.equal(out[0]!.to, 19)
73
- })
74
-
75
- it('drops ranges that collapsed past each other', () => {
76
- const list = [make('a', 10, 14), make('b', 20, 24)]
77
- // Map collapses everything to position 0 — `to (-1 in mapping bias) < from`.
78
- const out = remapSuggestions(list, (pos, side) => (side === -1 ? pos : 0))
79
- assert.equal(out.length, 0)
80
- })
81
-
82
- it('keeps a pure-insertion range (`from === to`) when it survives the mapping', () => {
83
- const list = [{ ...make('a', 5, 5), replacement: 'inserted' }]
84
- const out = remapSuggestions(list, (pos) => pos + 3)
85
- assert.equal(out.length, 1)
86
- assert.equal(out[0]!.from, 8)
87
- assert.equal(out[0]!.to, 8)
88
- })
89
-
90
- it('biases sides — `from` left, `to` right — to keep insertions stable under collapsed text', () => {
91
- const list = [make('a', 5, 10)]
92
- // A mapping that collapses at exactly `pos = 5` — left bias keeps `from`
93
- // anchored at 5; right bias for `to = 10` shifts it to 10. Range survives.
94
- const out = remapSuggestions(list, (pos, side) => {
95
- if (pos === 5 && side === -1) return 5
96
- if (pos === 5 && side === 1) return 7
97
- if (pos === 10 && side === -1) return 7
98
- if (pos === 10 && side === 1) return 10
99
- return pos
100
- })
101
- assert.equal(out.length, 1)
102
- assert.equal(out[0]!.from, 5)
103
- assert.equal(out[0]!.to, 10)
104
- })
105
- })
106
-
107
- describe('sortForApproveAll', () => {
108
- it('orders highest-`from` first so earlier positions are stable across replacements', () => {
109
- const list = [make('a', 0, 4), make('c', 20, 24), make('b', 10, 14)]
110
- const out = sortForApproveAll(list)
111
- assert.deepEqual(out.map(s => s.id), ['c', 'b', 'a'])
112
- })
113
-
114
- it('does not mutate the input', () => {
115
- const list = [make('a', 0, 4), make('b', 10, 14)]
116
- sortForApproveAll(list)
117
- assert.deepEqual(list.map(s => s.id), ['a', 'b'])
118
- })
119
- })
120
-
121
- describe('clampPos', () => {
122
- it('passes through positions within range', () => {
123
- assert.equal(clampPos(5, 10), 5)
124
- assert.equal(clampPos(0, 10), 0)
125
- assert.equal(clampPos(10, 10), 10)
126
- })
127
-
128
- it('floors negatives at 0 and ceils overshoots at max', () => {
129
- assert.equal(clampPos(-5, 10), 0)
130
- assert.equal(clampPos(99, 10), 10)
131
- })
132
-
133
- it('returns 0 for non-finite input', () => {
134
- assert.equal(clampPos(NaN, 10), 0)
135
- assert.equal(clampPos(Infinity, 10), 0)
136
- })
137
-
138
- it('truncates fractional positions', () => {
139
- assert.equal(clampPos(3.7, 10), 3)
140
- })
141
- })