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