@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,438 @@
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
+ import type { ToolbarGroups, ToolbarButtonId, ColorSwatch } from '../RichTextField.js'
6
+ import { TOOLBAR_BUTTONS, type ToolbarButtonDef } from './toolbarButtons.js'
7
+ import { Palette } from './Palette.js'
8
+
9
+ interface ToolbarProps {
10
+ editor: Editor
11
+ groups: ToolbarGroups
12
+ /** Force re-render when the selection / active marks change. */
13
+ tick: number
14
+ textColors: ColorSwatch[]
15
+ customTextColors: boolean
16
+ highlightColors: ColorSwatch[]
17
+ /**
18
+ * Open the upload dialog. The dialog itself is mounted in the parent
19
+ * (`ClientEditor`) so the slash menu's "Image" entry can share the
20
+ * same mount — Toolbar just toggles the controlled flag.
21
+ */
22
+ onAttachOpenChange: (open: boolean) => void
23
+ }
24
+
25
+ /**
26
+ * Always-on toolbar rendered above the editor. Groups are joined with thin
27
+ * dividers; unknown / unavailable button ids are silently dropped so config
28
+ * can target later-phase buttons today without crashing.
29
+ *
30
+ * Inline link button opens a Base UI dialog with a URL input — kept here
31
+ * (not in the FloatingToolbar's dialog) because each toolbar instance owns
32
+ * its own React state for the modal.
33
+ */
34
+ export function Toolbar({
35
+ editor, groups, tick, textColors, customTextColors, highlightColors,
36
+ onAttachOpenChange,
37
+ }: ToolbarProps) {
38
+ const [linkOpen, setLinkOpen] = useState(false)
39
+ const [linkUrl, setLinkUrl] = useState('')
40
+
41
+ const filteredGroups = groups
42
+ .map((g) => g.map((id) => TOOLBAR_BUTTONS[id]).filter((b): b is ToolbarButtonDef => Boolean(b?.available)))
43
+ .filter((g) => g.length > 0)
44
+
45
+ if (filteredGroups.length === 0) return null
46
+
47
+ const openLinkDialog = (): void => {
48
+ const previousUrl = editor.getAttributes('link')['href'] as string | undefined
49
+ setLinkUrl(previousUrl ?? '')
50
+ setLinkOpen(true)
51
+ }
52
+
53
+ const applyLink = (): void => {
54
+ setLinkOpen(false)
55
+ const trimmed = linkUrl.trim()
56
+ if (trimmed === '') {
57
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
58
+ return
59
+ }
60
+ editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run()
61
+ }
62
+
63
+ const removeLink = (): void => {
64
+ setLinkOpen(false)
65
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
66
+ }
67
+
68
+ return (
69
+ <>
70
+ <Tooltip.Provider delay={400}>
71
+ <div
72
+ // The toolbar sits visually attached to the top of the editor body —
73
+ // shared border-radius + a `border-b` on the toolbar gives a single
74
+ // grouped look. Editor body's own border-radius is reset top-side
75
+ // via global classnames in the editor.
76
+ className="flex flex-wrap items-center gap-0.5 rounded-t-md border border-input bg-muted/30 px-1 py-1 text-foreground"
77
+ // Mousedown anywhere in the toolbar should not steal focus from the
78
+ // editor — `preventDefault` keeps the selection alive while the
79
+ // command runs.
80
+ onMouseDown={(e) => { e.preventDefault() }}
81
+ data-tick={tick}
82
+ >
83
+ {filteredGroups.map((group, gi) => (
84
+ <div key={gi} className="flex items-center gap-0.5">
85
+ {gi > 0 && <span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-border" />}
86
+ {group.map((btn) => {
87
+ if (btn.custom === 'textColor') {
88
+ return (
89
+ <Palette
90
+ key={btn.id}
91
+ swatches={textColors}
92
+ custom={customTextColors}
93
+ activeColor={editor.getAttributes('textStyle')['color'] as string | undefined}
94
+ onPick={(value) => editor.chain().focus().setColor(value).run()}
95
+ onClear={() => editor.chain().focus().unsetColor().run()}
96
+ clearLabel="Reset color"
97
+ trigger={
98
+ <ToolbarButton def={btn} editor={editor} />
99
+ }
100
+ />
101
+ )
102
+ }
103
+ if (btn.custom === 'highlight') {
104
+ return (
105
+ <Palette
106
+ key={btn.id}
107
+ swatches={highlightColors}
108
+ custom={false}
109
+ activeColor={editor.getAttributes('highlight')['color'] as string | undefined}
110
+ onPick={(value) => editor.chain().focus().toggleHighlight({ color: value }).run()}
111
+ onClear={() => editor.chain().focus().unsetHighlight().run()}
112
+ clearLabel="Remove highlight"
113
+ trigger={
114
+ <ToolbarButton def={btn} editor={editor} />
115
+ }
116
+ />
117
+ )
118
+ }
119
+ const customClick =
120
+ btn.custom === 'link' ? openLinkDialog :
121
+ btn.custom === 'attachFiles' ? () => onAttachOpenChange(true) :
122
+ undefined
123
+ return (
124
+ <ToolbarButton
125
+ key={btn.id}
126
+ def={btn}
127
+ editor={editor}
128
+ onCustomClick={customClick}
129
+ />
130
+ )
131
+ })}
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </Tooltip.Provider>
136
+ <LinkDialog
137
+ open={linkOpen}
138
+ onOpenChange={setLinkOpen}
139
+ url={linkUrl}
140
+ onUrlChange={setLinkUrl}
141
+ onApply={applyLink}
142
+ onRemove={editor.isActive('link') ? removeLink : null}
143
+ isEdit={editor.isActive('link')}
144
+ />
145
+ {/* Attach-files dialog mount lives in `ClientEditor` (single source of
146
+ truth — slash-menu Image entry shares the same state). */}
147
+ </>
148
+ )
149
+ }
150
+
151
+ interface ToolbarButtonProps {
152
+ def: ToolbarButtonDef
153
+ editor: Editor
154
+ onCustomClick?: (() => void) | undefined
155
+ }
156
+
157
+ function ToolbarButton({ def, editor, onCustomClick }: ToolbarButtonProps) {
158
+ const active = def.isActive?.(editor) ?? false
159
+ const disabled = def.isDisabled?.(editor) ?? false
160
+ const mod = isMac() ? '⌘' : 'Ctrl+'
161
+ const tooltip = def.shortcut ? `${def.label} (${mod}${def.shortcut})` : def.label
162
+
163
+ return (
164
+ <Tooltip.Root>
165
+ <Tooltip.Trigger
166
+ render={(props) => (
167
+ <button
168
+ {...props}
169
+ type="button"
170
+ disabled={disabled}
171
+ onClick={() => {
172
+ if (onCustomClick) { onCustomClick(); return }
173
+ def.command(editor)
174
+ }}
175
+ className={`inline-flex h-7 w-7 items-center justify-center rounded text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none ${
176
+ active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
177
+ }`}
178
+ aria-label={def.label}
179
+ aria-pressed={active}
180
+ >
181
+ {def.icon}
182
+ </button>
183
+ )}
184
+ />
185
+ <Tooltip.Portal>
186
+ <Tooltip.Positioner side="bottom" sideOffset={6} className="isolate z-50">
187
+ <Tooltip.Popup className="rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md 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">
188
+ {tooltip}
189
+ </Tooltip.Popup>
190
+ </Tooltip.Positioner>
191
+ </Tooltip.Portal>
192
+ </Tooltip.Root>
193
+ )
194
+ }
195
+
196
+ export function AttachFilesDialog({
197
+ open, onOpenChange, editor,
198
+ uploadUrl, fieldName, acceptedFileTypes, maxFileSize, directory, visibility,
199
+ }: {
200
+ open: boolean
201
+ onOpenChange: (open: boolean) => void
202
+ editor: Editor
203
+ uploadUrl?: string
204
+ fieldName: string
205
+ acceptedFileTypes?: string[]
206
+ maxFileSize?: number
207
+ directory?: string
208
+ visibility?: 'public' | 'private'
209
+ }) {
210
+ const [file, setFile] = useState<File | null>(null)
211
+ const [alt, setAlt] = useState('')
212
+ const [busy, setBusy] = useState(false)
213
+ const [error, setError] = useState<string | null>(null)
214
+
215
+ // Reset transient state every time the dialog opens — otherwise the
216
+ // previous upload's filename / alt text would leak into the next one.
217
+ useEffect(() => {
218
+ if (open) { setFile(null); setAlt(''); setError(null); setBusy(false) }
219
+ }, [open])
220
+
221
+ const accept = acceptedFileTypes?.join(',') ?? 'image/*'
222
+
223
+ const onPickFile = (f: File | null): void => {
224
+ setError(null)
225
+ if (!f) { setFile(null); return }
226
+ if (maxFileSize !== undefined && f.size > maxFileSize) {
227
+ setError(`File exceeds the maximum size of ${formatBytes(maxFileSize)}.`)
228
+ setFile(null)
229
+ return
230
+ }
231
+ setFile(f)
232
+ if (alt === '') setAlt(stripExtension(f.name))
233
+ }
234
+
235
+ const onUpload = async (): Promise<void> => {
236
+ if (!file) return
237
+ if (!uploadUrl) {
238
+ setError('No upload route configured for this panel.')
239
+ return
240
+ }
241
+ setBusy(true)
242
+ setError(null)
243
+ try {
244
+ const fd = new FormData()
245
+ fd.append('file', file)
246
+ fd.append('fieldName', fieldName)
247
+ if (directory) fd.append('directory', directory)
248
+ if (visibility) fd.append('visibility', visibility)
249
+ const res = await fetch(uploadUrl, {
250
+ method: 'POST',
251
+ body: fd,
252
+ headers: { Accept: 'application/json' },
253
+ })
254
+ const data = await res.json().catch(() => ({} as { ok?: boolean; url?: string; error?: string }))
255
+ if (!res.ok || !data.ok || !data.url) {
256
+ setError(data.error ?? `Upload failed (status ${res.status}).`)
257
+ return
258
+ }
259
+ // Image vs non-image file. Tiptap's StarterKit doesn't ship a generic
260
+ // file/attachment node, so for non-images we fall back to inserting
261
+ // a link mark on the filename — same shape MarkdownField uses.
262
+ if (file.type.startsWith('image/')) {
263
+ editor.chain().focus().setImage({ src: data.url, alt: alt || file.name }).run()
264
+ } else {
265
+ editor.chain().focus()
266
+ .insertContent({
267
+ type: 'text',
268
+ text: alt || file.name,
269
+ marks: [{ type: 'link', attrs: { href: data.url } }],
270
+ })
271
+ .run()
272
+ }
273
+ onOpenChange(false)
274
+ } catch (err) {
275
+ setError(err instanceof Error ? err.message : String(err))
276
+ } finally {
277
+ setBusy(false)
278
+ }
279
+ }
280
+
281
+ return (
282
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
283
+ <Dialog.Portal>
284
+ <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" />
285
+ <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">
286
+ <Dialog.Title className="text-lg leading-none font-semibold">
287
+ Attach file
288
+ </Dialog.Title>
289
+ <div className="flex flex-col gap-3">
290
+ <input
291
+ type="file"
292
+ accept={accept}
293
+ disabled={busy}
294
+ onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
295
+ className="block w-full text-sm file:me-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-muted/80"
296
+ />
297
+ {file && (
298
+ <input
299
+ type="text"
300
+ value={alt}
301
+ onChange={(e) => setAlt(e.target.value)}
302
+ placeholder="Alt text (described for screen readers)"
303
+ disabled={busy}
304
+ 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"
305
+ />
306
+ )}
307
+ {maxFileSize !== undefined && (
308
+ <p className="text-xs text-muted-foreground">
309
+ Maximum size: {formatBytes(maxFileSize)}.
310
+ </p>
311
+ )}
312
+ {error && (
313
+ <p className="text-xs text-destructive">{error}</p>
314
+ )}
315
+ </div>
316
+ <div className="flex flex-row-reverse items-center gap-2">
317
+ <button
318
+ type="button"
319
+ onClick={onUpload}
320
+ disabled={!file || busy}
321
+ 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 disabled:opacity-50 disabled:pointer-events-none"
322
+ >
323
+ {busy ? 'Uploading…' : 'Insert'}
324
+ </button>
325
+ <button
326
+ type="button"
327
+ onClick={() => onOpenChange(false)}
328
+ disabled={busy}
329
+ 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 disabled:opacity-50"
330
+ >
331
+ Cancel
332
+ </button>
333
+ </div>
334
+ </Dialog.Popup>
335
+ </Dialog.Portal>
336
+ </Dialog.Root>
337
+ )
338
+ }
339
+
340
+ function stripExtension(name: string): string {
341
+ const i = name.lastIndexOf('.')
342
+ return i > 0 ? name.slice(0, i) : name
343
+ }
344
+
345
+ function formatBytes(bytes: number): string {
346
+ if (bytes < 1024) return `${bytes} B`
347
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
348
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
349
+ }
350
+
351
+ function LinkDialog({
352
+ open, onOpenChange, url, onUrlChange, onApply, onRemove, isEdit,
353
+ }: {
354
+ open: boolean
355
+ onOpenChange: (open: boolean) => void
356
+ url: string
357
+ onUrlChange: (url: string) => void
358
+ onApply: () => void
359
+ onRemove: (() => void) | null
360
+ isEdit: boolean
361
+ }) {
362
+ return (
363
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
364
+ <Dialog.Portal>
365
+ <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" />
366
+ <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">
367
+ <Dialog.Title className="text-lg leading-none font-semibold">
368
+ {isEdit ? 'Edit link' : 'Add link'}
369
+ </Dialog.Title>
370
+ <input
371
+ type="url"
372
+ value={url}
373
+ onChange={(e) => onUrlChange(e.target.value)}
374
+ onKeyDown={(e) => {
375
+ if (e.key === 'Enter') { e.preventDefault(); onApply() }
376
+ }}
377
+ placeholder="https://example.com"
378
+ autoFocus
379
+ 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"
380
+ />
381
+ <div className="flex flex-row-reverse items-center gap-2">
382
+ <button
383
+ type="button"
384
+ onClick={onApply}
385
+ 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"
386
+ >
387
+ {isEdit ? 'Update' : 'Add'}
388
+ </button>
389
+ <button
390
+ type="button"
391
+ onClick={() => onOpenChange(false)}
392
+ 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"
393
+ >
394
+ Cancel
395
+ </button>
396
+ {onRemove && (
397
+ <button
398
+ type="button"
399
+ onClick={onRemove}
400
+ 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"
401
+ >
402
+ Remove link
403
+ </button>
404
+ )}
405
+ </div>
406
+ </Dialog.Popup>
407
+ </Dialog.Portal>
408
+ </Dialog.Root>
409
+ )
410
+ }
411
+
412
+ /**
413
+ * Hook returning a tick that bumps on any selection / transaction change.
414
+ * The Toolbar renders dependent on this so active-state booleans stay fresh.
415
+ */
416
+ export function useEditorTick(editor: Editor | null): number {
417
+ const [tick, setTick] = useState(0)
418
+ useEffect(() => {
419
+ if (!editor) return
420
+ const bump = (): void => setTick((t) => t + 1)
421
+ editor.on('selectionUpdate', bump)
422
+ editor.on('transaction', bump)
423
+ return () => {
424
+ editor.off('selectionUpdate', bump)
425
+ editor.off('transaction', bump)
426
+ }
427
+ }, [editor])
428
+ return tick
429
+ }
430
+
431
+ function isMac(): boolean {
432
+ if (typeof navigator === 'undefined') return false
433
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
434
+ }
435
+
436
+ // Re-export the unused name so consumers can satisfy ToolbarGroups type checks
437
+ // when wiring custom layouts. Empty re-export side-effects nothing.
438
+ export type { ToolbarButtonId }