@pilotiq/tiptap 3.2.0 → 3.3.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.
@@ -0,0 +1,506 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useEditor, EditorContent } from '@tiptap/react'
3
+ import type { AnyExtension } from '@tiptap/core'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import Placeholder from '@tiptap/extension-placeholder'
6
+ import Image from '@tiptap/extension-image'
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ import { Markdown } from 'tiptap-markdown'
9
+ import {
10
+ useCollabRoom,
11
+ getCollabExtensions,
12
+ type MarkdownEditorProps,
13
+ } from '@pilotiq/pilotiq/react'
14
+
15
+ // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
16
+ // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
17
+ // consistent with the rich-text toolbar.
18
+ const ICON_PROPS = {
19
+ width: 14, height: 14, viewBox: '0 0 24 24',
20
+ fill: 'none', stroke: 'currentColor',
21
+ strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const,
22
+ 'aria-hidden': 'true' as const,
23
+ }
24
+ const Spinner = (
25
+ <svg {...ICON_PROPS} className="animate-spin">
26
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
27
+ </svg>
28
+ )
29
+ const SvgIcons: Record<string, React.ReactElement> = {
30
+ bold: (
31
+ <svg {...ICON_PROPS} strokeWidth={2.25}>
32
+ <path d="M6 12h9a4 4 0 0 1 0 8H6Z" />
33
+ <path d="M6 4h7a4 4 0 0 1 0 8H6Z" />
34
+ </svg>
35
+ ),
36
+ italic: (
37
+ <svg {...ICON_PROPS}>
38
+ <line x1="19" y1="4" x2="10" y2="4" />
39
+ <line x1="14" y1="20" x2="5" y2="20" />
40
+ <line x1="15" y1="4" x2="9" y2="20" />
41
+ </svg>
42
+ ),
43
+ strike: (
44
+ <svg {...ICON_PROPS}>
45
+ <path d="M16 4H9a3 3 0 0 0-2.83 4" />
46
+ <path d="M14 12a4 4 0 0 1 0 8H6" />
47
+ <line x1="4" y1="12" x2="20" y2="12" />
48
+ </svg>
49
+ ),
50
+ link: (
51
+ <svg {...ICON_PROPS}>
52
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
53
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
54
+ </svg>
55
+ ),
56
+ heading: <span className="text-xs font-semibold leading-none">H2</span>,
57
+ bulletList: (
58
+ <svg {...ICON_PROPS}>
59
+ <line x1="8" y1="6" x2="21" y2="6" />
60
+ <line x1="8" y1="12" x2="21" y2="12" />
61
+ <line x1="8" y1="18" x2="21" y2="18" />
62
+ <circle cx="4" cy="6" r="1" />
63
+ <circle cx="4" cy="12" r="1" />
64
+ <circle cx="4" cy="18" r="1" />
65
+ </svg>
66
+ ),
67
+ orderedList: (
68
+ <svg {...ICON_PROPS}>
69
+ <line x1="10" y1="6" x2="21" y2="6" />
70
+ <line x1="10" y1="12" x2="21" y2="12" />
71
+ <line x1="10" y1="18" x2="21" y2="18" />
72
+ <path d="M4 6h1v4" />
73
+ <path d="M4 10h2" />
74
+ <path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
75
+ </svg>
76
+ ),
77
+ blockquote: (
78
+ <svg {...ICON_PROPS}>
79
+ <path d="M3 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" />
80
+ <path d="M15 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" />
81
+ </svg>
82
+ ),
83
+ codeBlock: (
84
+ <svg {...ICON_PROPS}>
85
+ <polyline points="16 18 22 12 16 6" />
86
+ <polyline points="8 6 2 12 8 18" />
87
+ </svg>
88
+ ),
89
+ attachFiles: (
90
+ <svg {...ICON_PROPS}>
91
+ <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
92
+ </svg>
93
+ ),
94
+ pencil: (
95
+ <svg {...ICON_PROPS}>
96
+ <path d="M12 20h9" />
97
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
98
+ </svg>
99
+ ),
100
+ source: (
101
+ <svg {...ICON_PROPS}>
102
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
103
+ <polyline points="14 2 14 8 20 8" />
104
+ <line x1="9" y1="13" x2="15" y2="13" />
105
+ <line x1="9" y1="17" x2="15" y2="17" />
106
+ </svg>
107
+ ),
108
+ eye: (
109
+ <svg {...ICON_PROPS}>
110
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z" />
111
+ <circle cx="12" cy="12" r="3" />
112
+ </svg>
113
+ ),
114
+ }
115
+
116
+ /**
117
+ * Plug-in WYSIWYG markdown editor registered with pilotiq core's
118
+ * `registerMarkdownEditor()`. Replaces the legacy textarea + manual-toolbar
119
+ * UI with a real rich editor; serializes to markdown on every change via
120
+ * `tiptap-markdown` so the wire format stays a plain markdown string under
121
+ * the field name.
122
+ *
123
+ * Collab-aware: when a `<RecordCollabRoom>` is mounted up-tree AND
124
+ * `registerCollabExtensions()` ran (both shipped by `@pilotiq-pro/collab`),
125
+ * the editor binds to the room's shared `Y.XmlFragment` via Tiptap's
126
+ * `Collaboration` extension. Every peer mounts the same editor against the
127
+ * same fragment; markdown serialization runs locally per peer so only the
128
+ * ProseMirror tree crosses the wire.
129
+ *
130
+ * Tabs (top-right):
131
+ * - **Editor** (default) — WYSIWYG.
132
+ * - **Source** — raw markdown textarea; on switch back to Editor the editor
133
+ * parses the textarea contents (round-trips through tiptap-markdown).
134
+ * - **Preview** — read-only render of the current markdown via the editor's
135
+ * own HTML output. Same view a user would see on the public site if the
136
+ * resource ships a read-side renderer.
137
+ *
138
+ * Single-source-of-truth posture: the editor's `onUpdate` is the canonical
139
+ * write path. Source-tab edits flow back through the editor on tab-switch
140
+ * (no dual state, no drift between source and editor doc).
141
+ */
142
+ export function MarkdownEditor({
143
+ name,
144
+ defaultValue,
145
+ placeholder,
146
+ disabled = false,
147
+ onChange,
148
+ onBlur,
149
+ toolbarButtons,
150
+ minHeight,
151
+ maxHeight,
152
+ fileAttachmentsDirectory,
153
+ fileAttachmentsVisibility,
154
+ uploadUrl,
155
+ }: MarkdownEditorProps): React.ReactElement | null {
156
+ const room = useCollabRoom()
157
+ const factory = getCollabExtensions()
158
+ const collabActive = !!(room && factory)
159
+
160
+ const [tab, setTab] = useState<'editor' | 'source' | 'preview'>('editor')
161
+ const [sourceDraft, setSourceDraft] = useState<string>(defaultValue)
162
+ const [uploading, setUploading] = useState(false)
163
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
164
+
165
+ // Collab extension factory output. Built once per editor mount (the
166
+ // factory closes over the room's ydoc + provider + field name); keyed
167
+ // remount below ensures we never swap it underneath the running editor.
168
+ const collabExtensions = useMemo<AnyExtension[]>(() => {
169
+ if (!collabActive || !room || !factory) return []
170
+ return factory({
171
+ ydoc: room.ydoc,
172
+ provider: room.provider,
173
+ fieldName: name,
174
+ ...(room.user ? { user: room.user } : {}),
175
+ }) as AnyExtension[]
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, [collabActive])
178
+
179
+ const editor = useEditor(
180
+ {
181
+ editable: !disabled,
182
+ extensions: [
183
+ StarterKit.configure({
184
+ link: { openOnClick: false, autolink: true },
185
+ // Collaboration brings its own Yjs-backed history — disable
186
+ // StarterKit's local undoRedo when collab is active (else Tiptap
187
+ // logs a "not compatible with @tiptap/extension-undo-redo" warning).
188
+ ...(collabActive ? { undoRedo: false } : {}),
189
+ }),
190
+ // Markdown round-trip — parses `content` (when non-collab) and
191
+ // exposes `editor.storage.markdown.getMarkdown()`. We pass `html:
192
+ // false` because the wire format is markdown only.
193
+ Markdown.configure({
194
+ html: false,
195
+ tightLists: true,
196
+ breaks: false,
197
+ linkify: true,
198
+ transformPastedText: true,
199
+ transformCopiedText: true,
200
+ }),
201
+ Image.configure({ inline: false, allowBase64: false }),
202
+ Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ ...(collabExtensions as any[]),
205
+ ],
206
+ // Collab takes ownership of the document — passing `content` would
207
+ // race the Y.XmlFragment sync. Seed after first connect (effect below).
208
+ content: collabActive ? '' : defaultValue,
209
+ onUpdate({ editor }) {
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ const storage = (editor.storage as any).markdown
212
+ const md = typeof storage?.getMarkdown === 'function' ? storage.getMarkdown() : ''
213
+ onChange(md)
214
+ },
215
+ onBlur() { onBlur?.() },
216
+ },
217
+ // Re-mount when collab toggles. Other props (name, placeholder) are
218
+ // stable per mount — the field renderer doesn't swap them at runtime.
219
+ [collabActive],
220
+ )
221
+
222
+ useEffect(() => {
223
+ if (!editor) return
224
+ editor.setEditable(!disabled && tab === 'editor')
225
+ }, [editor, disabled, tab])
226
+
227
+ // First-load seed for collab. Collaboration starts the editor empty
228
+ // regardless of `content`; once the provider syncs from the server we
229
+ // check whether the field's `Y.XmlFragment` was ever written. Empty +
230
+ // we have an initial value = first session for this record. Mirrors
231
+ // the rich-text TiptapEditor seed path and the CollabTextRenderer seed.
232
+ const [hasSeeded, setHasSeeded] = useState(false)
233
+ useEffect(() => {
234
+ if (!editor || !collabActive || !room || hasSeeded) return
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ const ydoc = room.ydoc as any
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ const provider = room.provider as any
239
+ if (!ydoc || !provider) return
240
+
241
+ const trySeed = (): void => {
242
+ try {
243
+ const fragment = ydoc.getXmlFragment(name)
244
+ if (fragment && fragment.length === 0 && defaultValue) {
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
+ const cmd = (editor.commands as any).setContent
247
+ if (cmd) cmd(defaultValue)
248
+ }
249
+ setHasSeeded(true)
250
+ } catch {
251
+ setHasSeeded(true)
252
+ }
253
+ }
254
+
255
+ if (provider.synced) {
256
+ trySeed()
257
+ return
258
+ }
259
+ provider.once('synced', trySeed)
260
+ return () => {
261
+ try { provider.off?.('synced', trySeed) } catch { /* ignore */ }
262
+ }
263
+ // eslint-disable-next-line react-hooks/exhaustive-deps
264
+ }, [editor, collabActive, room])
265
+
266
+ // Source-tab → Editor: parse the textarea back into the editor (this also
267
+ // emits onChange via the editor's onUpdate). One-way during the same flip.
268
+ const enterEditorTab = (): void => {
269
+ if (tab === 'source' && editor) {
270
+ try {
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
+ ;(editor.commands as any).setContent(sourceDraft)
273
+ } catch { /* ignore parse errors */ }
274
+ }
275
+ setTab('editor')
276
+ }
277
+
278
+ const enterSourceTab = (): void => {
279
+ if (editor) {
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
281
+ const storage = (editor.storage as any).markdown
282
+ const md = typeof storage?.getMarkdown === 'function' ? storage.getMarkdown() : ''
283
+ setSourceDraft(md)
284
+ }
285
+ setTab('source')
286
+ }
287
+
288
+ const enterPreviewTab = (): void => {
289
+ setTab('preview')
290
+ }
291
+
292
+ // Live preview HTML is the editor's own HTML output — same renderer that
293
+ // produced what the user sees in the Editor tab. Read-only.
294
+ const previewHtml = useMemo<string>(() => {
295
+ if (tab !== 'preview' || !editor) return ''
296
+ try {
297
+ return editor.getHTML()
298
+ } catch {
299
+ return ''
300
+ }
301
+ }, [tab, editor])
302
+
303
+ const uploadAndInsert = async (file: File): Promise<void> => {
304
+ if (!uploadUrl || !editor) return
305
+ setUploading(true)
306
+ try {
307
+ const fd = new FormData()
308
+ fd.append('file', file)
309
+ if (fileAttachmentsDirectory) fd.append('directory', fileAttachmentsDirectory)
310
+ if (fileAttachmentsVisibility) fd.append('visibility', fileAttachmentsVisibility)
311
+ fd.append('fieldName', name)
312
+ const res = await fetch(uploadUrl, { method: 'POST', body: fd, headers: { Accept: 'application/json' } })
313
+ const data = await res.json().catch(() => ({} as { ok?: boolean; url?: string; error?: string }))
314
+ if (!res.ok || !data.ok || !data.url) return
315
+ const isImage = file.type.startsWith('image/')
316
+ if (isImage) {
317
+ editor.chain().focus().setImage({ src: data.url, alt: file.name }).run()
318
+ } else {
319
+ editor.chain().focus().insertContent(`[${file.name}](${data.url})`).run()
320
+ }
321
+ } finally {
322
+ setUploading(false)
323
+ }
324
+ }
325
+
326
+ const onAttachClick = (): void => {
327
+ const el = fileInputRef.current
328
+ if (el) el.click()
329
+ }
330
+
331
+ const onFilePicked = (e: React.ChangeEvent<HTMLInputElement>): void => {
332
+ const file = e.target.files?.[0]
333
+ if (file) void uploadAndInsert(file)
334
+ e.target.value = ''
335
+ }
336
+
337
+ // Toolbar item resolution. The pilotiq-side ids map onto Tiptap commands.
338
+ // attachFiles is gated on a configured uploadUrl (server strips it server-
339
+ // side when no adapter is registered, but defensive double-gate here too).
340
+ const allow = useMemo(() => new Set(toolbarButtons), [toolbarButtons])
341
+ const canAttach = allow.has('attachFiles') && !!uploadUrl
342
+
343
+ const exec = (id: string): void => {
344
+ if (!editor) return
345
+ const c = editor.chain().focus()
346
+ switch (id) {
347
+ case 'bold': c.toggleBold().run(); break
348
+ case 'italic': c.toggleItalic().run(); break
349
+ case 'strike': c.toggleStrike().run(); break
350
+ case 'link': {
351
+ const prev = editor.getAttributes('link').href as string | undefined
352
+ const url = window.prompt('URL', prev ?? '') ?? ''
353
+ if (url === '') c.unsetLink().run()
354
+ else c.extendMarkRange('link').setLink({ href: url }).run()
355
+ break
356
+ }
357
+ case 'heading': c.toggleHeading({ level: 2 }).run(); break
358
+ case 'bulletList': c.toggleBulletList().run(); break
359
+ case 'orderedList': c.toggleOrderedList().run(); break
360
+ case 'blockquote': c.toggleBlockquote().run(); break
361
+ case 'codeBlock': c.toggleCodeBlock().run(); break
362
+ case 'attachFiles': onAttachClick(); break
363
+ default: /* unknown id — skip */ break
364
+ }
365
+ }
366
+
367
+ const isActive = (id: string): boolean => {
368
+ if (!editor) return false
369
+ switch (id) {
370
+ case 'bold': return editor.isActive('bold')
371
+ case 'italic': return editor.isActive('italic')
372
+ case 'strike': return editor.isActive('strike')
373
+ case 'link': return editor.isActive('link')
374
+ case 'heading': return editor.isActive('heading', { level: 2 })
375
+ case 'bulletList': return editor.isActive('bulletList')
376
+ case 'orderedList': return editor.isActive('orderedList')
377
+ case 'blockquote': return editor.isActive('blockquote')
378
+ case 'codeBlock': return editor.isActive('codeBlock')
379
+ default: return false
380
+ }
381
+ }
382
+
383
+ const labels: Record<string, string> = {
384
+ bold: 'Bold (⌘B)',
385
+ italic: 'Italic (⌘I)',
386
+ strike: 'Strikethrough',
387
+ link: 'Link (⌘K)',
388
+ heading: 'Heading',
389
+ bulletList: 'Bulleted list',
390
+ orderedList: 'Numbered list',
391
+ blockquote: 'Quote',
392
+ codeBlock: 'Code block',
393
+ attachFiles: 'Attach file',
394
+ }
395
+
396
+ const wrapperStyle: React.CSSProperties = {}
397
+ if (minHeight) wrapperStyle.minHeight = minHeight
398
+ if (maxHeight) wrapperStyle.maxHeight = maxHeight
399
+
400
+ return (
401
+ <div className="flex flex-col rounded-md border bg-background">
402
+ {canAttach && (
403
+ <input
404
+ ref={fileInputRef}
405
+ type="file"
406
+ className="hidden"
407
+ onChange={onFilePicked}
408
+ />
409
+ )}
410
+ <div className="flex items-center justify-between border-b px-2 py-1 gap-2">
411
+ <div className="flex items-center gap-0.5">
412
+ <TabButton active={tab === 'editor'} onClick={enterEditorTab}>
413
+ {SvgIcons['pencil']} Editor
414
+ </TabButton>
415
+ <TabButton active={tab === 'source'} onClick={enterSourceTab}>
416
+ {SvgIcons['source']} Source
417
+ </TabButton>
418
+ <TabButton active={tab === 'preview'} onClick={enterPreviewTab}>
419
+ {SvgIcons['eye']} Preview
420
+ </TabButton>
421
+ </div>
422
+ {tab === 'editor' && toolbarButtons.length > 0 && (
423
+ <div className="flex items-center gap-0.5">
424
+ {toolbarButtons.map((b: string) => {
425
+ if (b === 'attachFiles' && !canAttach) return null
426
+ const icon = SvgIcons[b]
427
+ if (!icon) return null
428
+ const isAttach = b === 'attachFiles'
429
+ const active = isActive(b)
430
+ return (
431
+ <button
432
+ key={b}
433
+ type="button"
434
+ className={[
435
+ 'inline-flex size-7 items-center justify-center rounded text-foreground transition-colors',
436
+ active
437
+ ? 'bg-accent text-accent-foreground'
438
+ : 'hover:bg-accent hover:text-accent-foreground',
439
+ 'disabled:opacity-50',
440
+ ].join(' ')}
441
+ onClick={() => exec(b)}
442
+ disabled={disabled || (isAttach && uploading)}
443
+ title={labels[b] ?? b}
444
+ aria-label={labels[b] ?? b}
445
+ aria-pressed={active}
446
+ >
447
+ {isAttach && uploading ? Spinner : icon}
448
+ </button>
449
+ )
450
+ })}
451
+ </div>
452
+ )}
453
+ </div>
454
+
455
+ {tab === 'editor' && (
456
+ <div
457
+ className="prose prose-sm dark:prose-invert max-w-none px-3 py-2 [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[6rem]"
458
+ style={wrapperStyle}
459
+ >
460
+ <EditorContent editor={editor} />
461
+ </div>
462
+ )}
463
+
464
+ {tab === 'source' && (
465
+ <textarea
466
+ className="w-full resize-y bg-transparent px-3 py-2 text-sm font-mono leading-relaxed outline-none disabled:opacity-50"
467
+ style={wrapperStyle}
468
+ value={sourceDraft}
469
+ onChange={(e) => setSourceDraft(e.target.value)}
470
+ {...(placeholder !== undefined ? { placeholder } : {})}
471
+ disabled={disabled}
472
+ aria-label={`${name} (markdown source)`}
473
+ />
474
+ )}
475
+
476
+ {tab === 'preview' && (
477
+ <div
478
+ className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
479
+ style={wrapperStyle}
480
+ dangerouslySetInnerHTML={{ __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' }}
481
+ />
482
+ )}
483
+ </div>
484
+ )
485
+ }
486
+
487
+ function TabButton({ active, onClick, children }: {
488
+ active: boolean
489
+ onClick: () => void
490
+ children: React.ReactNode
491
+ }): React.ReactElement {
492
+ return (
493
+ <button
494
+ type="button"
495
+ className={[
496
+ 'inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors',
497
+ active
498
+ ? 'bg-accent text-accent-foreground'
499
+ : 'text-muted-foreground hover:text-foreground',
500
+ ].join(' ')}
501
+ onClick={onClick}
502
+ >
503
+ {children}
504
+ </button>
505
+ )
506
+ }
package/src/register.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { registerFieldRenderer } from '@pilotiq/pilotiq/react'
1
+ import { registerFieldRenderer, registerCollabTextRenderer, registerMarkdownEditor } from '@pilotiq/pilotiq/react'
2
2
  import { registerRichTextRenderer } from '@pilotiq/pilotiq/richtext'
3
3
  import { TiptapEditor } from './react/TiptapEditor.js'
4
+ import { CollabTextRenderer } from './react/CollabTextRenderer.js'
5
+ import { MarkdownEditor } from './react/MarkdownEditor.js'
4
6
  import { renderRichTextToHtml, isRichTextValue } from './render.js'
5
7
 
6
8
  /**
@@ -24,4 +26,17 @@ import { renderRichTextToHtml, isRichTextValue } from './render.js'
24
26
  export function registerTiptap(): void {
25
27
  registerFieldRenderer('richtext', TiptapEditor)
26
28
  registerRichTextRenderer(renderRichTextToHtml, isRichTextValue)
29
+ // Phase B — opt every plain-text field in the panel into y-prosemirror
30
+ // backing when collab is on. `TextLikeInput` checks this registry; if it's
31
+ // populated AND a `<RecordCollabRoom>` is up-tree AND the field hasn't opted
32
+ // out via `.collab(false)`, the renderer mounts `CollabTextRenderer`
33
+ // instead of the legacy `Y.Text` + `computeDelta` + `preserveCursor` path.
34
+ registerCollabTextRenderer(CollabTextRenderer)
35
+ // WYSIWYG markdown editor — replaces `MarkdownField`'s legacy textarea +
36
+ // manual-toolbar path with a real rich editor that serializes to markdown
37
+ // via `tiptap-markdown` on every change. Collab-aware on the same
38
+ // `useCollabRoom()` + `getCollabExtensions()` plumbing as the rich-text
39
+ // editor. Without `@pilotiq/tiptap` installed, `MarkdownInput` falls back
40
+ // to the textarea path so panels that skip the adapter still work.
41
+ registerMarkdownEditor(MarkdownEditor)
27
42
  }