@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,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
- })