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