@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.
- package/dist/PlainTextEditor.d.ts +91 -0
- package/dist/PlainTextEditor.d.ts.map +1 -0
- package/dist/PlainTextEditor.js +157 -0
- package/dist/PlainTextEditor.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/react/CollabTextRenderer.d.ts +26 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -0
- package/dist/react/CollabTextRenderer.js +149 -0
- package/dist/react/CollabTextRenderer.js.map +1 -0
- package/dist/react/MarkdownEditor.d.ts +30 -0
- package/dist/react/MarkdownEditor.d.ts.map +1 -0
- package/dist/react/MarkdownEditor.js +354 -0
- package/dist/react/MarkdownEditor.js.map +1 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/register.js +16 -1
- package/dist/register.js.map +1 -1
- package/package.json +4 -2
- package/src/PlainTextEditor.test.ts +158 -0
- package/src/PlainTextEditor.ts +229 -0
- package/src/index.ts +6 -0
- package/src/react/CollabTextRenderer.tsx +165 -0
- package/src/react/MarkdownEditor.tsx +506 -0
- package/src/register.ts +16 -1
|
@@ -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
|
}
|