@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/dist/react/CollabTextRenderer.d.ts.map +1 -1
  8. package/dist/react/CollabTextRenderer.js +4 -4
  9. package/dist/react/CollabTextRenderer.js.map +1 -1
  10. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  11. package/dist/react/MarkdownEditor.js +4 -5
  12. package/dist/react/MarkdownEditor.js.map +1 -1
  13. package/dist/react/TiptapEditor.d.ts.map +1 -1
  14. package/dist/react/TiptapEditor.js +8 -7
  15. package/dist/react/TiptapEditor.js.map +1 -1
  16. package/package.json +6 -3
  17. package/dist/collabShapes.d.ts +0 -22
  18. package/dist/collabShapes.d.ts.map +0 -1
  19. package/dist/collabShapes.js +0 -2
  20. package/dist/collabShapes.js.map +0 -1
  21. package/src/Block.ts +0 -75
  22. package/src/MentionProvider.ts +0 -153
  23. package/src/PlainTextEditor.dom.test.ts +0 -111
  24. package/src/PlainTextEditor.test.ts +0 -158
  25. package/src/PlainTextEditor.ts +0 -229
  26. package/src/RichTextField.test.ts +0 -447
  27. package/src/RichTextField.ts +0 -508
  28. package/src/collabShapes.ts +0 -22
  29. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  30. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  31. package/src/extensions/AiSuggestionExtension.ts +0 -522
  32. package/src/extensions/BlockNodeExtension.ts +0 -134
  33. package/src/extensions/DragHandleExtension.ts +0 -184
  34. package/src/extensions/GridExtension.test.ts +0 -31
  35. package/src/extensions/GridExtension.ts +0 -138
  36. package/src/extensions/MentionExtension.ts +0 -248
  37. package/src/extensions/MergeTagExtension.ts +0 -75
  38. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  39. package/src/extensions/SlashCommandExtension.ts +0 -332
  40. package/src/extensions/TextSizeMarks.ts +0 -73
  41. package/src/index.ts +0 -62
  42. package/src/markdownExtension.ts +0 -19
  43. package/src/markdownStorage.ts +0 -49
  44. package/src/plugin.test.ts +0 -19
  45. package/src/plugin.ts +0 -26
  46. package/src/react/AiSuggestionBanner.tsx +0 -185
  47. package/src/react/BlockNodeView.tsx +0 -99
  48. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  49. package/src/react/BlockSidePanel.test.ts +0 -412
  50. package/src/react/BlockSidePanel.tsx +0 -451
  51. package/src/react/CollabTextRenderer.tsx +0 -230
  52. package/src/react/FloatingToolbar.tsx +0 -304
  53. package/src/react/MarkdownEditor.tsx +0 -606
  54. package/src/react/MentionMenu.tsx +0 -120
  55. package/src/react/Palette.tsx +0 -86
  56. package/src/react/SlashMenu.tsx +0 -129
  57. package/src/react/TableFloatingToolbar.tsx +0 -154
  58. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  59. package/src/react/TiptapEditor.tsx +0 -776
  60. package/src/react/Toolbar.tsx +0 -438
  61. package/src/react/toolbarButtons.tsx +0 -579
  62. package/src/react/useAiInlineDiff.ts +0 -342
  63. package/src/react/useAiSuggestionBridge.ts +0 -223
  64. package/src/register.test.ts +0 -14
  65. package/src/register.ts +0 -42
  66. package/src/render.test.ts +0 -745
  67. package/src/render.ts +0 -480
  68. package/src/surgicalOps.ts +0 -205
  69. package/src/test/setup.ts +0 -64
