@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/Block.d.ts +47 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +56 -0
- package/dist/Block.js.map +1 -0
- package/dist/MentionProvider.d.ts +97 -0
- package/dist/MentionProvider.d.ts.map +1 -0
- package/dist/MentionProvider.js +104 -0
- package/dist/MentionProvider.js.map +1 -0
- package/dist/RichTextField.d.ts +286 -0
- package/dist/RichTextField.d.ts.map +1 -0
- package/dist/RichTextField.js +369 -0
- package/dist/RichTextField.js.map +1 -0
- package/dist/extensions/BlockNodeExtension.d.ts +41 -0
- package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
- package/dist/extensions/BlockNodeExtension.js +103 -0
- package/dist/extensions/BlockNodeExtension.js.map +1 -0
- package/dist/extensions/DragHandleExtension.d.ts +19 -0
- package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
- package/dist/extensions/DragHandleExtension.js +166 -0
- package/dist/extensions/DragHandleExtension.js.map +1 -0
- package/dist/extensions/GridExtension.d.ts +49 -0
- package/dist/extensions/GridExtension.d.ts.map +1 -0
- package/dist/extensions/GridExtension.js +105 -0
- package/dist/extensions/GridExtension.js.map +1 -0
- package/dist/extensions/MentionExtension.d.ts +71 -0
- package/dist/extensions/MentionExtension.d.ts.map +1 -0
- package/dist/extensions/MentionExtension.js +165 -0
- package/dist/extensions/MentionExtension.js.map +1 -0
- package/dist/extensions/MergeTagExtension.d.ts +24 -0
- package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
- package/dist/extensions/MergeTagExtension.js +57 -0
- package/dist/extensions/MergeTagExtension.js.map +1 -0
- package/dist/extensions/SlashCommandExtension.d.ts +71 -0
- package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
- package/dist/extensions/SlashCommandExtension.js +244 -0
- package/dist/extensions/SlashCommandExtension.js.map +1 -0
- package/dist/extensions/TextSizeMarks.d.ts +33 -0
- package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
- package/dist/extensions/TextSizeMarks.js +47 -0
- package/dist/extensions/TextSizeMarks.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/react/BlockNodeView.d.ts +19 -0
- package/dist/react/BlockNodeView.d.ts.map +1 -0
- package/dist/react/BlockNodeView.js +60 -0
- package/dist/react/BlockNodeView.js.map +1 -0
- package/dist/react/BlockSidePanel.d.ts +105 -0
- package/dist/react/BlockSidePanel.d.ts.map +1 -0
- package/dist/react/BlockSidePanel.js +339 -0
- package/dist/react/BlockSidePanel.js.map +1 -0
- package/dist/react/FloatingToolbar.d.ts +13 -0
- package/dist/react/FloatingToolbar.d.ts.map +1 -0
- package/dist/react/FloatingToolbar.js +113 -0
- package/dist/react/FloatingToolbar.js.map +1 -0
- package/dist/react/MentionMenu.d.ts +26 -0
- package/dist/react/MentionMenu.d.ts.map +1 -0
- package/dist/react/MentionMenu.js +64 -0
- package/dist/react/MentionMenu.js.map +1 -0
- package/dist/react/Palette.d.ts +26 -0
- package/dist/react/Palette.d.ts.map +1 -0
- package/dist/react/Palette.js +21 -0
- package/dist/react/Palette.js.map +1 -0
- package/dist/react/SlashMenu.d.ts +24 -0
- package/dist/react/SlashMenu.d.ts.map +1 -0
- package/dist/react/SlashMenu.js +74 -0
- package/dist/react/SlashMenu.js.map +1 -0
- package/dist/react/TableFloatingToolbar.d.ts +7 -0
- package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
- package/dist/react/TableFloatingToolbar.js +108 -0
- package/dist/react/TableFloatingToolbar.js.map +1 -0
- package/dist/react/TiptapEditor.d.ts +20 -0
- package/dist/react/TiptapEditor.d.ts.map +1 -0
- package/dist/react/TiptapEditor.js +398 -0
- package/dist/react/TiptapEditor.js.map +1 -0
- package/dist/react/Toolbar.d.ts +45 -0
- package/dist/react/Toolbar.d.ts.map +1 -0
- package/dist/react/Toolbar.js +204 -0
- package/dist/react/Toolbar.js.map +1 -0
- package/dist/react/toolbarButtons.d.ts +36 -0
- package/dist/react/toolbarButtons.d.ts.map +1 -0
- package/dist/react/toolbarButtons.js +300 -0
- package/dist/react/toolbarButtons.js.map +1 -0
- package/dist/register.d.ts +20 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +27 -0
- package/dist/register.js.map +1 -0
- package/dist/render.d.ts +89 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/package.json +92 -0
- package/src/Block.ts +75 -0
- package/src/MentionProvider.ts +153 -0
- package/src/RichTextField.test.ts +447 -0
- package/src/RichTextField.ts +508 -0
- package/src/extensions/BlockNodeExtension.ts +134 -0
- package/src/extensions/DragHandleExtension.ts +184 -0
- package/src/extensions/GridExtension.test.ts +31 -0
- package/src/extensions/GridExtension.ts +138 -0
- package/src/extensions/MentionExtension.ts +248 -0
- package/src/extensions/MergeTagExtension.ts +75 -0
- package/src/extensions/SlashCommandExtension.test.ts +147 -0
- package/src/extensions/SlashCommandExtension.ts +332 -0
- package/src/extensions/TextSizeMarks.ts +73 -0
- package/src/index.ts +28 -0
- package/src/plugin.test.ts +19 -0
- package/src/plugin.ts +26 -0
- package/src/react/BlockNodeView.tsx +99 -0
- package/src/react/BlockSidePanel.test.ts +412 -0
- package/src/react/BlockSidePanel.tsx +451 -0
- package/src/react/FloatingToolbar.tsx +304 -0
- package/src/react/MentionMenu.tsx +120 -0
- package/src/react/Palette.tsx +86 -0
- package/src/react/SlashMenu.tsx +129 -0
- package/src/react/TableFloatingToolbar.tsx +154 -0
- package/src/react/TiptapEditor.tsx +535 -0
- package/src/react/Toolbar.tsx +438 -0
- package/src/react/toolbarButtons.tsx +579 -0
- package/src/register.test.ts +14 -0
- package/src/register.ts +27 -0
- package/src/render.test.ts +745 -0
- package/src/render.ts +480 -0
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
})
|