@pilotiq/tiptap 3.10.4 → 3.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
package/src/react/Toolbar.tsx
DELETED
|
@@ -1,438 +0,0 @@
|
|
|
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 }
|