@@ -1,606 +0,0 @@
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
- import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
8
- // The `tiptap-markdown` chain (incl. CJS-only `markdown-it-task-lists`) is
9
- // pre-bundled into `dist/markdownExtension.js` at @pilotiq/tiptap build
10
- // time; importing the wrapper instead of `tiptap-markdown` directly
11
- // keeps the CJS interop on our side of the dist boundary.
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- import { Markdown } from '../markdownExtension.js'
14
- import {
15
- useCollabRoom,
16
- getCollabExtensions,
17
- useCollabSeed,
18
- useToast,
19
- type MarkdownEditorProps,
20
- } from '@pilotiq/pilotiq/react'
21
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
22
- import { AiInlineDiffExtension, aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
23
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
24
- import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
25
- import { AiSuggestionBanner } from './AiSuggestionBanner.js'
26
- import { getMarkdownString, parseMarkdownToHtml } from '../markdownStorage.js'
27
- import type { YDocShape } from '../collabShapes.js'
28
-
29
- // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
30
- // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
31
- // consistent with the rich-text toolbar.
32
- const ICON_PROPS = {
33
- width: 14, height: 14, viewBox: '0 0 24 24',
34
- fill: 'none', stroke: 'currentColor',
35
- strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const,
36
- 'aria-hidden': 'true' as const,
37
- }
38
- const Spinner = (
39
- <svg {...ICON_PROPS} className="animate-spin">
40
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
41
- </svg>
42
- )
43
- const SvgIcons: Record<string, React.ReactElement> = {
44
- bold: (
45
- <svg {...ICON_PROPS} strokeWidth={2.25}>
46
- <path d="M6 12h9a4 4 0 0 1 0 8H6Z" />
47
- <path d="M6 4h7a4 4 0 0 1 0 8H6Z" />
48
- </svg>
49
- ),
50
- italic: (
51
- <svg {...ICON_PROPS}>
52
- <line x1="19" y1="4" x2="10" y2="4" />
53
- <line x1="14" y1="20" x2="5" y2="20" />
54
- <line x1="15" y1="4" x2="9" y2="20" />
55
- </svg>
56
- ),
57
- strike: (
58
- <svg {...ICON_PROPS}>
59
- <path d="M16 4H9a3 3 0 0 0-2.83 4" />
60
- <path d="M14 12a4 4 0 0 1 0 8H6" />
61
- <line x1="4" y1="12" x2="20" y2="12" />
62
- </svg>
63
- ),
64
- link: (
65
- <svg {...ICON_PROPS}>
66
- <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
67
- <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
68
- </svg>
69
- ),
70
- heading: <span className="text-xs font-semibold leading-none">H2</span>,
71
- bulletList: (
72
- <svg {...ICON_PROPS}>
73
- <line x1="8" y1="6" x2="21" y2="6" />
74
- <line x1="8" y1="12" x2="21" y2="12" />
75
- <line x1="8" y1="18" x2="21" y2="18" />
76
- <circle cx="4" cy="6" r="1" />
77
- <circle cx="4" cy="12" r="1" />
78
- <circle cx="4" cy="18" r="1" />
79
- </svg>
80
- ),
81
- orderedList: (
82
- <svg {...ICON_PROPS}>
83
- <line x1="10" y1="6" x2="21" y2="6" />
84
- <line x1="10" y1="12" x2="21" y2="12" />
85
- <line x1="10" y1="18" x2="21" y2="18" />
86
- <path d="M4 6h1v4" />
87
- <path d="M4 10h2" />
88
- <path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
89
- </svg>
90
- ),
91
- blockquote: (
92
- <svg {...ICON_PROPS}>
93
- <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" />
94
- <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" />
95
- </svg>
96
- ),
97
- codeBlock: (
98
- <svg {...ICON_PROPS}>
99
- <polyline points="16 18 22 12 16 6" />
100
- <polyline points="8 6 2 12 8 18" />
101
- </svg>
102
- ),
103
- attachFiles: (
104
- <svg {...ICON_PROPS}>
105
- <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" />
106
- </svg>
107
- ),
108
- pencil: (
109
- <svg {...ICON_PROPS}>
110
- <path d="M12 20h9" />
111
- <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
112
- </svg>
113
- ),
114
- source: (
115
- <svg {...ICON_PROPS}>
116
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
117
- <polyline points="14 2 14 8 20 8" />
118
- <line x1="9" y1="13" x2="15" y2="13" />
119
- <line x1="9" y1="17" x2="15" y2="17" />
120
- </svg>
121
- ),
122
- eye: (
123
- <svg {...ICON_PROPS}>
124
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z" />
125
- <circle cx="12" cy="12" r="3" />
126
- </svg>
127
- ),
128
- }
129
-
130
- /**
131
- * Plug-in WYSIWYG markdown editor registered with pilotiq core's
132
- * `registerMarkdownEditor()`. Replaces the legacy textarea + manual-toolbar
133
- * UI with a real rich editor; serializes to markdown on every change via
134
- * `tiptap-markdown` so the wire format stays a plain markdown string under
135
- * the field name.
136
- *
137
- * Collab-aware: when a `<RecordCollabRoom>` is mounted up-tree AND
138
- * `registerCollabExtensions()` ran (both shipped by `@pilotiq-pro/collab`),
139
- * the editor binds to the room's shared `Y.XmlFragment` via Tiptap's
140
- * `Collaboration` extension. Every peer mounts the same editor against the
141
- * same fragment; markdown serialization runs locally per peer so only the
142
- * ProseMirror tree crosses the wire.
143
- *
144
- * Tabs (top-right):
145
- * - **Editor** (default) — WYSIWYG.
146
- * - **Source** — raw markdown textarea; on switch back to Editor the editor
147
- * parses the textarea contents (round-trips through tiptap-markdown).
148
- * - **Preview** — read-only render of the current markdown via the editor's
149
- * own HTML output. Same view a user would see on the public site if the
150
- * resource ships a read-side renderer.
151
- *
152
- * Single-source-of-truth posture: the editor's `onUpdate` is the canonical
153
- * write path. Source-tab edits flow back through the editor on tab-switch
154
- * (no dual state, no drift between source and editor doc).
155
- */
156
- export function MarkdownEditor({
157
- name,
158
- fragmentKey,
159
- defaultValue,
160
- placeholder,
161
- disabled = false,
162
- onChange,
163
- onBlur,
164
- toolbarButtons,
165
- minHeight,
166
- maxHeight,
167
- fileAttachmentsDirectory,
168
- fileAttachmentsVisibility,
169
- uploadUrl,
170
- }: MarkdownEditorProps): React.ReactElement | null {
171
- const room = useCollabRoom()
172
- const factory = getCollabExtensions()
173
- const collabActive = !!(room && factory)
174
-
175
- // Collab-stable identifier — same `name` (the FormData/AI routing
176
- // name) on top-level fields, but the row-id-anchored composite on
177
- // Repeater/Builder row leaves so the Y.XmlFragment survives row
178
- // reorders. AI suggestion routing + hidden input + banner stay on
179
- // `name` — they don't care about reorder stability.
180
- const collabName = fragmentKey ?? name
181
-
182
- const [tab, setTab] = useState<'editor' | 'source' | 'preview'>('editor')
183
- const [sourceDraft, setSourceDraft] = useState<string>(defaultValue)
184
- const [uploading, setUploading] = useState(false)
185
- const fileInputRef = useRef<HTMLInputElement | null>(null)
186
- // Toast handle for surfacing upload failures via the host's notification
187
- // stack. Falls back to a no-op when no ToasterProvider is mounted
188
- // (`useToast` returns a default context — see Toaster.tsx).
189
- const { notify } = useToast()
190
-
191
- // Collab extension factory output. Built once per editor mount (the
192
- // factory closes over the room's ydoc + provider + field name); keyed
193
- // remount below ensures we never swap it underneath the running editor.
194
- const collabExtensions = useMemo<AnyExtension[]>(() => {
195
- if (!collabActive || !room || !factory) return []
196
- return factory({
197
- ydoc: room.ydoc,
198
- provider: room.provider,
199
- fieldName: collabName,
200
- ...(room.user ? { user: room.user } : {}),
201
- }) as AnyExtension[]
202
- // eslint-disable-next-line react-hooks/exhaustive-deps
203
- }, [collabActive])
204
-
205
- const editor = useEditor(
206
- {
207
- // Tiptap v3 SSR guard. With `immediatelyRender: true` (default)
208
- // `useEditor` touches the DOM during construction; under Vike's
209
- // `onRenderHtml` that throws "SSR has been detected, please set
210
- // `immediatelyRender` explicitly to `false` to avoid hydration
211
- // mismatches." Deferring until the first React effect lets SSR
212
- // produce an empty shell + hydration mount the live editor.
213
- immediatelyRender: false,
214
- editable: !disabled,
215
- extensions: [
216
- StarterKit.configure({
217
- link: { openOnClick: false, autolink: true },
218
- // Collaboration brings its own Yjs-backed history — disable
219
- // StarterKit's local undoRedo when collab is active (else Tiptap
220
- // logs a "not compatible with @tiptap/extension-undo-redo" warning).
221
- ...(collabActive ? { undoRedo: false } : {}),
222
- }),
223
- // Markdown round-trip — parses `content` (when non-collab) and
224
- // exposes `editor.storage.markdown.getMarkdown()`. We pass `html:
225
- // false` because the wire format is markdown only.
226
- Markdown.configure({
227
- html: false,
228
- tightLists: true,
229
- breaks: false,
230
- linkify: true,
231
- transformPastedText: true,
232
- transformCopiedText: true,
233
- }),
234
- Image.configure({ inline: false, allowBase64: false }),
235
- Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
236
- // AI suggestions — chip widget for surgical (range-anchored) edits.
237
- AiSuggestionExtension,
238
- // AI inline diff — Tiptap-Pro-style visualization for whole-field
239
- // suggestions (prosemirror-changeset under the hood). Decorations
240
- // show green-background inserts inline + red-strikethrough widgets
241
- // for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
242
- // Reject via the extension's commands.
243
- AiInlineDiffExtension,
244
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
- ...(collabExtensions as any[]),
246
- ],
247
- // Collab takes ownership of the document — passing `content` would
248
- // race the Y.XmlFragment sync. Seed after first connect (effect below).
249
- content: collabActive ? '' : defaultValue,
250
- onUpdate({ editor }) {
251
- onChange(getMarkdownString(editor))
252
- },
253
- onBlur() { onBlur?.() },
254
- },
255
- // Re-mount when collab toggles. Other props (name, placeholder) are
256
- // stable per mount — the field renderer doesn't swap them at runtime.
257
- [collabActive],
258
- )
259
-
260
- useEffect(() => {
261
- if (!editor) return
262
- editor.setEditable(!disabled && tab === 'editor')
263
- }, [editor, disabled, tab])
264
-
265
- // Cross-package suggestion bridge — sync the host's
266
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
267
- // extension. No-op when no provider is mounted (default no-op context).
268
- //
269
- // Whole-field handling: NO chip widget here. The chip's `textContent`
270
- // renderer surfaces raw markdown (`## Heading\n- item`) as literal text
271
- // inside the green pill — visually unparseable for multi-paragraph
272
- // rewrites. Instead, `<AiSuggestionBanner>` mounts below the editor
273
- // (see render below). Producer-supplied range suggestions still ride
274
- // the inline chip path — those have a precise anchor worth showing
275
- // in context.
276
- const applyWholeField = (value: string): void => {
277
- if (!editor || editor.isDestroyed) return
278
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
279
- ;(editor.commands as any).setContent(value)
280
- }
281
- useAiSuggestionBridge(editor ?? null, name, {
282
- onApplyWholeField: applyWholeField,
283
- })
284
-
285
- // Inline diff for whole-field suggestions — replaces the editor doc with
286
- // the proposed markdown so the user sees the structural diff (inserted
287
- // headings / list items / etc.) before approving. Pipeline:
288
- // 1. tiptap-markdown's parser turns the source into HTML
289
- // (`editor.storage.markdown.parser.parse(value)` returns a string).
290
- // 2. ProseMirror's `DOMParser.fromSchema(schema).parseSlice(...)` turns
291
- // that HTML into a Slice against THIS editor's schema — same path
292
- // the editor's own clipboard-paste uses, so the slice is guaranteed
293
- // schema-valid.
294
- useAiInlineDiff(editor ?? null, name, {
295
- parseSuggestion: (ed, value) => {
296
- try {
297
- const html = parseMarkdownToHtml(ed, value)
298
- if (html === undefined) return null
299
- const container = document.createElement('div')
300
- container.innerHTML = html
301
- return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
302
- } catch { return null }
303
- },
304
- })
305
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
306
-
307
- // First-load seed for collab. Collaboration starts the editor empty
308
- // regardless of `content`; once the room's first sync resolves,
309
- // `useCollabSeed` runs the callback inside `ydoc.transact`. Empty
310
- // fragment + we have an initial value = first session for this
311
- // record. Mirrors the rich-text TiptapEditor seed path and the
312
- // CollabTextRenderer seed. Gates on `editor` so Tiptap v3's deferred
313
- // `immediatelyRender: false` mount completes first.
314
- //
315
- // Subscribe-after-sync mirror: after the seed branch (or no-op when
316
- // the fragment already has content from a remote peer), serialize
317
- // the editor's current markdown and propagate via `onChange` so the
318
- // host's hidden FormData input picks it up. The host (`MarkdownEditorHost`
319
- // in pilotiq core) drives the hidden input from React state that's
320
- // populated ONLY through `onChange`; in the cold-mount case
321
- // (fresh peer joining a populated doc) y-prosemirror's `ySyncPlugin`
322
- // view hook may run `_forceRerender` before the React owner has
323
- // installed the `update` listener that drives `onUpdate` — leaving
324
- // the input at its SSR-rendered `defaultValue`. Idempotent — when
325
- // `onUpdate` already propagated the value, this is a no-op
326
- // `setText(sameValue)`. Same shape as `TiptapEditor` /
327
- // `CollabTextRenderer` / `rowArrayBinding.subscribeRows`.
328
- useCollabSeed(
329
- editor && collabActive ? room : null,
330
- collabName,
331
- (doc) => {
332
- const fragment = (doc as YDocShape).getXmlFragment(collabName)
333
- if (fragment && fragment.length === 0 && defaultValue && editor) {
334
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
335
- const cmd = (editor.commands as any).setContent
336
- if (cmd) cmd(defaultValue)
337
- }
338
- if (editor) onChange(getMarkdownString(editor))
339
- },
340
- )
341
-
342
- // Source-tab → Editor: parse the textarea back into the editor (this also
343
- // emits onChange via the editor's onUpdate). One-way during the same flip.
344
- const enterEditorTab = (): void => {
345
- if (tab === 'source' && editor) {
346
- try {
347
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
348
- ;(editor.commands as any).setContent(sourceDraft)
349
- } catch { /* ignore parse errors */ }
350
- }
351
- setTab('editor')
352
- }
353
-
354
- const enterSourceTab = (): void => {
355
- if (editor) {
356
- setSourceDraft(getMarkdownString(editor))
357
- }
358
- setTab('source')
359
- }
360
-
361
- const enterPreviewTab = (): void => {
362
- setTab('preview')
363
- }
364
-
365
- // Live preview HTML is the editor's own HTML output — same renderer that
366
- // produced what the user sees in the Editor tab. Read-only.
367
- const previewHtml = useMemo<string>(() => {
368
- if (tab !== 'preview' || !editor) return ''
369
- try {
370
- return editor.getHTML()
371
- } catch {
372
- return ''
373
- }
374
- }, [tab, editor])
375
-
376
- const uploadAndInsert = async (file: File): Promise<void> => {
377
- if (!uploadUrl || !editor) return
378
- setUploading(true)
379
- try {
380
- const fd = new FormData()
381
- fd.append('file', file)
382
- if (fileAttachmentsDirectory) fd.append('directory', fileAttachmentsDirectory)
383
- if (fileAttachmentsVisibility) fd.append('visibility', fileAttachmentsVisibility)
384
- fd.append('fieldName', name)
385
- let res: Response
386
- try {
387
- res = await fetch(uploadUrl, { method: 'POST', body: fd, headers: { Accept: 'application/json' } })
388
- } catch {
389
- // Network-level failure (offline, DNS, etc.) — surface as a
390
- // toast since the user clicked Upload and would otherwise see
391
- // only the spinner stop.
392
- notify({ title: 'Upload failed', body: 'Could not reach the upload endpoint.', type: 'error' })
393
- return
394
- }
395
- const data = await res.json().catch(() => ({} as { ok?: boolean; url?: string; error?: string }))
396
- if (!res.ok || !data.ok || !data.url) {
397
- notify({
398
- title: 'Upload failed',
399
- body: data.error ?? `Upload failed (status ${res.status}).`,
400
- type: 'error',
401
- })
402
- return
403
- }
404
- const isImage = file.type.startsWith('image/')
405
- if (isImage) {
406
- editor.chain().focus().setImage({ src: data.url, alt: file.name }).run()
407
- } else {
408
- editor.chain().focus().insertContent(`[${file.name}](${data.url})`).run()
409
- }
410
- } finally {
411
- setUploading(false)
412
- }
413
- }
414
-
415
- const onAttachClick = (): void => {
416
- const el = fileInputRef.current
417
- if (el) el.click()
418
- }
419
-
420
- const onFilePicked = (e: React.ChangeEvent<HTMLInputElement>): void => {
421
- const file = e.target.files?.[0]
422
- if (file) void uploadAndInsert(file)
423
- e.target.value = ''
424
- }
425
-
426
- // Toolbar item resolution. The pilotiq-side ids map onto Tiptap commands.
427
- // attachFiles is gated on a configured uploadUrl (server strips it server-
428
- // side when no adapter is registered, but defensive double-gate here too).
429
- const allow = useMemo(() => new Set(toolbarButtons), [toolbarButtons])
430
- const canAttach = allow.has('attachFiles') && !!uploadUrl
431
-
432
- const exec = (id: string): void => {
433
- if (!editor) return
434
- const c = editor.chain().focus()
435
- switch (id) {
436
- case 'bold': c.toggleBold().run(); break
437
- case 'italic': c.toggleItalic().run(); break
438
- case 'strike': c.toggleStrike().run(); break
439
- case 'link': {
440
- const prev = editor.getAttributes('link').href as string | undefined
441
- const url = window.prompt('URL', prev ?? '') ?? ''
442
- if (url === '') c.unsetLink().run()
443
- else c.extendMarkRange('link').setLink({ href: url }).run()
444
- break
445
- }
446
- case 'heading': c.toggleHeading({ level: 2 }).run(); break
447
- case 'bulletList': c.toggleBulletList().run(); break
448
- case 'orderedList': c.toggleOrderedList().run(); break
449
- case 'blockquote': c.toggleBlockquote().run(); break
450
- case 'codeBlock': c.toggleCodeBlock().run(); break
451
- case 'attachFiles': onAttachClick(); break
452
- default: /* unknown id — skip */ break
453
- }
454
- }
455
-
456
- const isActive = (id: string): boolean => {
457
- if (!editor) return false
458
- switch (id) {
459
- case 'bold': return editor.isActive('bold')
460
- case 'italic': return editor.isActive('italic')
461
- case 'strike': return editor.isActive('strike')
462
- case 'link': return editor.isActive('link')
463
- case 'heading': return editor.isActive('heading', { level: 2 })
464
- case 'bulletList': return editor.isActive('bulletList')
465
- case 'orderedList': return editor.isActive('orderedList')
466
- case 'blockquote': return editor.isActive('blockquote')
467
- case 'codeBlock': return editor.isActive('codeBlock')
468
- default: return false
469
- }
470
- }
471
-
472
- const labels: Record<string, string> = {
473
- bold: 'Bold (⌘B)',
474
- italic: 'Italic (⌘I)',
475
- strike: 'Strikethrough',
476
- link: 'Link (⌘K)',
477
- heading: 'Heading',
478
- bulletList: 'Bulleted list',
479
- orderedList: 'Numbered list',
480
- blockquote: 'Quote',
481
- codeBlock: 'Code block',
482
- attachFiles: 'Attach file',
483
- }
484
-
485
- const wrapperStyle: React.CSSProperties = {}
486
- if (minHeight) wrapperStyle.minHeight = minHeight
487
- if (maxHeight) wrapperStyle.maxHeight = maxHeight
488
-
489
- return (
490
- <div className="flex flex-col rounded-md border bg-background">
491
- {canAttach && (
492
- <input
493
- ref={fileInputRef}
494
- type="file"
495
- className="hidden"
496
- onChange={onFilePicked}
497
- />
498
- )}
499
- <div className="flex items-center justify-between border-b px-2 py-1 gap-2">
500
- <div className="flex items-center gap-0.5">
501
- <TabButton active={tab === 'editor'} onClick={enterEditorTab}>
502
- {SvgIcons['pencil']} Editor
503
- </TabButton>
504
- <TabButton active={tab === 'source'} onClick={enterSourceTab}>
505
- {SvgIcons['source']} Source
506
- </TabButton>
507
- <TabButton active={tab === 'preview'} onClick={enterPreviewTab}>
508
- {SvgIcons['eye']} Preview
509
- </TabButton>
510
- </div>
511
- {tab === 'editor' && toolbarButtons.length > 0 && (
512
- <div className="flex items-center gap-0.5">
513
- {toolbarButtons.map((b: string) => {
514
- if (b === 'attachFiles' && !canAttach) return null
515
- const icon = SvgIcons[b]
516
- if (!icon) return null
517
- const isAttach = b === 'attachFiles'
518
- const active = isActive(b)
519
- return (
520
- <button
521
- key={b}
522
- type="button"
523
- className={[
524
- 'inline-flex size-7 items-center justify-center rounded text-foreground transition-colors',
525
- active
526
- ? 'bg-accent text-accent-foreground'
527
- : 'hover:bg-accent hover:text-accent-foreground',
528
- 'disabled:opacity-50',
529
- ].join(' ')}
530
- onClick={() => exec(b)}
531
- disabled={disabled || (isAttach && uploading)}
532
- title={labels[b] ?? b}
533
- aria-label={labels[b] ?? b}
534
- aria-pressed={active}
535
- >
536
- {isAttach && uploading ? Spinner : icon}
537
- </button>
538
- )
539
- })}
540
- </div>
541
- )}
542
- </div>
543
-
544
- {tab === 'editor' && (
545
- <div
546
- className="prose prose-sm dark:prose-invert max-w-none px-3 py-2 [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[6rem]"
547
- style={wrapperStyle}
548
- >
549
- <EditorContent editor={editor} />
550
- </div>
551
- )}
552
-
553
- {tab === 'source' && (
554
- <textarea
555
- className="w-full resize-y bg-transparent px-3 py-2 text-sm font-mono leading-relaxed outline-none disabled:opacity-50"
556
- style={wrapperStyle}
557
- value={sourceDraft}
558
- onChange={(e) => setSourceDraft(e.target.value)}
559
- {...(placeholder !== undefined ? { placeholder } : {})}
560
- disabled={disabled}
561
- aria-label={`${name} (markdown source)`}
562
- />
563
- )}
564
-
565
- {tab === 'preview' && (
566
- <div
567
- className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
568
- style={wrapperStyle}
569
- dangerouslySetInnerHTML={{ __html: previewHtml || '<p class="text-muted-foreground italic">Nothing to preview</p>' }}
570
- />
571
- )}
572
-
573
- <AiSuggestionBanner
574
- fieldName={name}
575
- onApplyWholeField={applyWholeField}
576
- {...(isDiffActive && editor
577
- ? {
578
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
579
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
580
- }
581
- : {})}
582
- />
583
- </div>
584
- )
585
- }
586
-
587
- function TabButton({ active, onClick, children }: {
588
- active: boolean
589
- onClick: () => void
590
- children: React.ReactNode
591
- }): React.ReactElement {
592
- return (
593
- <button
594
- type="button"
595
- className={[
596
- 'inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors',
597
- active
598
- ? 'bg-accent text-accent-foreground'
599
- : 'text-muted-foreground hover:text-foreground',
600
- ].join(' ')}
601
- onClick={onClick}
602
- >
603
- {children}
604
- </button>
605
- )
606
- }