@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,147 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildSlashItems } from './SlashCommandExtension.js'
4
+
5
+ describe('SlashCommandExtension built-ins', () => {
6
+ it('always exposes the Table entry', () => {
7
+ const items = buildSlashItems([], [], '', {
8
+ hasUpload: false,
9
+ onInsertImage: () => {},
10
+ })
11
+ const table = items.find((i) => i.key === 'table')
12
+ assert.ok(table, 'expected a "table" slash entry')
13
+ assert.equal(table!.label, 'Table')
14
+ assert.equal(table!.group, 'Insert')
15
+ })
16
+
17
+ it('omits the Image entry when no upload adapter is wired', () => {
18
+ const items = buildSlashItems([], [], '', {
19
+ hasUpload: false,
20
+ onInsertImage: () => {},
21
+ })
22
+ const image = items.find((i) => i.key === 'image')
23
+ assert.equal(image, undefined)
24
+ })
25
+
26
+ it('surfaces the Image entry when hasUpload is true', () => {
27
+ const items = buildSlashItems([], [], '', {
28
+ hasUpload: true,
29
+ onInsertImage: () => {},
30
+ })
31
+ const image = items.find((i) => i.key === 'image')
32
+ assert.ok(image, 'expected an "image" slash entry when hasUpload')
33
+ assert.equal(image!.label, 'Image')
34
+ assert.equal(image!.group, 'Insert')
35
+ })
36
+
37
+ it('Image command fires the onInsertImage callback', () => {
38
+ let calls = 0
39
+ const items = buildSlashItems([], [], '', {
40
+ hasUpload: true,
41
+ onInsertImage: () => { calls += 1 },
42
+ })
43
+ const image = items.find((i) => i.key === 'image')
44
+ // Stand-in editor — the slash command chains through `.deleteRange(...)`
45
+ // before firing the callback. We don't assert on the chain output;
46
+ // confirming `calls === 1` proves the wire is connected.
47
+ const stubChain = {
48
+ focus: () => stubChain,
49
+ deleteRange: () => stubChain,
50
+ run: () => true,
51
+ }
52
+ image!.command({
53
+ editor: { chain: () => stubChain } as never,
54
+ range: { from: 0, to: 0 } as never,
55
+ })
56
+ assert.equal(calls, 1)
57
+ })
58
+
59
+ it('respects the search query against label / searchKey / group', () => {
60
+ const items = buildSlashItems([], [], 'tab', {
61
+ hasUpload: true,
62
+ onInsertImage: () => {},
63
+ })
64
+ assert.ok(items.some((i) => i.key === 'table'),
65
+ 'expected `table` to match the "tab" query via label/searchKey')
66
+ assert.equal(items.some((i) => i.key === 'paragraph'), false,
67
+ 'expected unrelated entries to drop out of the filtered list')
68
+ })
69
+
70
+ it('exposes lead + small entries under the Style group', () => {
71
+ const items = buildSlashItems([], [], '', {
72
+ hasUpload: false,
73
+ onInsertImage: () => {},
74
+ })
75
+ const lead = items.find((i) => i.key === 'lead')
76
+ const small = items.find((i) => i.key === 'small')
77
+ assert.ok(lead, 'expected a "lead" slash entry')
78
+ assert.ok(small, 'expected a "small" slash entry')
79
+ assert.equal(lead!.group, 'Style')
80
+ assert.equal(small!.group, 'Style')
81
+ })
82
+
83
+ it('always exposes the Details entry under the Insert group', () => {
84
+ const items = buildSlashItems([], [], '', {
85
+ hasUpload: false,
86
+ onInsertImage: () => {},
87
+ })
88
+ const details = items.find((i) => i.key === 'details')
89
+ assert.ok(details, 'expected a "details" slash entry')
90
+ assert.equal(details!.label, 'Collapsible block')
91
+ assert.equal(details!.group, 'Insert')
92
+ })
93
+
94
+ it('Details search matches "collapsible" / "summary" / "toggle"', () => {
95
+ for (const q of ['collapsible', 'summary', 'toggle']) {
96
+ const items = buildSlashItems([], [], q, {
97
+ hasUpload: false,
98
+ onInsertImage: () => {},
99
+ })
100
+ assert.ok(
101
+ items.some((i) => i.key === 'details'),
102
+ `expected the Details entry to match search query "${q}"`,
103
+ )
104
+ }
105
+ })
106
+
107
+ it('exposes Two-column and Three-column grid entries under Insert', () => {
108
+ const items = buildSlashItems([], [], '', {
109
+ hasUpload: false,
110
+ onInsertImage: () => {},
111
+ })
112
+ const two = items.find((i) => i.key === 'grid-2')
113
+ const three = items.find((i) => i.key === 'grid-3')
114
+ assert.ok(two, 'expected a "grid-2" slash entry')
115
+ assert.ok(three, 'expected a "grid-3" slash entry')
116
+ assert.equal(two!.label, 'Two-column grid')
117
+ assert.equal(two!.group, 'Insert')
118
+ assert.equal(three!.label, 'Three-column grid')
119
+ assert.equal(three!.group, 'Insert')
120
+ })
121
+
122
+ it('Grid search matches "columns" / "split" / "layout"', () => {
123
+ for (const q of ['columns', 'split', 'layout']) {
124
+ const items = buildSlashItems([], [], q, {
125
+ hasUpload: false,
126
+ onInsertImage: () => {},
127
+ })
128
+ assert.ok(
129
+ items.some((i) => i.key === 'grid-2'),
130
+ `expected the Two-column grid entry to match search query "${q}"`,
131
+ )
132
+ }
133
+ })
134
+
135
+ it('Three-column grid distinguishes by "three" / "3"', () => {
136
+ for (const q of ['three', '3']) {
137
+ const items = buildSlashItems([], [], q, {
138
+ hasUpload: false,
139
+ onInsertImage: () => {},
140
+ })
141
+ assert.ok(
142
+ items.some((i) => i.key === 'grid-3'),
143
+ `expected the Three-column grid entry to match search query "${q}"`,
144
+ )
145
+ }
146
+ })
147
+ })
@@ -0,0 +1,332 @@
1
+ import { Extension, type Editor, type Range } from '@tiptap/core'
2
+ import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
3
+ import { PluginKey } from '@tiptap/pm/state'
4
+
5
+ const SLASH_PLUGIN_KEY = new PluginKey('pilotiqSlashSuggestion')
6
+ import type { BlockMeta } from '../Block.js'
7
+
8
+ export interface SlashItem {
9
+ /** Stable id used to dedupe + as React key. */
10
+ key: string
11
+ label: string
12
+ icon: string | undefined
13
+ group?: string
14
+ /** Free-text searched against label + group. */
15
+ searchKey: string
16
+ /** Run when the user picks this item. `range` is the slash + query slice. */
17
+ command: (args: { editor: Editor; range: Range }) => void
18
+ }
19
+
20
+ /**
21
+ * State the React side of the editor needs to render the slash menu. Set to
22
+ * `null` when the menu should be unmounted.
23
+ */
24
+ export interface SlashState {
25
+ items: SlashItem[]
26
+ /** Pick item — Suggestion will replace the slash range and run the command. */
27
+ command: (item: SlashItem) => void
28
+ /**
29
+ * Cursor / range coords in viewport space. Re-call this every layout tick
30
+ * (the popover positioner does) so scroll/resize tracking comes for free.
31
+ */
32
+ clientRect: () => DOMRect | null
33
+ query: string
34
+ }
35
+
36
+ export interface SlashCommandOptions {
37
+ /** Custom blocks contributed by RichTextField.blocks([...]). */
38
+ blocks: BlockMeta[]
39
+ /** Merge-tag identifiers contributed by RichTextField.mergeTags([...]). */
40
+ mergeTags: string[]
41
+ /**
42
+ * Called whenever the menu should mount, update, or unmount. TiptapEditor
43
+ * holds the React state and passes a setter here.
44
+ */
45
+ onStateChange: (state: SlashState | null) => void
46
+ /**
47
+ * `true` when the panel has wired an `UploadAdapter` (mirrors the
48
+ * toolbar's `attachFiles` gating). Drives whether the "Image" entry
49
+ * appears in the menu — it would have nowhere to upload otherwise.
50
+ */
51
+ hasUpload: boolean
52
+ /**
53
+ * Called when the user picks the "Image" slash entry. The slash range
54
+ * is already deleted before this fires; the callback just opens the
55
+ * shared attach-files dialog (whose UI lives in `Toolbar`).
56
+ */
57
+ onInsertImage: () => void
58
+ }
59
+
60
+ /**
61
+ * `/`-triggered slash menu. The plugin owns the suggestion lifecycle (range
62
+ * detection + command invocation); rendering is React-side via a Base UI
63
+ * Popover anchored to a virtual element. Keyboard events are NOT forwarded
64
+ * through the Suggestion plugin — TiptapEditor installs a document-level
65
+ * capture-phase keydown listener while the menu is open, because Base UI's
66
+ * focus manager can briefly steal focus from the editor when the popup mounts
67
+ * and the suggestion plugin's `handleKeyDown` only fires when the editor has
68
+ * focus.
69
+ */
70
+ export const SlashCommandExtension = Extension.create<SlashCommandOptions>({
71
+ name: 'slashCommand',
72
+
73
+ addOptions() {
74
+ return {
75
+ blocks: [],
76
+ mergeTags: [],
77
+ onStateChange: () => {},
78
+ hasUpload: false,
79
+ onInsertImage: () => {},
80
+ }
81
+ },
82
+
83
+ addProseMirrorPlugins() {
84
+ const blocks = this.options.blocks
85
+ const mergeTags = this.options.mergeTags
86
+ const emit = this.options.onStateChange
87
+ const hasUpload = this.options.hasUpload
88
+ const onInsertImage = this.options.onInsertImage
89
+
90
+ return [
91
+ Suggestion({
92
+ pluginKey: SLASH_PLUGIN_KEY,
93
+ editor: this.editor,
94
+ char: '/',
95
+ startOfLine: false,
96
+ allowSpaces: false,
97
+ items: ({ query }: { query: string }) => buildSlashItems(blocks, mergeTags, query, { hasUpload, onInsertImage }),
98
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
99
+ props.command({ editor, range })
100
+ },
101
+ render: () => ({
102
+ onStart: (props) => emit(stateFrom(props)),
103
+ onUpdate: (props) => emit(stateFrom(props)),
104
+ // Keys are handled at the document level by TiptapEditor; nothing
105
+ // to do here. Returning false lets PM's keymap handle anything we
106
+ // don't intercept (typing more of the slash query, etc.).
107
+ onKeyDown: () => false,
108
+ onExit: () => emit(null),
109
+ }),
110
+ } satisfies SuggestionOptions<SlashItem, SlashItem>),
111
+ ]
112
+ },
113
+ })
114
+
115
+ function stateFrom(props: {
116
+ items: SlashItem[]
117
+ command: (item: SlashItem) => void
118
+ clientRect?: (() => DOMRect | null) | null
119
+ query: string
120
+ }): SlashState {
121
+ return {
122
+ items: props.items,
123
+ command: props.command,
124
+ clientRect: props.clientRect ?? (() => null),
125
+ query: props.query,
126
+ }
127
+ }
128
+
129
+ export interface SlashInsertEntries {
130
+ hasUpload: boolean
131
+ onInsertImage: () => void
132
+ }
133
+
134
+ // Built-in items mirror the standard rich-text-editor slash menu. Custom
135
+ // blocks append, then merge-tag placeholders.
136
+ //
137
+ // Exported so tests can pin down the menu contents without spinning up an
138
+ // editor instance — the function is pure and deterministic given its
139
+ // inputs.
140
+ export function buildSlashItems(
141
+ blocks: BlockMeta[],
142
+ mergeTags: string[],
143
+ query: string,
144
+ insert: SlashInsertEntries,
145
+ ): SlashItem[] {
146
+ const builtins: SlashItem[] = [
147
+ {
148
+ key: 'paragraph', label: 'Text', icon: '¶', group: 'Basic',
149
+ searchKey: 'text paragraph p',
150
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setNode('paragraph').run(),
151
+ },
152
+ ...[1, 2, 3, 4, 5, 6].map((level) => ({
153
+ key: `heading-${level}`,
154
+ label: `Heading ${level}`,
155
+ icon: `H${level}`,
156
+ group: 'Headings',
157
+ searchKey: `heading ${level} h${level}${level === 1 ? ' title' : level === 2 ? ' subtitle' : ''}`,
158
+ command: ({ editor, range }: { editor: Editor; range: Range }) =>
159
+ editor.chain().focus().deleteRange(range).setNode('heading', { level }).run(),
160
+ })),
161
+ {
162
+ key: 'bullet-list', label: 'Bullet list', icon: '•', group: 'Lists',
163
+ searchKey: 'bullet list ul unordered',
164
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBulletList().run(),
165
+ },
166
+ {
167
+ key: 'ordered-list', label: 'Numbered list', icon: '1.', group: 'Lists',
168
+ searchKey: 'numbered ordered list ol',
169
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
170
+ },
171
+ {
172
+ key: 'quote', label: 'Quote', icon: '❝', group: 'Basic',
173
+ searchKey: 'quote blockquote',
174
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
175
+ },
176
+ {
177
+ key: 'code', label: 'Code block', icon: '</>', group: 'Basic',
178
+ searchKey: 'code block pre',
179
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
180
+ },
181
+ {
182
+ key: 'hr', label: 'Divider', icon: '—', group: 'Basic',
183
+ searchKey: 'divider hr horizontal rule',
184
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
185
+ },
186
+ // 3×3 table with a header row — same shape as the toolbar `table` button.
187
+ // No upload gate; tables are pure schema, available everywhere.
188
+ {
189
+ key: 'table', label: 'Table', icon: '⊞', group: 'Insert',
190
+ searchKey: 'table grid rows columns',
191
+ command: ({ editor, range }) =>
192
+ editor.chain().focus().deleteRange(range)
193
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
194
+ },
195
+ // Collapsible `<details>` block. `setDetails` wraps the cursor's
196
+ // paragraph in a details node with an empty summary; the user starts
197
+ // typing the summary and presses Enter to drop into the body. No
198
+ // upload gate — pure schema, available everywhere.
199
+ {
200
+ key: 'details', label: 'Collapsible block', icon: '▸', group: 'Insert',
201
+ searchKey: 'details collapsible disclosure summary toggle expand',
202
+ command: ({ editor, range }) =>
203
+ editor.chain().focus().deleteRange(range).setDetails().run(),
204
+ },
205
+ // Multi-column grid layout. Two distinct entries (2-col + 3-col) so
206
+ // the user picks the column count from the slash menu directly,
207
+ // matching how the toolbar's `grid` button defaults to 2 cols.
208
+ {
209
+ key: 'grid-2', label: 'Two-column grid', icon: '⊞', group: 'Insert',
210
+ searchKey: 'grid columns layout 2 two split side',
211
+ command: ({ editor, range }) =>
212
+ editor.chain().focus().deleteRange(range).setGrid({ columns: 2 }).run(),
213
+ },
214
+ {
215
+ key: 'grid-3', label: 'Three-column grid', icon: '⊞', group: 'Insert',
216
+ searchKey: 'grid columns layout 3 three split',
217
+ command: ({ editor, range }) =>
218
+ editor.chain().focus().deleteRange(range).setGrid({ columns: 3 }).run(),
219
+ },
220
+ // Image entry shares the toolbar's attach-files dialog; only surfaced
221
+ // when the panel has wired an `UploadAdapter`. Without one, the dialog
222
+ // would post to a missing endpoint — the slash item degrades the same
223
+ // way the toolbar's `attachFiles` button does (server-stripped at meta
224
+ // build time when no adapter is set).
225
+ ...(insert.hasUpload ? [{
226
+ key: 'image', label: 'Image', icon: '🖼', group: 'Insert',
227
+ searchKey: 'image upload media file attach',
228
+ command: ({ editor, range }: { editor: Editor; range: Range }) => {
229
+ // Drop the slash range first so the user doesn't return to a
230
+ // dangling `/image` after closing the dialog. Inserting the
231
+ // actual image happens inside the dialog's upload handler.
232
+ editor.chain().focus().deleteRange(range).run()
233
+ insert.onInsertImage()
234
+ },
235
+ }] satisfies SlashItem[] : []),
236
+ {
237
+ key: 'align-left', label: 'Align left', icon: '⇤', group: 'Align',
238
+ searchKey: 'align left start',
239
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('left').run(),
240
+ },
241
+ {
242
+ key: 'align-center', label: 'Align center', icon: '⇔', group: 'Align',
243
+ searchKey: 'align center middle',
244
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('center').run(),
245
+ },
246
+ {
247
+ key: 'align-right', label: 'Align right', icon: '⇥', group: 'Align',
248
+ searchKey: 'align right end',
249
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('right').run(),
250
+ },
251
+ {
252
+ key: 'clear-format', label: 'Clear formatting', icon: '⌫', group: 'Basic',
253
+ searchKey: 'clear formatting reset',
254
+ command: ({ editor, range }) =>
255
+ editor.chain().focus().deleteRange(range).clearNodes().unsetAllMarks().run(),
256
+ },
257
+ // Inline-mark size variants. Slash-menu form leaves the slash range in
258
+ // place rather than swallowing it, so the user runs the command on the
259
+ // word they were just typing — the alternative ("/lead" deletes the
260
+ // range, then user types more) requires re-positioning the cursor and
261
+ // breaks the "type-toggle-keep-typing" rhythm authors use most.
262
+ {
263
+ key: 'lead', label: 'Lead', icon: 'P+', group: 'Style',
264
+ searchKey: 'lead lede intro paragraph emphasis',
265
+ command: ({ editor, range }) =>
266
+ editor.chain().focus().deleteRange(range).toggleMark('lead').run(),
267
+ },
268
+ {
269
+ key: 'small', label: 'Small', icon: 'P-', group: 'Style',
270
+ searchKey: 'small fine print footnote caption',
271
+ command: ({ editor, range }) =>
272
+ editor.chain().focus().deleteRange(range).toggleMark('small').run(),
273
+ },
274
+ ]
275
+
276
+ const customs: SlashItem[] = blocks.map((b) => ({
277
+ key: `block:${b.name}`,
278
+ label: b.label,
279
+ icon: b.icon,
280
+ group: 'Blocks',
281
+ searchKey: `${b.label} ${b.name} block`,
282
+ command: ({ editor, range }) => {
283
+ // Use insertContent directly with explicit attrs rather than chaining
284
+ // through our custom `insertBlock` command — chained custom commands
285
+ // sometimes drop the `attrs` payload depending on Tiptap version.
286
+ editor
287
+ .chain()
288
+ .focus()
289
+ .deleteRange(range)
290
+ .insertContent({
291
+ type: 'pilotiqBlock',
292
+ attrs: {
293
+ blockType: b.name,
294
+ blockData: defaultsFromSchema(b),
295
+ },
296
+ })
297
+ .run()
298
+ },
299
+ }))
300
+
301
+ const merges: SlashItem[] = mergeTags.map((id) => ({
302
+ key: `merge-tag:${id}`,
303
+ label: `{{ ${id} }}`,
304
+ icon: '{{}}',
305
+ group: 'Merge tags',
306
+ searchKey: `merge tag placeholder ${id}`,
307
+ command: ({ editor, range }) => {
308
+ editor
309
+ .chain()
310
+ .focus()
311
+ .deleteRange(range)
312
+ .insertContent({ type: 'mergeTag', attrs: { id } })
313
+ .run()
314
+ },
315
+ }))
316
+
317
+ const all = [...builtins, ...customs, ...merges]
318
+ if (!query) return all
319
+
320
+ const needle = query.toLowerCase()
321
+ return all.filter((item) =>
322
+ `${item.label} ${item.searchKey} ${item.group ?? ''}`.toLowerCase().includes(needle),
323
+ )
324
+ }
325
+
326
+ function defaultsFromSchema(block: BlockMeta): Record<string, unknown> {
327
+ const out: Record<string, unknown> = {}
328
+ for (const f of block.schema) {
329
+ out[f.name] = ''
330
+ }
331
+ return out
332
+ }
@@ -0,0 +1,73 @@
1
+ import { Mark, mergeAttributes } from '@tiptap/core'
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ lead: {
6
+ /** Toggle the `lead` mark on the current selection. */
7
+ toggleLead: () => ReturnType
8
+ }
9
+ small: {
10
+ /** Toggle the `small` mark on the current selection. */
11
+ toggleSmall: () => ReturnType
12
+ }
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Two inline marks for paragraph-style size variants beyond the standard
18
+ * heading levels:
19
+ *
20
+ * - `lead` — opening / lede paragraph styling. Renders as
21
+ * `<span class="lead">…</span>` so authors keep paragraph
22
+ * semantics; styling is owned by the consumer's CSS (the
23
+ * adapter doesn't ship a `.lead` rule — every site already
24
+ * has one).
25
+ * - `small` — semantic `<small>` mark. Mirrors the HTML element so
26
+ * read-side renderers don't need a special class to style
27
+ * fine print.
28
+ *
29
+ * Both marks live in the standard inline group so they compose with bold,
30
+ * italic, color, etc. without exclusivity rules. Excluding each other isn't
31
+ * useful — a `<small lead>` selection would be inert visually anyway.
32
+ */
33
+ export const LeadMarkExtension = Mark.create({
34
+ name: 'lead',
35
+
36
+ parseHTML() {
37
+ return [{ tag: 'span.lead' }]
38
+ },
39
+
40
+ renderHTML({ HTMLAttributes }) {
41
+ return ['span', mergeAttributes({ class: 'lead' }, HTMLAttributes), 0]
42
+ },
43
+
44
+ addCommands() {
45
+ return {
46
+ toggleLead:
47
+ () =>
48
+ ({ commands }) =>
49
+ commands.toggleMark(this.name),
50
+ }
51
+ },
52
+ })
53
+
54
+ export const SmallMarkExtension = Mark.create({
55
+ name: 'small',
56
+
57
+ parseHTML() {
58
+ return [{ tag: 'small' }]
59
+ },
60
+
61
+ renderHTML({ HTMLAttributes }) {
62
+ return ['small', mergeAttributes(HTMLAttributes), 0]
63
+ },
64
+
65
+ addCommands() {
66
+ return {
67
+ toggleSmall:
68
+ () =>
69
+ ({ commands }) =>
70
+ commands.toggleMark(this.name),
71
+ }
72
+ },
73
+ })
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export {
2
+ RichTextField,
3
+ DEFAULT_TOOLBAR_GROUPS,
4
+ DEFAULT_TEXT_COLORS,
5
+ DEFAULT_HIGHLIGHT_COLORS,
6
+ type ColorSwatch,
7
+ type RichTextAttachmentVisibility,
8
+ type RichTextFieldMeta,
9
+ type RichTextStorage,
10
+ type ToolbarButtonId,
11
+ type ToolbarGroups,
12
+ } from './RichTextField.js'
13
+ export { Block, type BlockMeta } from './Block.js'
14
+ export {
15
+ MentionProvider,
16
+ type MentionItem,
17
+ type MentionProviderMeta,
18
+ } from './MentionProvider.js'
19
+ export { registerTiptap } from './register.js'
20
+ export { tiptap } from './plugin.js'
21
+ export { TiptapEditor } from './react/TiptapEditor.js'
22
+ export {
23
+ renderRichTextToHtml,
24
+ isRichTextValue,
25
+ type RenderRichTextOptions,
26
+ type TiptapNode,
27
+ type TiptapMark,
28
+ } from './render.js'
@@ -0,0 +1,19 @@
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 ADDED
@@ -0,0 +1,26 @@
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
+ }