@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,19 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { getFieldRenderer } from '@pilotiq/pilotiq/react'
4
- import { Pilotiq } from '@pilotiq/pilotiq'
5
- import { tiptap } from './plugin.js'
6
-
7
- describe('tiptap() plugin', () => {
8
- it('exposes a Pilotiq plugin shape', () => {
9
- const plugin = tiptap()
10
- assert.equal(plugin.name, '@pilotiq/tiptap')
11
- assert.equal(typeof plugin.register, 'function')
12
- })
13
-
14
- it('plugins([tiptap()]) wires the richtext field renderer', () => {
15
- Pilotiq.make('test').plugins([tiptap()])
16
- const renderer = getFieldRenderer('richtext')
17
- assert.equal(typeof renderer, 'function')
18
- })
19
- })
package/src/plugin.ts DELETED
@@ -1,26 +0,0 @@
1
- import type { PilotiqPlugin } from '@pilotiq/pilotiq'
2
- import { registerTiptap } from './register.js'
3
-
4
- /**
5
- * Pilotiq plugin that registers the Tiptap rich-text renderer.
6
- *
7
- * Use with `Pilotiq.make(...).plugins([tiptap()])` instead of calling
8
- * `registerTiptap()` directly from your app's client entry — the plugin
9
- * runs through the panel module so it's wired in one place.
10
- *
11
- * @example
12
- * ```ts
13
- * import { Pilotiq } from '@pilotiq/pilotiq'
14
- * import { tiptap } from '@pilotiq/tiptap'
15
- *
16
- * Pilotiq.make('Admin').plugins([tiptap()])
17
- * ```
18
- */
19
- export function tiptap(): PilotiqPlugin {
20
- return {
21
- name: '@pilotiq/tiptap',
22
- register() {
23
- registerTiptap()
24
- },
25
- }
26
- }
@@ -1,185 +0,0 @@
1
- import { useMemo } from 'react'
2
- import {
3
- usePendingSuggestionsForField,
4
- usePendingSuggestions,
5
- type PendingSuggestion,
6
- } from '@pilotiq/pilotiq/react'
7
-
8
- /**
9
- * Bottom-of-editor banner UI for whole-field AI suggestions on Tiptap
10
- * surfaces whose content shape can't survive the inline chip widget's
11
- * plain-text replace (richtext, markdown). The chip path renders the
12
- * replacement via `Element.textContent = replacement` which surfaces raw
13
- * HTML / markdown as literal text — fine for plain `TextField`, ugly for
14
- * the others.
15
- *
16
- * Visible only when at least one pending suggestion targets this field
17
- * AND lacks `meta.editorRange` (i.e. a whole-field replacement from
18
- * `update_form_state`'s `set_value` op). Range-anchored suggestions stay
19
- * on the editor-side chip widget path — those have a precise location
20
- * the user wants to see in context.
21
- *
22
- * Phase 1 ships banner-only ("Changes suggested — Accept / Reject"); no
23
- * inline diff visualization yet. Phase 2 will replace the banner-only
24
- * UX with a `prosemirror-changeset`-driven inline diff on the editor's
25
- * doc itself, with the banner staying as the global Accept-all / Reject
26
- * control bar. See `[[project_pilotiq_text_field_tiptap_rules]]`.
27
- *
28
- * Approve runs the renderer-supplied `onApplyWholeField(value)` callback
29
- * AND dismisses the suggestion from the queue. Reject just dismisses
30
- * (no doc mutation). Multiple pending whole-field suggestions on the
31
- * same field stack — Accept all / Reject all collapse the queue in one
32
- * pass.
33
- */
34
- export interface AiSuggestionBannerProps {
35
- /** Field name, matches the suggestion's `fieldName`. */
36
- fieldName: string
37
- /**
38
- * Apply a whole-field suggestion to the underlying editor. Receives the
39
- * raw `suggestedValue` string from the suggestion. The renderer wires
40
- * its own content-shape-aware `setContent` here (markdown source for
41
- * MarkdownEditor, HTML / JSON for TiptapEditor).
42
- *
43
- * Skipped when `onAcceptViaEditor` is supplied — that path means the
44
- * editor already holds the proposed state via `AiInlineDiffExtension`,
45
- * and Accept routes through `acceptAiInlineDiff()` instead. The host
46
- * still calls `pendingSuggestions.approve(id)` afterwards to dismiss
47
- * the queue entry.
48
- */
49
- onApplyWholeField: (suggestedValue: string) => void
50
- /**
51
- * Diff-aware Accept hook. When supplied, the banner calls this first
52
- * (so the editor commits its diff state) and then dismisses via the
53
- * context. `onApplyWholeField` is NOT called in this mode — the
54
- * editor's current doc is already the accepted state.
55
- *
56
- * Sparse so the simple banner path (Phase 1, no diff) keeps its
57
- * existing semantics.
58
- */
59
- onAcceptViaEditor?: () => void
60
- /**
61
- * Diff-aware Reject hook. When supplied, the banner calls this first
62
- * (so the editor reverts to the baseline) and then dismisses via the
63
- * context. Sparse — see `onAcceptViaEditor`.
64
- */
65
- onRejectViaEditor?: () => void
66
- /** Optional class on the outer banner element. Defaults to a minimal styled chrome. */
67
- className?: string
68
- }
69
-
70
- /**
71
- * Hook variant — returns banner state without rendering, for renderers
72
- * that want to compose their own chrome. Renderer-agnostic.
73
- */
74
- export function useAiSuggestionBanner(fieldName: string): {
75
- pending: readonly PendingSuggestion[]
76
- approveAll: (apply: (value: string) => void) => void
77
- rejectAll: () => void
78
- } {
79
- const { list, dismiss } = usePendingSuggestionsForField(fieldName)
80
-
81
- // Only whole-field suggestions land in the banner. Range-anchored ones
82
- // ride the editor chip widget.
83
- const pending = useMemo(
84
- () => list.filter(s => !hasEditorRange(s)),
85
- [list],
86
- )
87
-
88
- const approveAll = (apply: (value: string) => void): void => {
89
- for (const s of pending) {
90
- if (typeof s.suggestedValue === 'string') apply(s.suggestedValue)
91
- dismiss(s.id)
92
- }
93
- }
94
-
95
- const rejectAll = (): void => {
96
- for (const s of pending) dismiss(s.id)
97
- }
98
-
99
- return { pending, approveAll, rejectAll }
100
- }
101
-
102
- function hasEditorRange(s: PendingSuggestion): boolean {
103
- const meta = (s.meta ?? {}) as Record<string, unknown>
104
- const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
105
- return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
106
- }
107
-
108
- export function AiSuggestionBanner({
109
- fieldName,
110
- onApplyWholeField,
111
- onAcceptViaEditor,
112
- onRejectViaEditor,
113
- className,
114
- }: AiSuggestionBannerProps): React.ReactElement | null {
115
- const { pending, approveAll, rejectAll } = useAiSuggestionBanner(fieldName)
116
- const { dismiss } = usePendingSuggestions()
117
-
118
- if (pending.length === 0) return null
119
-
120
- // First (and usually only) pending suggestion drives the agent-label
121
- // display. Multiple-at-once is rare in practice — the banner shows the
122
- // most recent producer to keep the chrome compact.
123
- const head = pending[0]!
124
- const sourceLabel = head.source?.agentLabel ?? null
125
-
126
- const handleAccept = (): void => {
127
- // Diff-active path — editor's current doc IS the accepted state.
128
- // Commit via the editor command, then drop the queue entries.
129
- if (onAcceptViaEditor) {
130
- onAcceptViaEditor()
131
- for (const s of pending) dismiss(s.id)
132
- return
133
- }
134
- approveAll(onApplyWholeField)
135
- }
136
-
137
- const handleReject = (): void => {
138
- // Diff-active path — editor still holds the proposed state; revert
139
- // to the captured baseline before dismissing.
140
- if (onRejectViaEditor) {
141
- onRejectViaEditor()
142
- for (const s of pending) dismiss(s.id)
143
- return
144
- }
145
- rejectAll()
146
- }
147
-
148
- // Per-suggestion controls when there's more than one — keeps the UX
149
- // discoverable. Single suggestion: Accept / Reject only.
150
- const single = pending.length === 1
151
-
152
- return (
153
- <div
154
- role="region"
155
- aria-label="AI suggested changes"
156
- data-pilotiq-ai-banner=""
157
- className={className ?? 'pilotiq-ai-banner'}
158
- >
159
- <span className="pilotiq-ai-banner-icon" aria-hidden="true">💡</span>
160
- <span className="pilotiq-ai-banner-label">
161
- {single
162
- ? sourceLabel
163
- ? `Changes suggested by ${sourceLabel}`
164
- : 'Changes suggested'
165
- : `${pending.length} changes suggested`}
166
- </span>
167
- <div className="pilotiq-ai-banner-actions">
168
- <button
169
- type="button"
170
- className="pilotiq-ai-banner-reject"
171
- onClick={handleReject}
172
- >
173
- {single ? 'Reject' : 'Reject all'}
174
- </button>
175
- <button
176
- type="button"
177
- className="pilotiq-ai-banner-accept"
178
- onClick={handleAccept}
179
- >
180
- {single ? 'Accept' : 'Accept all'}
181
- </button>
182
- </div>
183
- </div>
184
- )
185
- }
@@ -1,99 +0,0 @@
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
- }
@@ -1,38 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import React from 'react'
4
- import { render, cleanup, screen } from '@testing-library/react'
5
-
6
- import { clampPanelWidth } from './BlockSidePanel.js'
7
-
8
- /**
9
- * Phase 6e proof-of-concept — exercise React Testing Library's
10
- * `render()` against the jsdom environment that `src/test/setup.ts`
11
- * boots. The pure helper `clampPanelWidth` is already covered by the
12
- * neighbouring `BlockSidePanel.test.ts`; this file proves the RTL
13
- * surface (render / screen / cleanup) actually works in the test
14
- * harness — every future component-level test for `BlockSidePanel`,
15
- * `Toolbar`, `SlashMenu`, etc. uses the same primitives.
16
- */
17
- describe('RTL render() (DOM)', () => {
18
- it('renders a trivial React tree and queries it via `screen`', () => {
19
- render(<div data-testid="probe">hello tiptap</div>)
20
- try {
21
- const node = screen.getByTestId('probe')
22
- assert.equal(node.textContent, 'hello tiptap')
23
- } finally {
24
- cleanup()
25
- }
26
- })
27
-
28
- it('exports clampPanelWidth as a stable pure helper (sanity)', () => {
29
- // Cross-check: pure-data assertions still work alongside RTL
30
- // mounts. `clampPanelWidth` is the helper tested via the
31
- // pure-mode `BlockSidePanel.test.ts` already — re-asserting one
32
- // case here confirms the dual-import surface (both pure tests
33
- // and DOM tests in the same package) doesn't conflict.
34
- assert.equal(clampPanelWidth(100), 240)
35
- assert.equal(clampPanelWidth(320), 320)
36
- assert.equal(clampPanelWidth(1000), 600)
37
- })
38
- })