@pilotiq/tiptap 3.10.5 → 3.10.7

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