@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,184 +0,0 @@
1
- import { Extension } from '@tiptap/core'
2
- import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
3
- import type { EditorView } from '@tiptap/pm/view'
4
-
5
- const dragHandlePluginKey = new PluginKey('pilotiqDragHandle')
6
-
7
- // Node types whose direct children are the draggable units (e.g. each
8
- // `listItem` of a `bulletList` is a draggable unit). Listed forward-compat
9
- // includes `taskList` even though StarterKit 3 doesn't ship it; if a consumer
10
- // registers it later it'll just work.
11
- const LIST_CONTAINERS = new Set(['bulletList', 'orderedList', 'taskList'])
12
-
13
- /**
14
- * Hand-built drag handle: floats a six-dot button in the gutter on the left
15
- * of whichever top-level block the cursor is hovering. Click-drag to reorder.
16
- *
17
- * Implementation (kept self-contained, no `tiptap-extension-global-drag-handle`
18
- * dep — that pkg uses Tiptap internals that break across versions):
19
- *
20
- * 1. On mount, append a single `<button>` to `document.body`. It's the
21
- * handle. Position lives in CSS `top` / `left`.
22
- * 2. On `mousemove` over the editor, find the top-level block under the
23
- * cursor via `posAtCoords` + climb to depth 1, then read its DOM rect
24
- * and align the handle to its top-left.
25
- * 3. On `mousedown` on the handle, set `node` selection on that block and
26
- * dispatch a synthetic `dragstart` from the editor DOM — ProseMirror's
27
- * own drop logic handles reorder.
28
- */
29
- export const DragHandleExtension = Extension.create({
30
- name: 'pilotiqDragHandle',
31
-
32
- addProseMirrorPlugins() {
33
- return [
34
- new Plugin({
35
- key: dragHandlePluginKey,
36
- view: (view) => createDragHandleView(view),
37
- }),
38
- ]
39
- },
40
- })
41
-
42
- function createDragHandleView(view: EditorView): {
43
- update?: () => void
44
- destroy: () => void
45
- } {
46
- const handle = document.createElement('button')
47
- handle.type = 'button'
48
- handle.setAttribute('data-pilotiq-drag-handle', '')
49
- handle.setAttribute('contenteditable', 'false')
50
- handle.setAttribute('aria-label', 'Drag block')
51
- handle.setAttribute('draggable', 'true')
52
- handle.style.cssText = [
53
- 'position: absolute',
54
- 'display: none',
55
- 'cursor: grab',
56
- 'background: transparent',
57
- 'border: 0',
58
- 'padding: 2px',
59
- 'color: var(--muted-foreground, #888)',
60
- 'opacity: 0',
61
- 'transition: opacity 0.15s',
62
- 'z-index: 50',
63
- 'line-height: 1',
64
- 'border-radius: 4px',
65
- ].join(';')
66
- handle.innerHTML = '<svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor" aria-hidden="true"><circle cx="2" cy="2" r="1"/><circle cx="2" cy="6" r="1"/><circle cx="2" cy="10" r="1"/><circle cx="6" cy="2" r="1"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="10" r="1"/></svg>'
67
- document.body.appendChild(handle)
68
-
69
- let activePos: number | null = null
70
-
71
- const onMouseMove = (event: MouseEvent): void => {
72
- const editorRect = view.dom.getBoundingClientRect()
73
- const inside =
74
- event.clientX >= editorRect.left - 60 &&
75
- event.clientX <= editorRect.right &&
76
- event.clientY >= editorRect.top &&
77
- event.clientY <= editorRect.bottom
78
- if (!inside) {
79
- hide()
80
- return
81
- }
82
- const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
83
- if (!pos) {
84
- hide()
85
- return
86
- }
87
- const $pos = view.state.doc.resolve(pos.pos)
88
- // Pick the deepest ancestor whose parent is the doc OR a list container —
89
- // that's the "unit" the user expects the handle to grab. So inside a
90
- // bullet list, hovering an item resolves to the list_item (not the whole
91
- // bullet_list); inside a blockquote, hovering its inner paragraph resolves
92
- // to the blockquote (since the blockquote's parent is the doc).
93
- let blockDepth = 1
94
- for (let d = $pos.depth; d >= 1; d--) {
95
- const parentName = $pos.node(d - 1).type.name
96
- if (parentName === 'doc' || LIST_CONTAINERS.has(parentName)) {
97
- blockDepth = d
98
- break
99
- }
100
- }
101
- const blockPos = $pos.before(blockDepth)
102
- const blockNode = view.nodeDOM(blockPos) as HTMLElement | null
103
- if (!blockNode) {
104
- hide()
105
- return
106
- }
107
- const rect = blockNode.getBoundingClientRect()
108
- handle.style.display = 'block'
109
- handle.style.opacity = '1'
110
- handle.style.top = `${rect.top + window.scrollY + 4}px`
111
- // Pin X to the editor's left gutter (not the block's left edge). For
112
- // nested content the block's `rect.left` sits past the indent / bullet
113
- // gutter, so handles for list items would overlap their bullets. Keeping
114
- // the handle in a fixed column lets the eye track which block it points
115
- // at by vertical alignment alone.
116
- handle.style.left = `${editorRect.left + window.scrollX + 16}px`
117
- activePos = blockPos
118
- }
119
-
120
- const hide = (): void => {
121
- handle.style.opacity = '0'
122
- handle.style.display = 'none'
123
- activePos = null
124
- }
125
-
126
- const onMouseLeave = (event: MouseEvent): void => {
127
- // Don't hide while moving onto the handle itself.
128
- if (event.relatedTarget === handle) return
129
- hide()
130
- }
131
-
132
- const onDragStart = (event: DragEvent): void => {
133
- if (activePos === null || !event.dataTransfer) return
134
- const node = view.state.doc.nodeAt(activePos)
135
- if (!node) return
136
-
137
- // Select the block as a NodeSelection so PM treats the drag as a node
138
- // move, not a text-range move.
139
- const selection = NodeSelection.create(view.state.doc, activePos)
140
- view.dispatch(view.state.tr.setSelection(selection))
141
-
142
- // PM's drop handler reads `view.dragging` for in-editor drags. Without
143
- // it the drop falls back to clipboard-HTML parsing and silently no-ops
144
- // (drop returns to origin). This is the line that actually fixes drop.
145
- const slice = selection.content()
146
- ;(view as unknown as { dragging: { slice: typeof slice; move: boolean } }).dragging = {
147
- slice,
148
- move: !event.ctrlKey && !event.metaKey,
149
- }
150
-
151
- // Use PM's clipboard serializer so the drag carries proper HTML — both
152
- // for cross-editor pastes and as the visible drag image.
153
- const { dom, text } = view.serializeForClipboard(slice)
154
- event.dataTransfer.clearData()
155
- event.dataTransfer.setData('text/html', dom.innerHTML)
156
- event.dataTransfer.setData('text/plain', text)
157
- event.dataTransfer.effectAllowed = 'copyMove'
158
-
159
- // Drag image = the actual block, not the handle button.
160
- const blockNode = view.nodeDOM(activePos) as HTMLElement | null
161
- if (blockNode) event.dataTransfer.setDragImage(blockNode, 0, 0)
162
-
163
- handle.style.cursor = 'grabbing'
164
- }
165
-
166
- const onDragEnd = (): void => {
167
- handle.style.cursor = 'grab'
168
- }
169
-
170
- view.dom.addEventListener('mousemove', onMouseMove)
171
- view.dom.addEventListener('mouseleave', onMouseLeave)
172
- handle.addEventListener('dragstart', onDragStart)
173
- handle.addEventListener('dragend', onDragEnd)
174
-
175
- return {
176
- destroy: () => {
177
- view.dom.removeEventListener('mousemove', onMouseMove)
178
- view.dom.removeEventListener('mouseleave', onMouseLeave)
179
- handle.removeEventListener('dragstart', onDragStart)
180
- handle.removeEventListener('dragend', onDragEnd)
181
- handle.remove()
182
- },
183
- }
184
- }
@@ -1,31 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { clampGridColumns } from './GridExtension.js'
4
-
5
- describe('clampGridColumns', () => {
6
- it('passes through 2 and 3', () => {
7
- assert.equal(clampGridColumns(2), 2)
8
- assert.equal(clampGridColumns(3), 3)
9
- })
10
-
11
- it('falls back to 2 for 1', () => {
12
- assert.equal(clampGridColumns(1), 2)
13
- })
14
-
15
- it('falls back to 2 for 4+ / NaN / negative / undefined / non-numeric strings', () => {
16
- assert.equal(clampGridColumns(4), 2)
17
- assert.equal(clampGridColumns(99), 2)
18
- assert.equal(clampGridColumns(NaN), 2)
19
- assert.equal(clampGridColumns(-1), 2)
20
- assert.equal(clampGridColumns(undefined), 2)
21
- assert.equal(clampGridColumns(null), 2)
22
- assert.equal(clampGridColumns(''), 2)
23
- assert.equal(clampGridColumns('abc'), 2)
24
- })
25
-
26
- it('coerces numeric strings before clamping', () => {
27
- assert.equal(clampGridColumns('2'), 2)
28
- assert.equal(clampGridColumns('3'), 3)
29
- assert.equal(clampGridColumns('4'), 2)
30
- })
31
- })
@@ -1,138 +0,0 @@
1
- import { Node, mergeAttributes } from '@tiptap/core'
2
-
3
- declare module '@tiptap/core' {
4
- interface Commands<ReturnType> {
5
- grid: {
6
- /**
7
- * Insert a multi-column grid at the cursor. Each column gets one
8
- * empty paragraph. Defaults to 2 columns; pass `{ columns: 3 }` for
9
- * the three-column variant.
10
- */
11
- setGrid: (options?: { columns?: GridColumns }) => ReturnType
12
- /**
13
- * Unwrap the enclosing grid: replace the grid node with the flat
14
- * concatenation of every column's children. No-op when the cursor
15
- * isn't inside a grid.
16
- */
17
- unsetGrid: () => ReturnType
18
- }
19
- }
20
- }
21
-
22
- /** Allowed column counts. */
23
- export type GridColumns = 2 | 3
24
-
25
- const ALLOWED_COLUMNS: ReadonlyArray<GridColumns> = [2, 3] as const
26
-
27
- /**
28
- * Coerce any input into one of the allowed column counts. Out-of-range
29
- * values, NaN, undefined, and non-numeric strings all fall back to 2 — the
30
- * conservative default.
31
- *
32
- * Exported for unit tests and so the renderer in `render.ts` can share the
33
- * same validator with the editor's `parseHTML`.
34
- */
35
- export function clampGridColumns(raw: unknown): GridColumns {
36
- const n = typeof raw === 'number' ? raw : Number(raw)
37
- if (!Number.isFinite(n)) return 2
38
- const trunc = Math.trunc(n)
39
- return ALLOWED_COLUMNS.includes(trunc as GridColumns) ? (trunc as GridColumns) : 2
40
- }
41
-
42
- /**
43
- * Multi-column grid container. Schema constrains it to exactly 2 or 3
44
- * `gridColumn` children, so the user can't construct a 1-column or
45
- * 4-column grid through any path (toolbar, slash, paste).
46
- *
47
- * Renders to `<div data-type="grid" data-columns="N" class="pilotiq-grid
48
- * pilotiq-grid-cols-N">` — consumers ship the matching CSS (Tailwind
49
- * `grid grid-cols-N gap-4` or hand-rolled). Same posture as `lead` /
50
- * `small` size marks: the package stays CSS-free.
51
- */
52
- export const Grid = Node.create({
53
- name: 'grid',
54
- group: 'block',
55
- content: 'gridColumn{2,3}',
56
- defining: true,
57
-
58
- addAttributes() {
59
- return {
60
- columns: {
61
- default: 2 as GridColumns,
62
- parseHTML: (el) => clampGridColumns(el.getAttribute('data-columns')),
63
- renderHTML: (attrs) => {
64
- const cols = clampGridColumns(attrs['columns'])
65
- return {
66
- 'data-columns': String(cols),
67
- class: `pilotiq-grid pilotiq-grid-cols-${cols}`,
68
- }
69
- },
70
- },
71
- }
72
- },
73
-
74
- parseHTML() {
75
- return [{ tag: 'div[data-type="grid"]' }]
76
- },
77
-
78
- renderHTML({ HTMLAttributes }) {
79
- return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'grid' }), 0]
80
- },
81
-
82
- addCommands() {
83
- return {
84
- setGrid:
85
- (options) =>
86
- ({ chain }) => {
87
- const columns = clampGridColumns(options?.columns ?? 2)
88
- const content = Array.from({ length: columns }, () => ({
89
- type: 'gridColumn',
90
- content: [{ type: 'paragraph' }],
91
- }))
92
- return chain()
93
- .focus()
94
- .insertContent({ type: 'grid', attrs: { columns }, content })
95
- .run()
96
- },
97
-
98
- unsetGrid:
99
- () =>
100
- ({ state, tr, dispatch }) => {
101
- const $head = state.selection.$head
102
- for (let depth = $head.depth; depth > 0; depth--) {
103
- const node = $head.node(depth)
104
- if (node.type.name !== 'grid') continue
105
- const start = $head.before(depth)
106
- const end = $head.after(depth)
107
- // Concatenate each column's children into a flat block list.
108
- const flat: unknown[] = []
109
- node.forEach((col) => {
110
- col.forEach((child) => flat.push(child))
111
- })
112
- if (dispatch) tr.replaceWith(start, end, flat as never)
113
- return true
114
- }
115
- return false
116
- },
117
- }
118
- },
119
- })
120
-
121
- /**
122
- * Individual column inside a `grid`. Belongs to its own custom group so the
123
- * top-level document only accepts `Grid` (not bare `gridColumn`s).
124
- */
125
- export const GridColumn = Node.create({
126
- name: 'gridColumn',
127
- group: 'gridColumn',
128
- content: 'block+',
129
- defining: true,
130
-
131
- parseHTML() {
132
- return [{ tag: 'div[data-type="gridColumn"]' }]
133
- },
134
-
135
- renderHTML({ HTMLAttributes }) {
136
- return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'gridColumn' }), 0]
137
- },
138
- })
@@ -1,248 +0,0 @@
1
- import { Node, mergeAttributes, type Editor, type Range } from '@tiptap/core'
2
- import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
3
- import { PluginKey } from '@tiptap/pm/state'
4
- import type { MentionItem, MentionProviderMeta } from '../MentionProvider.js'
5
-
6
- declare module '@tiptap/core' {
7
- interface Commands<ReturnType> {
8
- mention: {
9
- /**
10
- * Insert a `mention` atom node. `trigger` is the character the
11
- * provider was registered with (`@` / `#` / …) and is rendered
12
- * inline together with the resolved label.
13
- */
14
- insertMention: (args: { id: string; label: string; trigger: string }) => ReturnType
15
- }
16
- }
17
- }
18
-
19
- /**
20
- * State the React side of the editor needs to render the mention popover.
21
- * `null` when the popover should be unmounted.
22
- */
23
- export interface MentionState {
24
- trigger: string
25
- items: MentionItem[]
26
- /** Pick item — Suggestion will replace the trigger range and run the command. */
27
- command: (item: MentionItem) => void
28
- clientRect: () => DOMRect | null
29
- query: string
30
- }
31
-
32
- export interface MentionOptions {
33
- providers: MentionProviderMeta[]
34
- /**
35
- * Called whenever the popover should mount, update, or unmount. TiptapEditor
36
- * holds the React state and passes a setter here.
37
- */
38
- onStateChange: (state: MentionState | null) => void
39
- /**
40
- * URL the field's `tagRichTextMentionUrls` walker stamped on the wire-side
41
- * meta. Required for async providers (`MentionProvider.itemsUsing(fn)`);
42
- * unused when every provider on this field is static. The client POSTs
43
- * `{ field, trigger, query }` per keystroke and expects `{ ok, items }`.
44
- */
45
- mentionsUrl?: string
46
- /**
47
- * Field path the route handler uses to find the RichTextField on the
48
- * page. Equals `Field.name` for non-nested fields. Inside a Repeater
49
- * row this is the dotted form `<repeaterName>.<index>.<innerName>`;
50
- * inside a Builder row it's `<builderName>.<index>.data.<innerName>`.
51
- * The route handler parses the prefix and looks up the field against
52
- * the Repeater's template / each Builder block's schema.
53
- */
54
- fieldName?: string
55
- }
56
-
57
- /**
58
- * Inline atom node + a Suggestion plugin per registered provider.
59
- *
60
- * The node carries `id`, `label`, and `trigger` attributes. Storage is JSON:
61
- * `{ type: 'mention', attrs: { id: 'sleman', label: 'Sleman', trigger: '@' } }`.
62
- *
63
- * Each provider gets its own ProseMirror Suggestion plugin instance so users
64
- * can mix multiple trigger characters in the same editor (`@user`, `#room`).
65
- * Items are static — declared via `MentionProvider.make('@').items([...])`.
66
- *
67
- * Read-side rendering happens through `renderRichTextToHtml(content,
68
- * { resolveMention: (trigger, id) => latestLabel })`. Without an override
69
- * the cached label is used — the editor stamps it at insert time so
70
- * static-content snapshots stay self-contained.
71
- */
72
- export const MentionExtension = Node.create<MentionOptions>({
73
- name: 'mention',
74
- group: 'inline',
75
- inline: true,
76
- atom: true,
77
- selectable: true,
78
-
79
- addOptions() {
80
- return {
81
- providers: [],
82
- onStateChange: () => {},
83
- }
84
- },
85
-
86
- addAttributes() {
87
- return {
88
- id: {
89
- default: null,
90
- parseHTML: (el) => el.getAttribute('data-id'),
91
- renderHTML: (a) => (a['id'] ? { 'data-id': String(a['id']) } : {}),
92
- },
93
- label: {
94
- default: null,
95
- parseHTML: (el) => el.getAttribute('data-label'),
96
- renderHTML: (a) => (a['label'] ? { 'data-label': String(a['label']) } : {}),
97
- },
98
- trigger: {
99
- default: null,
100
- parseHTML: (el) => el.getAttribute('data-trigger'),
101
- renderHTML: (a) => (a['trigger'] ? { 'data-trigger': String(a['trigger']) } : {}),
102
- },
103
- }
104
- },
105
-
106
- parseHTML() {
107
- return [{ tag: 'span[data-pilotiq-mention]' }]
108
- },
109
-
110
- renderHTML({ node, HTMLAttributes }) {
111
- const trigger = String(node.attrs['trigger'] ?? '')
112
- const label = String(node.attrs['label'] ?? node.attrs['id'] ?? '')
113
- return [
114
- 'span',
115
- mergeAttributes(
116
- {
117
- 'data-pilotiq-mention': '',
118
- class: 'pilotiq-mention rounded bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300 align-baseline',
119
- },
120
- HTMLAttributes,
121
- ),
122
- `${trigger}${label}`,
123
- ]
124
- },
125
-
126
- addCommands() {
127
- return {
128
- insertMention:
129
- ({ id, label, trigger }) =>
130
- ({ commands }) =>
131
- commands.insertContent([
132
- { type: this.name, attrs: { id, label, trigger } },
133
- { type: 'text', text: ' ' },
134
- ]),
135
- }
136
- },
137
-
138
- addProseMirrorPlugins() {
139
- const providers = this.options.providers
140
- const emit = this.options.onStateChange
141
- const editor = this.editor
142
- const url = this.options.mentionsUrl
143
- const fieldName = this.options.fieldName
144
-
145
- return providers.map((provider, i) =>
146
- Suggestion({
147
- pluginKey: new PluginKey(`pilotiqMentionSuggestion-${i}`),
148
- editor,
149
- char: provider.trigger,
150
- startOfLine: false,
151
- allowSpaces: false,
152
- items: provider.async
153
- ? async ({ query }: { query: string }): Promise<MentionItem[]> =>
154
- fetchAsyncMentionItems(url, fieldName, provider.trigger, query)
155
- : ({ query }: { query: string }) => filterMentionItems(provider.items, query),
156
- command: ({ editor: ed, range, props }: { editor: Editor; range: Range; props: MentionItem }) => {
157
- ed
158
- .chain()
159
- .focus()
160
- .deleteRange(range)
161
- .insertContent([
162
- {
163
- type: 'mention',
164
- attrs: { id: props.id, label: props.label, trigger: provider.trigger },
165
- },
166
- { type: 'text', text: ' ' },
167
- ])
168
- .run()
169
- },
170
- render: () => ({
171
- onStart: (props) => emit(stateFrom(provider.trigger, props)),
172
- onUpdate: (props) => emit(stateFrom(provider.trigger, props)),
173
- // Keys handled at the document level by TiptapEditor — same posture
174
- // as the slash menu.
175
- onKeyDown: () => false,
176
- onExit: () => emit(null),
177
- }),
178
- } satisfies SuggestionOptions<MentionItem, MentionItem>),
179
- )
180
- },
181
- })
182
-
183
- function stateFrom(
184
- trigger: string,
185
- props: {
186
- items: MentionItem[]
187
- command: (item: MentionItem) => void
188
- clientRect?: (() => DOMRect | null) | null
189
- query: string
190
- },
191
- ): MentionState {
192
- return {
193
- trigger,
194
- items: props.items,
195
- command: props.command,
196
- clientRect: props.clientRect ?? (() => null),
197
- query: props.query,
198
- }
199
- }
200
-
201
- function filterMentionItems(items: MentionItem[], query: string): MentionItem[] {
202
- if (!query) return items
203
- const needle = query.toLowerCase()
204
- return items.filter((item) =>
205
- `${item.label} ${item.id} ${item.group ?? ''}`.toLowerCase().includes(needle),
206
- )
207
- }
208
-
209
- /**
210
- * Async path — POST `{ field, trigger, query }` to the field's
211
- * `mentionsUrl` and return the resolved items. Returns `[]` for any
212
- * failure (missing URL / network error / non-200 response / malformed
213
- * payload) so the menu degrades to "no matches" instead of throwing
214
- * inside Suggestion's items pipeline.
215
- *
216
- * Suggestion handles its own debouncing; we don't need a setTimeout
217
- * wrapper. In-flight races aren't tracked here either — Suggestion's
218
- * internal sequence handling owns "newer query supersedes older one".
219
- */
220
- async function fetchAsyncMentionItems(
221
- url: string | undefined,
222
- field: string | undefined,
223
- trigger: string,
224
- query: string,
225
- ): Promise<MentionItem[]> {
226
- if (!url || !field) return []
227
- try {
228
- const res = await fetch(url, {
229
- method: 'POST',
230
- headers: {
231
- 'Content-Type': 'application/json',
232
- 'Accept': 'application/json',
233
- },
234
- body: JSON.stringify({ field, trigger, query }),
235
- })
236
- if (!res.ok) return []
237
- const json = await res.json() as { ok?: boolean; items?: unknown }
238
- if (!json.ok || !Array.isArray(json.items)) return []
239
- return json.items.filter((item): item is MentionItem =>
240
- item != null
241
- && typeof item === 'object'
242
- && typeof (item as Record<string, unknown>)['id'] === 'string'
243
- && typeof (item as Record<string, unknown>)['label'] === 'string',
244
- )
245
- } catch {
246
- return []
247
- }
248
- }
@@ -1,75 +0,0 @@
1
- import { Node, mergeAttributes } from '@tiptap/core'
2
-
3
- declare module '@tiptap/core' {
4
- interface Commands<ReturnType> {
5
- mergeTag: {
6
- /** Insert a `mergeTag` atom node carrying the given identifier. */
7
- insertMergeTag: (id: string) => ReturnType
8
- }
9
- }
10
- }
11
-
12
- /**
13
- * Inline atom that represents a `{{ tag }}` placeholder in the document.
14
- *
15
- * The editor renders the node as a small chip — `{{ id }}` inside a styled
16
- * `<span>` — so the author sees what gets substituted at read time. Storage
17
- * is JSON: `{ type: 'mergeTag', attrs: { id: 'name' } }`.
18
- *
19
- * Read-side rendering happens through `renderRichTextToHtml(content,
20
- * { mergeTags: { name: 'Sleman' } })` — pass a substitution map and the
21
- * placeholder is replaced with the value (HTML-escaped). Without a map,
22
- * the renderer emits `<span class="merge-tag" data-id="name">{{ name }}</span>`
23
- * so previews on the server stay informative.
24
- */
25
- export const MergeTagExtension = Node.create({
26
- name: 'mergeTag',
27
- group: 'inline',
28
- inline: true,
29
- atom: true,
30
- selectable: true,
31
-
32
- addAttributes() {
33
- return {
34
- id: {
35
- default: null,
36
- parseHTML: (el) => el.getAttribute('data-id'),
37
- renderHTML: (attrs) => {
38
- if (!attrs['id']) return {}
39
- return { 'data-id': String(attrs['id']) }
40
- },
41
- },
42
- }
43
- },
44
-
45
- parseHTML() {
46
- return [{ tag: 'span[data-pilotiq-merge-tag]' }]
47
- },
48
-
49
- renderHTML({ node, HTMLAttributes }) {
50
- const id = String(node.attrs['id'] ?? '')
51
- return [
52
- 'span',
53
- mergeAttributes(
54
- {
55
- 'data-pilotiq-merge-tag': '',
56
- class: 'pilotiq-merge-tag rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary align-baseline',
57
- },
58
- HTMLAttributes,
59
- ),
60
- `{{ ${id} }}`,
61
- ]
62
- },
63
-
64
- addCommands() {
65
- return {
66
- insertMergeTag:
67
- (id: string) =>
68
- ({ commands }) =>
69
- commands.insertContent({
70
- type: this.name,
71
- attrs: { id },
72
- }),
73
- }
74
- },
75
- })