@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,304 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import type { Editor } from '@tiptap/core'
3
+ import { Tooltip } from '@base-ui/react/tooltip'
4
+ import { Dialog } from '@base-ui/react/dialog'
5
+
6
+ interface FloatingToolbarProps {
7
+ editor: Editor
8
+ }
9
+
10
+ /**
11
+ * Selection-based formatting toolbar. Visible whenever the editor has a
12
+ * non-empty range selection inside text content. Inline marks (B/I/S/Code)
13
+ * are grouped together; Link sits after a separator since it's a different
14
+ * kind of action.
15
+ */
16
+ export function FloatingToolbar({ editor }: FloatingToolbarProps) {
17
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
18
+ const [linkOpen, setLinkOpen] = useState(false)
19
+ const [linkUrl, setLinkUrl] = useState('')
20
+
21
+ useEffect(() => {
22
+ const update = (): void => {
23
+ const { from, to, empty } = editor.state.selection
24
+ if (empty) { setPos(null); return }
25
+ // Don't show on full-block selections (e.g. clicking a custom block).
26
+ const slice = editor.state.doc.slice(from, to)
27
+ if (slice.content.childCount === 0) { setPos(null); return }
28
+ const start = editor.view.coordsAtPos(from)
29
+ const end = editor.view.coordsAtPos(to)
30
+ // Viewport-relative — pair with `position: fixed` below. The wrapper
31
+ // around <EditorContent> is `position: relative`, so an absolute toolbar
32
+ // would be positioned relative to it instead of the viewport.
33
+ // Lift the toolbar above the selection. The value is the toolbar's full
34
+ // height plus a small breathing gap; bump if the toolbar grows.
35
+ const top = Math.min(start.top, end.top) - 48
36
+ const left = (start.left + end.right) / 2
37
+ setPos({ top, left })
38
+ }
39
+ const close = (): void => setPos(null)
40
+ editor.on('selectionUpdate', update)
41
+ editor.on('blur', close)
42
+ // Keep the toolbar pinned to the selection when the page or any scroll
43
+ // ancestor scrolls (capture phase catches inner scrollers too).
44
+ window.addEventListener('scroll', update, true)
45
+ window.addEventListener('resize', update)
46
+ return () => {
47
+ editor.off('selectionUpdate', update)
48
+ editor.off('blur', close)
49
+ window.removeEventListener('scroll', update, true)
50
+ window.removeEventListener('resize', update)
51
+ }
52
+ }, [editor])
53
+
54
+ const openLinkDialog = (): void => {
55
+ const previousUrl = editor.getAttributes('link')['href'] as string | undefined
56
+ setLinkUrl(previousUrl ?? '')
57
+ setLinkOpen(true)
58
+ }
59
+
60
+ const applyLink = (): void => {
61
+ setLinkOpen(false)
62
+ const trimmed = linkUrl.trim()
63
+ if (trimmed === '') {
64
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
65
+ return
66
+ }
67
+ editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run()
68
+ }
69
+
70
+ const removeLink = (): void => {
71
+ setLinkOpen(false)
72
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
73
+ }
74
+
75
+ const mod = isMac() ? '⌘' : 'Ctrl+'
76
+ const isLink = editor.isActive('link')
77
+
78
+ return (
79
+ <>
80
+ {pos && (
81
+ <Tooltip.Provider delay={400}>
82
+ <div
83
+ className="fixed z-40 flex items-center gap-0.5 rounded-md border bg-popover px-1 py-1 text-popover-foreground shadow-md"
84
+ style={{ top: pos.top, left: pos.left, transform: 'translateX(-50%)' }}
85
+ >
86
+ <ToolbarButton
87
+ label={`Bold (${mod}B)`}
88
+ active={editor.isActive('bold')}
89
+ onClick={() => editor.chain().focus().toggleBold().run()}
90
+ >
91
+ <BoldIcon />
92
+ </ToolbarButton>
93
+ <ToolbarButton
94
+ label={`Italic (${mod}I)`}
95
+ active={editor.isActive('italic')}
96
+ onClick={() => editor.chain().focus().toggleItalic().run()}
97
+ >
98
+ <ItalicIcon />
99
+ </ToolbarButton>
100
+ <ToolbarButton
101
+ label={`Strikethrough (${mod}⇧X)`}
102
+ active={editor.isActive('strike')}
103
+ onClick={() => editor.chain().focus().toggleStrike().run()}
104
+ >
105
+ <StrikeIcon />
106
+ </ToolbarButton>
107
+ <ToolbarButton
108
+ label={`Code (${mod}E)`}
109
+ active={editor.isActive('code')}
110
+ onClick={() => editor.chain().focus().toggleCode().run()}
111
+ >
112
+ <CodeIcon />
113
+ </ToolbarButton>
114
+ <span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-border" />
115
+ <ToolbarButton
116
+ label={isLink ? 'Edit link' : 'Add link'}
117
+ active={isLink}
118
+ onClick={openLinkDialog}
119
+ >
120
+ <LinkIcon />
121
+ </ToolbarButton>
122
+ </div>
123
+ </Tooltip.Provider>
124
+ )}
125
+ <LinkDialog
126
+ open={linkOpen}
127
+ onOpenChange={setLinkOpen}
128
+ url={linkUrl}
129
+ onUrlChange={setLinkUrl}
130
+ onApply={applyLink}
131
+ onRemove={isLink ? removeLink : null}
132
+ isEdit={isLink}
133
+ />
134
+ </>
135
+ )
136
+ }
137
+
138
+ function ToolbarButton({
139
+ label,
140
+ active,
141
+ onClick,
142
+ children,
143
+ }: {
144
+ label: string
145
+ active: boolean
146
+ onClick: () => void
147
+ children: ReactNode
148
+ }) {
149
+ return (
150
+ <Tooltip.Root>
151
+ <Tooltip.Trigger
152
+ render={(props) => (
153
+ <button
154
+ {...props}
155
+ type="button"
156
+ // mousedown + preventDefault keeps editor focus so the selection
157
+ // (and therefore the toolbar) survives the click.
158
+ onMouseDown={(e) => { e.preventDefault(); onClick() }}
159
+ className={`inline-flex h-7 w-7 items-center justify-center rounded text-foreground transition-colors ${
160
+ active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
161
+ }`}
162
+ aria-label={label}
163
+ aria-pressed={active}
164
+ >
165
+ {children}
166
+ </button>
167
+ )}
168
+ />
169
+ <Tooltip.Portal>
170
+ <Tooltip.Positioner side="top" sideOffset={6} className="isolate z-50">
171
+ <Tooltip.Popup className="rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
172
+ {label}
173
+ </Tooltip.Popup>
174
+ </Tooltip.Positioner>
175
+ </Tooltip.Portal>
176
+ </Tooltip.Root>
177
+ )
178
+ }
179
+
180
+ function LinkDialog({
181
+ open,
182
+ onOpenChange,
183
+ url,
184
+ onUrlChange,
185
+ onApply,
186
+ onRemove,
187
+ isEdit,
188
+ }: {
189
+ open: boolean
190
+ onOpenChange: (open: boolean) => void
191
+ url: string
192
+ onUrlChange: (url: string) => void
193
+ onApply: () => void
194
+ onRemove: (() => void) | null
195
+ isEdit: boolean
196
+ }) {
197
+ return (
198
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
199
+ <Dialog.Portal>
200
+ <Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 transition-opacity duration-150 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" />
201
+ <Dialog.Popup className="fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg transition-[opacity,transform] duration-150 data-[starting-style]:scale-95 data-[starting-style]:opacity-0 data-[ending-style]:scale-95 data-[ending-style]:opacity-0">
202
+ <Dialog.Title className="text-lg leading-none font-semibold">
203
+ {isEdit ? 'Edit link' : 'Add link'}
204
+ </Dialog.Title>
205
+ <input
206
+ type="url"
207
+ value={url}
208
+ onChange={(e) => onUrlChange(e.target.value)}
209
+ onKeyDown={(e) => {
210
+ if (e.key === 'Enter') {
211
+ e.preventDefault()
212
+ onApply()
213
+ }
214
+ }}
215
+ placeholder="https://example.com"
216
+ autoFocus
217
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
218
+ />
219
+ <div className="flex flex-row-reverse items-center gap-2">
220
+ <button
221
+ type="button"
222
+ onClick={onApply}
223
+ className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
224
+ >
225
+ {isEdit ? 'Update' : 'Add'}
226
+ </button>
227
+ <button
228
+ type="button"
229
+ onClick={() => onOpenChange(false)}
230
+ className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
231
+ >
232
+ Cancel
233
+ </button>
234
+ {onRemove && (
235
+ <button
236
+ type="button"
237
+ onClick={onRemove}
238
+ className="me-auto inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-medium text-destructive hover:bg-destructive/10"
239
+ >
240
+ Remove link
241
+ </button>
242
+ )}
243
+ </div>
244
+ </Dialog.Popup>
245
+ </Dialog.Portal>
246
+ </Dialog.Root>
247
+ )
248
+ }
249
+
250
+ function isMac(): boolean {
251
+ if (typeof navigator === 'undefined') return false
252
+ // navigator.platform is deprecated but still the most reliable signal here.
253
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
254
+ }
255
+
256
+ // Inline SVG icons (lucide.dev paths). Kept inline so this package doesn't
257
+ // pull `lucide-react` as a peer dep — the rest of @pilotiq/tiptap is already
258
+ // inline-SVG (DragHandle).
259
+ function BoldIcon() {
260
+ return (
261
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
262
+ <path d="M6 12h9a4 4 0 0 1 0 8H6Z" />
263
+ <path d="M6 4h7a4 4 0 0 1 0 8H6Z" />
264
+ </svg>
265
+ )
266
+ }
267
+
268
+ function ItalicIcon() {
269
+ return (
270
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
271
+ <line x1="19" y1="4" x2="10" y2="4" />
272
+ <line x1="14" y1="20" x2="5" y2="20" />
273
+ <line x1="15" y1="4" x2="9" y2="20" />
274
+ </svg>
275
+ )
276
+ }
277
+
278
+ function StrikeIcon() {
279
+ return (
280
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
281
+ <path d="M16 4H9a3 3 0 0 0-2.83 4" />
282
+ <path d="M14 12a4 4 0 0 1 0 8H6" />
283
+ <line x1="4" y1="12" x2="20" y2="12" />
284
+ </svg>
285
+ )
286
+ }
287
+
288
+ function CodeIcon() {
289
+ return (
290
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
291
+ <polyline points="16 18 22 12 16 6" />
292
+ <polyline points="8 6 2 12 8 18" />
293
+ </svg>
294
+ )
295
+ }
296
+
297
+ function LinkIcon() {
298
+ return (
299
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
300
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
301
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
302
+ </svg>
303
+ )
304
+ }
@@ -0,0 +1,120 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { MentionItem } from '../MentionProvider.js'
3
+
4
+ /**
5
+ * Mutable ref the document-level keydown listener in `TiptapEditor` reads.
6
+ * `MentionMenu` installs its keyboard handler on mount, clears on unmount —
7
+ * same protocol as `SlashMenu`'s `keyHandlerRef`.
8
+ */
9
+ export type MentionKeyHandlerRef = { current: ((event: KeyboardEvent) => boolean) | null }
10
+
11
+ interface MentionMenuProps {
12
+ trigger: string
13
+ items: MentionItem[]
14
+ command: (item: MentionItem) => void
15
+ keyHandlerRef: MentionKeyHandlerRef
16
+ }
17
+
18
+ /**
19
+ * Floating list of mention items. Mirrors `SlashMenu` but uses the lighter
20
+ * `MentionItem` shape (no command thunk per item — the `command` prop is
21
+ * pre-curried by the Suggestion plugin).
22
+ *
23
+ * Optional `group` strings on items render as section headings; items
24
+ * without a group land under "Suggestions".
25
+ */
26
+ export function MentionMenu({ trigger, items, command, keyHandlerRef }: MentionMenuProps) {
27
+ const [active, setActive] = useState(0)
28
+ const containerRef = useRef<HTMLDivElement | null>(null)
29
+
30
+ const grouped = useMemo(
31
+ () => groupBy(items, (it) => it.group ?? 'Suggestions'),
32
+ [items],
33
+ )
34
+ const renderOrder = useMemo(
35
+ () => Array.from(grouped.values()).flat(),
36
+ [grouped],
37
+ )
38
+
39
+ useEffect(() => { setActive(0) }, [renderOrder])
40
+
41
+ useEffect(() => {
42
+ const el = containerRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)
43
+ el?.scrollIntoView({ block: 'nearest' })
44
+ }, [active])
45
+
46
+ useEffect(() => {
47
+ keyHandlerRef.current = (event) => {
48
+ const len = renderOrder.length
49
+ if (event.key === 'ArrowDown') {
50
+ setActive((i) => (len === 0 ? 0 : (i + 1) % len))
51
+ return true
52
+ }
53
+ if (event.key === 'ArrowUp') {
54
+ setActive((i) => (len === 0 ? 0 : (i - 1 + len) % len))
55
+ return true
56
+ }
57
+ if (event.key === 'Enter') {
58
+ const item = renderOrder[active]
59
+ if (item) command(item)
60
+ return true
61
+ }
62
+ return false
63
+ }
64
+ return () => { keyHandlerRef.current = null }
65
+ }, [renderOrder, active, command, keyHandlerRef])
66
+
67
+ if (renderOrder.length === 0) {
68
+ return (
69
+ <div className="px-3 py-2 text-xs text-muted-foreground">
70
+ No matches
71
+ </div>
72
+ )
73
+ }
74
+
75
+ let runningIndex = 0
76
+ return (
77
+ <div ref={containerRef} className="max-h-72 w-64 overflow-y-auto p-1 text-sm">
78
+ {Array.from(grouped.entries()).map(([groupName, groupItems]) => (
79
+ <div key={groupName}>
80
+ <div className="px-2 pt-2 pb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
81
+ {groupName}
82
+ </div>
83
+ {groupItems.map((item) => {
84
+ const idx = runningIndex++
85
+ const isActive = idx === active
86
+ return (
87
+ <button
88
+ key={item.id}
89
+ data-index={idx}
90
+ type="button"
91
+ onMouseDown={(e) => { e.preventDefault(); command(item) }}
92
+ onMouseEnter={() => setActive(idx)}
93
+ className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left ${
94
+ isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
95
+ }`}
96
+ >
97
+ <span className="flex size-6 items-center justify-center rounded border bg-background text-xs">
98
+ {trigger}
99
+ </span>
100
+ <span className="flex-1">{item.label}</span>
101
+ <span className="text-xs text-muted-foreground">{item.id}</span>
102
+ </button>
103
+ )
104
+ })}
105
+ </div>
106
+ ))}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
112
+ const out = new Map<string, T[]>()
113
+ for (const item of items) {
114
+ const k = key(item)
115
+ const list = out.get(k)
116
+ if (list) list.push(item)
117
+ else out.set(k, [item])
118
+ }
119
+ return out
120
+ }
@@ -0,0 +1,86 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { Popover } from '@base-ui/react/popover'
3
+ import type { ColorSwatch } from '../RichTextField.js'
4
+
5
+ interface PaletteProps {
6
+ /** Trigger button — usually the toolbar's `textColor` / `highlight` button. */
7
+ trigger: ReactNode
8
+ swatches: ColorSwatch[]
9
+ /** Whether to render a free-form color picker below the swatches. */
10
+ custom: boolean
11
+ /** Currently active color, when known. Used to show the highlight ring. */
12
+ activeColor?: string | undefined
13
+ /** Pick a swatch (or the custom-picker value). */
14
+ onPick: (value: string) => void
15
+ /** Clear the color (removes the mark). */
16
+ onClear: () => void
17
+ clearLabel?: string
18
+ }
19
+
20
+ /**
21
+ * Swatch popover anchored to a toolbar button. Drives `textColor` and
22
+ * `highlight` — both share the same UI shape, only the swatches and the
23
+ * `onPick`/`onClear` wiring differ.
24
+ *
25
+ * Mounts open / closed itself; consumers don't manage the open state.
26
+ */
27
+ export function Palette({
28
+ trigger, swatches, custom, activeColor, onPick, onClear, clearLabel = 'No color',
29
+ }: PaletteProps) {
30
+ const [open, setOpen] = useState(false)
31
+
32
+ const close = (): void => setOpen(false)
33
+ const pick = (value: string): void => { onPick(value); close() }
34
+ const clear = (): void => { onClear(); close() }
35
+
36
+ return (
37
+ <Popover.Root open={open} onOpenChange={setOpen}>
38
+ <Popover.Trigger render={trigger as React.ReactElement} />
39
+ <Popover.Portal>
40
+ <Popover.Positioner side="bottom" align="start" sideOffset={6} className="isolate z-50">
41
+ <Popover.Popup
42
+ className="rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
43
+ >
44
+ <div className="grid grid-cols-5 gap-1">
45
+ {swatches.map((s) => {
46
+ const isActive = activeColor && activeColor.toLowerCase() === s.value.toLowerCase()
47
+ return (
48
+ <button
49
+ key={s.value}
50
+ type="button"
51
+ title={s.label}
52
+ aria-label={s.label}
53
+ aria-pressed={Boolean(isActive)}
54
+ onClick={() => pick(s.value)}
55
+ className={`h-6 w-6 rounded border transition-transform hover:scale-110 ${
56
+ isActive ? 'ring-2 ring-ring ring-offset-1' : 'border-border/60'
57
+ }`}
58
+ style={{ background: s.value }}
59
+ />
60
+ )
61
+ })}
62
+ </div>
63
+ {custom && (
64
+ <label className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
65
+ <span>Custom</span>
66
+ <input
67
+ type="color"
68
+ defaultValue={activeColor ?? '#000000'}
69
+ onChange={(e) => onPick(e.target.value)}
70
+ className="h-6 w-12 cursor-pointer rounded border-0 bg-transparent p-0"
71
+ />
72
+ </label>
73
+ )}
74
+ <button
75
+ type="button"
76
+ onClick={clear}
77
+ className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
78
+ >
79
+ {clearLabel}
80
+ </button>
81
+ </Popover.Popup>
82
+ </Popover.Positioner>
83
+ </Popover.Portal>
84
+ </Popover.Root>
85
+ )
86
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { SlashItem } from '../extensions/SlashCommandExtension.js'
3
+
4
+ /**
5
+ * Mutable ref the document-level keydown listener in `TiptapEditor` reads.
6
+ * `SlashMenu` installs its keyboard handler on mount, clears on unmount.
7
+ */
8
+ export type SlashKeyHandlerRef = { current: ((event: KeyboardEvent) => boolean) | null }
9
+
10
+ interface SlashMenuProps {
11
+ items: SlashItem[]
12
+ command: (item: SlashItem) => void
13
+ keyHandlerRef: SlashKeyHandlerRef
14
+ }
15
+
16
+ /**
17
+ * Floating list of slash items. Mounted by the Base UI Popover in
18
+ * TiptapEditor; the popover's anchor is a virtual element, so we don't need
19
+ * to position the menu ourselves.
20
+ *
21
+ * Keys: ArrowUp / ArrowDown to move, Enter to pick. Escape is handled in
22
+ * `SlashCommandExtension.onKeyDown` (closes the popup directly).
23
+ */
24
+ export function SlashMenu({ items, command, keyHandlerRef }: SlashMenuProps) {
25
+ const [active, setActive] = useState(0)
26
+ const containerRef = useRef<HTMLDivElement | null>(null)
27
+
28
+ // Group items for visual organisation, then derive the flat render-order
29
+ // array. `items` is in plugin order (paragraph, h1, h2, h3, …) but we
30
+ // render grouped (Basic/Headings/Lists/Blocks). The active index must
31
+ // track render order — otherwise ArrowDown highlights item N visually
32
+ // but Enter inserts items[N] from the plugin-order array, which is a
33
+ // different item.
34
+ const grouped = useMemo(
35
+ () => groupBy(items, (it) => it.group ?? 'Other'),
36
+ [items],
37
+ )
38
+ const renderOrder = useMemo(
39
+ () => Array.from(grouped.values()).flat(),
40
+ [grouped],
41
+ )
42
+
43
+ // Reset selection when the filtered list changes.
44
+ useEffect(() => { setActive(0) }, [renderOrder])
45
+
46
+ // Keep the active item in view inside the scroll container.
47
+ useEffect(() => {
48
+ const el = containerRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)
49
+ el?.scrollIntoView({ block: 'nearest' })
50
+ }, [active])
51
+
52
+ // Install the keyboard bridge for the document-level listener in
53
+ // TiptapEditor.
54
+ useEffect(() => {
55
+ keyHandlerRef.current = (event) => {
56
+ const len = renderOrder.length
57
+ if (event.key === 'ArrowDown') {
58
+ setActive((i) => (len === 0 ? 0 : (i + 1) % len))
59
+ return true
60
+ }
61
+ if (event.key === 'ArrowUp') {
62
+ setActive((i) => (len === 0 ? 0 : (i - 1 + len) % len))
63
+ return true
64
+ }
65
+ if (event.key === 'Enter') {
66
+ const item = renderOrder[active]
67
+ if (item) command(item)
68
+ return true
69
+ }
70
+ return false
71
+ }
72
+ return () => { keyHandlerRef.current = null }
73
+ }, [renderOrder, active, command, keyHandlerRef])
74
+
75
+ if (renderOrder.length === 0) {
76
+ return (
77
+ <div className="px-3 py-2 text-xs text-muted-foreground">
78
+ No matches
79
+ </div>
80
+ )
81
+ }
82
+
83
+ let runningIndex = 0
84
+ return (
85
+ <div ref={containerRef} className="max-h-72 w-64 overflow-y-auto p-1 text-sm">
86
+ {Array.from(grouped.entries()).map(([groupName, groupItems]) => (
87
+ <div key={groupName}>
88
+ <div className="px-2 pt-2 pb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
89
+ {groupName}
90
+ </div>
91
+ {groupItems.map((item) => {
92
+ const idx = runningIndex++
93
+ const isActive = idx === active
94
+ return (
95
+ <button
96
+ key={item.key}
97
+ data-index={idx}
98
+ type="button"
99
+ onMouseDown={(e) => { e.preventDefault(); command(item) }}
100
+ onMouseEnter={() => setActive(idx)}
101
+ className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left ${
102
+ isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
103
+ }`}
104
+ >
105
+ {item.icon && (
106
+ <span className="flex size-6 items-center justify-center rounded border bg-background text-xs">
107
+ {item.icon}
108
+ </span>
109
+ )}
110
+ <span>{item.label}</span>
111
+ </button>
112
+ )
113
+ })}
114
+ </div>
115
+ ))}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
121
+ const out = new Map<string, T[]>()
122
+ for (const item of items) {
123
+ const k = key(item)
124
+ const list = out.get(k)
125
+ if (list) list.push(item)
126
+ else out.set(k, [item])
127
+ }
128
+ return out
129
+ }