@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,776 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
|
3
|
-
import StarterKit from '@tiptap/starter-kit'
|
|
4
|
-
import Placeholder from '@tiptap/extension-placeholder'
|
|
5
|
-
import Subscript from '@tiptap/extension-subscript'
|
|
6
|
-
import Superscript from '@tiptap/extension-superscript'
|
|
7
|
-
import TextAlign from '@tiptap/extension-text-align'
|
|
8
|
-
import { TextStyle } from '@tiptap/extension-text-style'
|
|
9
|
-
import { Color } from '@tiptap/extension-color'
|
|
10
|
-
import Highlight from '@tiptap/extension-highlight'
|
|
11
|
-
import Image from '@tiptap/extension-image'
|
|
12
|
-
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
|
13
|
-
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details'
|
|
14
|
-
import { Grid, GridColumn } from '../extensions/GridExtension.js'
|
|
15
|
-
import { Popover } from '@base-ui/react/popover'
|
|
16
|
-
import type {
|
|
17
|
-
FieldRendererProps,
|
|
18
|
-
CollabRoom,
|
|
19
|
-
CollabExtensionFactory,
|
|
20
|
-
} from '@pilotiq/pilotiq/react'
|
|
21
|
-
import { useCollabRoom, getCollabExtensions, useCollabSeed, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react'
|
|
22
|
-
import type { YDocShape } from '../collabShapes.js'
|
|
23
|
-
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
24
|
-
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
|
|
25
|
-
import { AiSuggestionBanner } from './AiSuggestionBanner.js'
|
|
26
|
-
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
|
|
27
|
-
import type { BlockMeta } from '../Block.js'
|
|
28
|
-
import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
|
|
29
|
-
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
|
|
30
|
-
import {
|
|
31
|
-
SlashCommandExtension,
|
|
32
|
-
type SlashState,
|
|
33
|
-
} from '../extensions/SlashCommandExtension.js'
|
|
34
|
-
import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
|
|
35
|
-
import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
|
|
36
|
-
import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
|
|
37
|
-
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
|
|
38
|
-
import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
|
|
39
|
-
import {
|
|
40
|
-
MentionExtension,
|
|
41
|
-
type MentionState,
|
|
42
|
-
} from '../extensions/MentionExtension.js'
|
|
43
|
-
import type { MentionProviderMeta } from '../MentionProvider.js'
|
|
44
|
-
import { SlashMenu, type SlashKeyHandlerRef } from './SlashMenu.js'
|
|
45
|
-
import { MentionMenu, type MentionKeyHandlerRef } from './MentionMenu.js'
|
|
46
|
-
import { FloatingToolbar } from './FloatingToolbar.js'
|
|
47
|
-
import { TableFloatingToolbar } from './TableFloatingToolbar.js'
|
|
48
|
-
import { Toolbar, AttachFilesDialog, useEditorTick } from './Toolbar.js'
|
|
49
|
-
import { BlockSidePanel } from './BlockSidePanel.js'
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The pilotiq field renderer for `RichTextField`. Registered globally via
|
|
53
|
-
* `registerTiptap()`; pilotiq's `SchemaRenderer` looks it up by `fieldType:
|
|
54
|
-
* 'richtext'` and mounts it inline inside the form.
|
|
55
|
-
*
|
|
56
|
-
* Wiring (Phase A):
|
|
57
|
-
* - StarterKit + Underline + Subscript + Superscript + TextAlign
|
|
58
|
-
* - Placeholder
|
|
59
|
-
* - BlockNodeExtension (custom-block storage + React NodeView)
|
|
60
|
-
* - SlashCommandExtension (`/` opens menu, items derived from `blocks`)
|
|
61
|
-
* - DragHandleExtension (hover gutter handle)
|
|
62
|
-
*
|
|
63
|
-
* Form integration: a hidden `<input type="hidden" name={field}>` carries
|
|
64
|
-
* the editor's serialized output. Storage format depends on the field's
|
|
65
|
-
* `.storage('json' | 'html')` setting — JSON parses on the server,
|
|
66
|
-
* HTML is passed through.
|
|
67
|
-
*/
|
|
68
|
-
export function TiptapEditor(props: FieldRendererProps) {
|
|
69
|
-
// useEditor + ProseMirror touch the DOM during construction — render a
|
|
70
|
-
// static placeholder during SSR so Vike's first paint doesn't crash.
|
|
71
|
-
// Hydration mounts the real editor on the client.
|
|
72
|
-
const [mounted, setMounted] = useState(false)
|
|
73
|
-
useEffect(() => { setMounted(true) }, [])
|
|
74
|
-
|
|
75
|
-
if (!mounted) {
|
|
76
|
-
const storage = (props.el['storage'] as RichTextStorage | undefined) ?? 'json'
|
|
77
|
-
const initialValue = serializeForHidden(props.defaultValue, storage)
|
|
78
|
-
return (
|
|
79
|
-
<div className="flex flex-col">
|
|
80
|
-
<input type="hidden" name={props.name} value={initialValue} />
|
|
81
|
-
<div className="prose prose-sm max-w-none min-h-[180px] rounded-md border border-input bg-transparent px-10 py-3 text-sm text-muted-foreground">
|
|
82
|
-
{props.placeholder ?? 'Start writing…'}
|
|
83
|
-
</div>
|
|
84
|
-
</div>
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return <CollabAwareTiptap {...props} />
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Bridges pilotiq's open-core `CollabRoomContext` + `CollabExtensionFactory`
|
|
93
|
-
* registry into the Tiptap renderer. When `@pilotiq-pro/collab` is wired
|
|
94
|
-
* AND a `<RecordCollabRoom>` is mounted up-tree, the room flips non-null;
|
|
95
|
-
* keying `ClientEditor` on that toggle remounts the editor cleanly so the
|
|
96
|
-
* `Collaboration` extension can install (Tiptap can't swap it at runtime).
|
|
97
|
-
*
|
|
98
|
-
* No-op shell when collab isn't installed — `room` stays `null`,
|
|
99
|
-
* `getCollabExtensions()` returns `null`, and `ClientEditor` runs its
|
|
100
|
-
* plain Tiptap path with the same shape as before.
|
|
101
|
-
*/
|
|
102
|
-
function CollabAwareTiptap(props: FieldRendererProps) {
|
|
103
|
-
const room = useCollabRoom()
|
|
104
|
-
const factory = getCollabExtensions()
|
|
105
|
-
// Per-field opt-out — `RichTextField.collab(false)` stamps `meta.collab`
|
|
106
|
-
// explicitly false, overriding the panel-wide auto-on default. Useful for
|
|
107
|
-
// fields that should stay device-local (private notes, draft scratch
|
|
108
|
-
// space, etc.) inside an otherwise collab-on form.
|
|
109
|
-
const fieldCollab = props.el['collab'] as boolean | undefined
|
|
110
|
-
const collabActive = !!(room && factory) && fieldCollab !== false
|
|
111
|
-
return (
|
|
112
|
-
<ClientEditor
|
|
113
|
-
key={collabActive ? 'collab' : 'local'}
|
|
114
|
-
{...props}
|
|
115
|
-
room={collabActive ? room : null}
|
|
116
|
-
factory={collabActive ? factory : null}
|
|
117
|
-
collabActive={collabActive}
|
|
118
|
-
/>
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
interface ClientEditorProps extends FieldRendererProps {
|
|
123
|
-
/** Active record room, or `null` when no `<RecordCollabRoom>` is mounted. */
|
|
124
|
-
room: CollabRoom | null
|
|
125
|
-
/** Registered collab extension factory, or `null` when no plugin registered. */
|
|
126
|
-
factory: CollabExtensionFactory | null
|
|
127
|
-
/** Convenience flag — `true` iff both `room` AND `factory` are non-null. */
|
|
128
|
-
collabActive: boolean
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function ClientEditor(props: ClientEditorProps) {
|
|
132
|
-
const { el, name, defaultValue, placeholder, disabled, room, factory, collabActive } = props
|
|
133
|
-
|
|
134
|
-
// Collab-stable identifier — top-level fields just use `name`, but
|
|
135
|
-
// Repeater / Builder row leaves rebind to a row-id-anchored composite
|
|
136
|
-
// (`metadata.<rowId>.body`) so the `Y.XmlFragment` survives reorders.
|
|
137
|
-
// AI suggestion routing, hidden FormData input, and the inline-diff
|
|
138
|
-
// banner stay on the positional `name` so tool calls referencing the
|
|
139
|
-
// field by its FormData name (`metadata.0.body`) still resolve.
|
|
140
|
-
//
|
|
141
|
-
// Mirrors the `name` + `fragmentKey` split shipped for `MarkdownField`
|
|
142
|
-
// in `@pilotiq/pilotiq@0.20.0` + `@pilotiq/tiptap@3.9.0`. Different
|
|
143
|
-
// mechanics: `MarkdownInput` had an intermediate host wrapper in
|
|
144
|
-
// pilotiq core (because markdown has a textarea fallback path that
|
|
145
|
-
// needs the same composite). `RichTextField` has no fallback —
|
|
146
|
-
// pilotiq core just dispatches the registered renderer with the
|
|
147
|
-
// positional name. So the composite logic lives here, on the only
|
|
148
|
-
// editor that needs it.
|
|
149
|
-
const rowCoords = useRowCoords()
|
|
150
|
-
const collabName = useMemo<string>(() => {
|
|
151
|
-
if (!name.includes('.')) return name
|
|
152
|
-
if (!rowCoords) return name
|
|
153
|
-
const parsed = parseRowFieldPath(name)
|
|
154
|
-
if (!parsed) return name
|
|
155
|
-
if (parsed.arrayName !== rowCoords.arrayName) return name
|
|
156
|
-
if (parsed.index !== rowCoords.rowIndex) return name
|
|
157
|
-
return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
|
|
158
|
-
}, [name, rowCoords])
|
|
159
|
-
|
|
160
|
-
const blocks = (el['blocks'] as BlockMeta[] | undefined) ?? []
|
|
161
|
-
const slashEnabled = (el['slashCommand'] as boolean | undefined) ?? true
|
|
162
|
-
const toolbarGroups = (el['toolbarGroups'] as ToolbarGroups | null | undefined) ?? null
|
|
163
|
-
const floatingEnabled = (el['floatingToolbar'] as boolean | undefined) ?? true
|
|
164
|
-
const storage = (el['storage'] as RichTextStorage | undefined) ?? 'json'
|
|
165
|
-
const textColors = (el['textColors'] as ColorSwatch[] | undefined) ?? []
|
|
166
|
-
const customTextColors = (el['customTextColors'] as boolean | undefined) ?? false
|
|
167
|
-
const highlightColors = (el['highlightColors'] as ColorSwatch[] | undefined) ?? []
|
|
168
|
-
const resizableImages = (el['resizableImages'] as boolean | undefined) ?? false
|
|
169
|
-
const uploadUrl = (el['uploadUrl'] as string | undefined)
|
|
170
|
-
const acceptedFileTypes = (el['fileAttachmentsAcceptedFileTypes'] as string[] | undefined)
|
|
171
|
-
const maxAttachmentSize = (el['fileAttachmentsMaxSize'] as number | undefined)
|
|
172
|
-
const attachmentDir = (el['fileAttachmentsDirectory'] as string | undefined)
|
|
173
|
-
const attachmentVis = (el['fileAttachmentsVisibility'] as ('public' | 'private') | undefined)
|
|
174
|
-
const mergeTags = (el['mergeTags'] as string[] | undefined) ?? []
|
|
175
|
-
const mentionProviders = (el['mentions'] as MentionProviderMeta[] | undefined) ?? []
|
|
176
|
-
const mentionsUrl = (el['mentionsUrl'] as string | undefined)
|
|
177
|
-
|
|
178
|
-
const initialContent = parseInitialContent(defaultValue)
|
|
179
|
-
const [serialized, setSerialized] = useState(() => serializeForHidden(initialContent, storage))
|
|
180
|
-
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
181
|
-
|
|
182
|
-
// Slash-menu state. `rawState` is what the extension last emitted.
|
|
183
|
-
// `dismissed` is the Escape latch — while true, the popover stays hidden
|
|
184
|
-
// even if the suggestion plugin keeps firing onUpdate. It clears when the
|
|
185
|
-
// suggestion plugin formally exits (cursor leaves the slash range).
|
|
186
|
-
const [rawState, setRawState] = useState<SlashState | null>(null)
|
|
187
|
-
const [dismissed, setDismissed] = useState(false)
|
|
188
|
-
const slashState = dismissed ? null : rawState
|
|
189
|
-
const slashKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
|
|
190
|
-
|
|
191
|
-
const handleStateChange = useCallback((s: SlashState | null) => {
|
|
192
|
-
if (s === null) setDismissed(false)
|
|
193
|
-
setRawState(s)
|
|
194
|
-
}, [])
|
|
195
|
-
|
|
196
|
-
// Mention popover state — symmetrical to slash, with its own dismiss latch
|
|
197
|
-
// so Escape closes the mention popup without affecting slash.
|
|
198
|
-
const [rawMentionState, setRawMentionState] = useState<MentionState | null>(null)
|
|
199
|
-
const [mentionDismissed, setMentionDismissed] = useState(false)
|
|
200
|
-
const mentionState = mentionDismissed ? null : rawMentionState
|
|
201
|
-
const mentionKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
|
|
202
|
-
|
|
203
|
-
// Lifted upload-dialog state — the toolbar's `attachFiles` button and the
|
|
204
|
-
// slash menu's "Image" entry both flip this flag. Single source of truth
|
|
205
|
-
// keeps the dialog mounted in one place (inside `Toolbar`) regardless of
|
|
206
|
-
// which trigger fired.
|
|
207
|
-
const [attachOpen, setAttachOpen] = useState(false)
|
|
208
|
-
|
|
209
|
-
const handleMentionStateChange = useCallback((s: MentionState | null) => {
|
|
210
|
-
if (s === null) setMentionDismissed(false)
|
|
211
|
-
setRawMentionState(s)
|
|
212
|
-
}, [])
|
|
213
|
-
|
|
214
|
-
// Custom-block side panel — opens when a block's NodeView fires its
|
|
215
|
-
// Edit button. The NodeView lives in a separate React tree and reaches
|
|
216
|
-
// us via `BlockNodeExtension.options.onEdit` (set during configure()
|
|
217
|
-
// below). Stores `pos` + `blockType` at open-time; `BlockSidePanel`
|
|
218
|
-
// tracks the position forward through transactions and writes attrs
|
|
219
|
-
// back via setNodeMarkup. Closing nullifies the slot — re-opening
|
|
220
|
-
// remounts the panel fresh, including a re-snapshot of `blockData`.
|
|
221
|
-
const [selectedBlock, setSelectedBlock] = useState<{ pos: number; blockType: string } | null>(null)
|
|
222
|
-
const handleEditBlock = useCallback((pos: number) => {
|
|
223
|
-
// We resolve `blockType` here against the current doc so a stale
|
|
224
|
-
// pos (e.g. the block was just deleted before the click landed)
|
|
225
|
-
// produces a no-op rather than an empty panel.
|
|
226
|
-
setSelectedBlock((prev) => {
|
|
227
|
-
// Read from the editor lazily — the editor ref isn't stable yet
|
|
228
|
-
// on the very first render where this callback is created, so
|
|
229
|
-
// defer the lookup to call time.
|
|
230
|
-
const ed = editorRef.current
|
|
231
|
-
if (!ed) return prev
|
|
232
|
-
const node = (ed.state.doc as unknown as { nodeAt: (p: number) => { type: { name: string }; attrs: Record<string, unknown> } | null }).nodeAt(pos)
|
|
233
|
-
if (!node || node.type.name !== 'pilotiqBlock') return prev
|
|
234
|
-
return { pos, blockType: String(node.attrs['blockType'] ?? '') }
|
|
235
|
-
})
|
|
236
|
-
}, [])
|
|
237
|
-
const closeBlockPanel = useCallback(() => { setSelectedBlock(null) }, [])
|
|
238
|
-
|
|
239
|
-
// editorRef gives the onEdit callback access to the editor instance
|
|
240
|
-
// without re-creating the callback on every render (which would force
|
|
241
|
-
// the extension config to re-evaluate, triggering a full editor reset).
|
|
242
|
-
const editorRef = useRef<Editor | null>(null)
|
|
243
|
-
|
|
244
|
-
// Resolve the collab-attached extensions once per editor build.
|
|
245
|
-
// `Collaboration` is constructed eagerly here (during `useEditor`'s
|
|
246
|
-
// first call); the keyed remount above guarantees we never swap it.
|
|
247
|
-
const collabExtensions = useMemo(() => {
|
|
248
|
-
if (!collabActive || !room || !factory) return [] as unknown[]
|
|
249
|
-
return factory({
|
|
250
|
-
ydoc: room.ydoc,
|
|
251
|
-
provider: room.provider,
|
|
252
|
-
fieldName: collabName,
|
|
253
|
-
...(room.user ? { user: room.user } : {}),
|
|
254
|
-
})
|
|
255
|
-
// Intentionally deps-stable across renders within the same collab
|
|
256
|
-
// mount — the keyed wrapper above remounts us when collab toggles.
|
|
257
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
258
|
-
}, [collabActive])
|
|
259
|
-
|
|
260
|
-
const editor = useEditor({
|
|
261
|
-
editable: !disabled,
|
|
262
|
-
extensions: [
|
|
263
|
-
// StarterKit 3.22+ ships Link AND Underline; configure through the
|
|
264
|
-
// kit rather than re-adding (else "Duplicate extension names" warns).
|
|
265
|
-
// `Collaboration` brings its own Yjs-backed history — disable
|
|
266
|
-
// StarterKit's local `undoRedo` extension when collab is active
|
|
267
|
-
// (renamed from `history` in Tiptap v3.x; passing `history: false`
|
|
268
|
-
// silently no-ops and produces a runtime "not compatible with
|
|
269
|
-
// @tiptap/extension-undo-redo" warning).
|
|
270
|
-
StarterKit.configure({
|
|
271
|
-
link: { openOnClick: false, autolink: true },
|
|
272
|
-
...(collabActive ? { undoRedo: false } : {}),
|
|
273
|
-
}),
|
|
274
|
-
Subscript,
|
|
275
|
-
Superscript,
|
|
276
|
-
LeadMarkExtension,
|
|
277
|
-
SmallMarkExtension,
|
|
278
|
-
// textAlign needs to be told which node types it can target. Headings
|
|
279
|
-
// + paragraphs are the standard set. Blockquote alignment is handled
|
|
280
|
-
// by aligning the inner paragraph.
|
|
281
|
-
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
282
|
-
// TextStyle is a no-op mark on its own, but Color decorates it with the
|
|
283
|
-
// `color` attribute so `.setColor(...)` works. Loading them as a pair
|
|
284
|
-
// keeps the extension surface complete.
|
|
285
|
-
TextStyle,
|
|
286
|
-
Color,
|
|
287
|
-
Highlight.configure({ multicolor: true }),
|
|
288
|
-
Image.configure({
|
|
289
|
-
// Inline images break under prose's `figure` margin reset; the
|
|
290
|
-
// editor uses block images by default, matching the read-side
|
|
291
|
-
// renderer's `<img>` output.
|
|
292
|
-
inline: false,
|
|
293
|
-
// Most upload adapters return URLs — base64 inflates the doc and
|
|
294
|
-
// re-uploads on every save. Opt back in only if your adapter
|
|
295
|
-
// explicitly stores data URLs.
|
|
296
|
-
allowBase64: false,
|
|
297
|
-
resize: resizableImages
|
|
298
|
-
? { enabled: true, alwaysPreserveAspectRatio: true }
|
|
299
|
-
: false,
|
|
300
|
-
}),
|
|
301
|
-
// Tables — the four nodes ship from one peer (`@tiptap/extension-table`).
|
|
302
|
-
// `resizable: true` mounts the built-in column-resize NodeView so users
|
|
303
|
-
// can drag column dividers; `lastColumnResizable: false` keeps the
|
|
304
|
-
// right-edge handle from creating an unbounded growth target when the
|
|
305
|
-
// table sits inside a constrained-width form.
|
|
306
|
-
Table.configure({ resizable: true, lastColumnResizable: false }),
|
|
307
|
-
TableRow,
|
|
308
|
-
TableHeader,
|
|
309
|
-
TableCell,
|
|
310
|
-
// Collapsible `<details>` blocks. `persist: true` round-trips the
|
|
311
|
-
// open/closed state through the document attrs so SSR + reload pick up
|
|
312
|
-
// the same state the author left it in. The default summary text on
|
|
313
|
-
// insert ("Title") gives the user something to overwrite.
|
|
314
|
-
Details.configure({ persist: true, HTMLAttributes: { class: 'details' } }),
|
|
315
|
-
DetailsSummary,
|
|
316
|
-
DetailsContent,
|
|
317
|
-
// Multi-column grid blocks (`grid` + `gridColumn`). Custom node pair —
|
|
318
|
-
// Tiptap doesn't ship a first-party grid extension. Schema constrains
|
|
319
|
-
// grids to 2 or 3 columns; consumer owns the CSS for `pilotiq-grid` /
|
|
320
|
-
// `pilotiq-grid-cols-N`.
|
|
321
|
-
Grid,
|
|
322
|
-
GridColumn,
|
|
323
|
-
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
324
|
-
// BlockNodeExtension carries the block registry on its options —
|
|
325
|
-
// NodeViews mount in a separate React tree and can't see context.
|
|
326
|
-
// `onEdit` is the bridge back to the host editor's tree where the
|
|
327
|
-
// side panel lives; the NodeView's Edit button calls it with its
|
|
328
|
-
// own `getPos()`.
|
|
329
|
-
BlockNodeExtension.configure({ blocks, onEdit: handleEditBlock }),
|
|
330
|
-
...(slashEnabled ? [SlashCommandExtension.configure({
|
|
331
|
-
blocks,
|
|
332
|
-
mergeTags,
|
|
333
|
-
onStateChange: handleStateChange,
|
|
334
|
-
hasUpload: Boolean(uploadUrl),
|
|
335
|
-
onInsertImage: () => setAttachOpen(true),
|
|
336
|
-
})] : []),
|
|
337
|
-
// MergeTagExtension provides the `mergeTag` node type even when no tags
|
|
338
|
-
// are configured — the slash menu is the gate for *inserting* them, but
|
|
339
|
-
// the schema needs to know about the node either way (otherwise loading
|
|
340
|
-
// an existing doc that contains one throws a parse error).
|
|
341
|
-
MergeTagExtension,
|
|
342
|
-
...(mentionProviders.length > 0 ? [MentionExtension.configure({
|
|
343
|
-
providers: mentionProviders,
|
|
344
|
-
onStateChange: handleMentionStateChange,
|
|
345
|
-
...(mentionsUrl ? { mentionsUrl } : {}),
|
|
346
|
-
fieldName: name,
|
|
347
|
-
})] : [MentionExtension]),
|
|
348
|
-
DragHandleExtension,
|
|
349
|
-
// AI suggestions — chip widget for surgical (range-anchored) edits.
|
|
350
|
-
AiSuggestionExtension,
|
|
351
|
-
// AI inline diff — Tiptap-Pro-style visualization for whole-field
|
|
352
|
-
// suggestions via prosemirror-changeset. See AiInlineDiffExtension.
|
|
353
|
-
AiInlineDiffExtension,
|
|
354
|
-
// Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
|
|
355
|
-
// when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
|
|
356
|
-
// registered a factory via `registerCollabExtensions`.
|
|
357
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
358
|
-
...(collabExtensions as any[]),
|
|
359
|
-
],
|
|
360
|
-
// Collaboration takes ownership of the document — `content` would race
|
|
361
|
-
// the Y.XmlFragment sync. Seed instead via the post-`synced` effect
|
|
362
|
-
// below so existing DB content lands once and only once. The non-collab
|
|
363
|
-
// branch also gates on `isTiptapShapedContent` so leftover content from
|
|
364
|
-
// a previous editor (e.g. Lexical's `{ root: {...} }`) doesn't crash
|
|
365
|
-
// the schema-strict node parser on first paint.
|
|
366
|
-
content: collabActive
|
|
367
|
-
? ''
|
|
368
|
-
: (initialContent !== undefined && isTiptapShapedContent(initialContent) ? initialContent : ''),
|
|
369
|
-
onUpdate: ({ editor: ed }) => {
|
|
370
|
-
// Debounce serialization — every keystroke fires onUpdate.
|
|
371
|
-
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
372
|
-
debounceRef.current = setTimeout(() => {
|
|
373
|
-
const value = storage === 'html' ? ed.getHTML() : JSON.stringify(ed.getJSON())
|
|
374
|
-
setSerialized(value)
|
|
375
|
-
}, 250)
|
|
376
|
-
},
|
|
377
|
-
editorProps: {
|
|
378
|
-
attributes: {
|
|
379
|
-
// Drop the top border-radius when the toolbar is on so the toolbar
|
|
380
|
-
// and editor body read as a single chrome.
|
|
381
|
-
class: `prose prose-sm dark:prose-invert max-w-none min-h-[180px] border border-input bg-transparent px-10 py-3 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
|
|
382
|
-
toolbarGroups && toolbarGroups.length > 0
|
|
383
|
-
? 'rounded-b-md border-t-0'
|
|
384
|
-
: 'rounded-md'
|
|
385
|
-
}`,
|
|
386
|
-
},
|
|
387
|
-
},
|
|
388
|
-
})
|
|
389
|
-
|
|
390
|
-
// Document-level keyboard handler for the slash menu. Capture phase so we
|
|
391
|
-
// run before ProseMirror's `view.dom` keydown listener — that way Enter
|
|
392
|
-
// doesn't split the paragraph and Arrows don't move the cursor while
|
|
393
|
-
// navigating the menu. Listen at `document` because Base UI's focus manager
|
|
394
|
-
// can briefly pull focus into the popup when it mounts.
|
|
395
|
-
const open = slashState !== null
|
|
396
|
-
useEffect(() => {
|
|
397
|
-
if (!open) return
|
|
398
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
399
|
-
if (e.key === 'Escape') {
|
|
400
|
-
setDismissed(true)
|
|
401
|
-
e.preventDefault()
|
|
402
|
-
e.stopPropagation()
|
|
403
|
-
return
|
|
404
|
-
}
|
|
405
|
-
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
406
|
-
const handled = slashKeyRef.current?.(e) ?? false
|
|
407
|
-
if (handled) {
|
|
408
|
-
e.preventDefault()
|
|
409
|
-
e.stopPropagation()
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
document.addEventListener('keydown', onKeyDown, true)
|
|
414
|
-
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
415
|
-
}, [open])
|
|
416
|
-
|
|
417
|
-
// Mirror keyboard handling for the mention popover. Capture-phase listener
|
|
418
|
-
// anchored to `document` for the same reason the slash menu uses it —
|
|
419
|
-
// Base UI's focus manager can briefly steal focus to its popup.
|
|
420
|
-
const mentionOpen = mentionState !== null
|
|
421
|
-
useEffect(() => {
|
|
422
|
-
if (!mentionOpen) return
|
|
423
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
424
|
-
if (e.key === 'Escape') {
|
|
425
|
-
setMentionDismissed(true)
|
|
426
|
-
e.preventDefault()
|
|
427
|
-
e.stopPropagation()
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
431
|
-
const handled = mentionKeyRef.current?.(e) ?? false
|
|
432
|
-
if (handled) {
|
|
433
|
-
e.preventDefault()
|
|
434
|
-
e.stopPropagation()
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
document.addEventListener('keydown', onKeyDown, true)
|
|
439
|
-
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
440
|
-
}, [mentionOpen])
|
|
441
|
-
|
|
442
|
-
useEffect(() => () => {
|
|
443
|
-
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
444
|
-
}, [])
|
|
445
|
-
|
|
446
|
-
// Mirror the editor instance into a ref so callbacks captured during
|
|
447
|
-
// `useEditor`'s extension config (notably the BlockNode `onEdit`
|
|
448
|
-
// bridge) can reach the live editor without re-creating themselves
|
|
449
|
-
// every render. Re-creation would force the editor to rebuild from
|
|
450
|
-
// scratch on every keystroke.
|
|
451
|
-
useEffect(() => { editorRef.current = editor ?? null }, [editor])
|
|
452
|
-
|
|
453
|
-
// Mirror `disabled` onto the live editor at runtime. `useEditor`'s
|
|
454
|
-
// `editable: !disabled` only fires at construction time, so a parent
|
|
455
|
-
// flipping read-only after mount (e.g. policy denial mid-edit, form
|
|
456
|
-
// submitting state) would silently no-op without this effect. Same
|
|
457
|
-
// shape MarkdownEditor.tsx and CollabTextRenderer.tsx already use.
|
|
458
|
-
useEffect(() => {
|
|
459
|
-
if (!editor) return
|
|
460
|
-
editor.setEditable(!disabled)
|
|
461
|
-
}, [editor, disabled])
|
|
462
|
-
|
|
463
|
-
// First-load seed when collab is active. Collaboration starts the
|
|
464
|
-
// editor empty regardless of `defaultValue`; once the room's first
|
|
465
|
-
// sync resolves, `useCollabSeed` runs the callback inside
|
|
466
|
-
// `ydoc.transact(..., 'pilotiq-collab-seed')`. We check whether the
|
|
467
|
-
// field's `Y.XmlFragment` was ever written — empty + we have an
|
|
468
|
-
// initial value = first session for this record — push the DB
|
|
469
|
-
// content into the ydoc exactly once. Non-empty = the room already
|
|
470
|
-
// has authoritative state; don't overwrite. Gating on `editor` keeps
|
|
471
|
-
// the effect from firing before Tiptap mounts its
|
|
472
|
-
// y-prosemirror binding (Tiptap v3 defers editor construction to
|
|
473
|
-
// first effect under `immediatelyRender: false`).
|
|
474
|
-
//
|
|
475
|
-
// Subscribe-after-sync mirror: after the seed branch (or no-op when
|
|
476
|
-
// the fragment already has content from a remote peer), serialize the
|
|
477
|
-
// editor's current state into the hidden FormData input. The
|
|
478
|
-
// debounced `onUpdate` path covers steady-state typing, but in the
|
|
479
|
-
// cold-mount case (fresh peer joining a populated doc) y-prosemirror's
|
|
480
|
-
// `ySyncPlugin` view hook may run `_forceRerender` before the React
|
|
481
|
-
// owner has installed the `update` listener — leaving the hidden
|
|
482
|
-
// input empty on submit. Idempotent: when `onUpdate` already
|
|
483
|
-
// propagated, this is a no-op `setSerialized(sameValue)`. Same shape
|
|
484
|
-
// as `CollabTextRenderer`'s post-sync mirror and
|
|
485
|
-
// `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows` catch-up.
|
|
486
|
-
useCollabSeed(
|
|
487
|
-
editor && collabActive ? room : null,
|
|
488
|
-
collabName,
|
|
489
|
-
(doc) => {
|
|
490
|
-
const fragment = (doc as YDocShape).getXmlFragment(collabName)
|
|
491
|
-
if (
|
|
492
|
-
fragment &&
|
|
493
|
-
fragment.length === 0 &&
|
|
494
|
-
initialContent !== undefined &&
|
|
495
|
-
initialContent !== null &&
|
|
496
|
-
initialContent !== '' &&
|
|
497
|
-
isTiptapShapedContent(initialContent) &&
|
|
498
|
-
editor
|
|
499
|
-
) {
|
|
500
|
-
// setContent dispatches a Tiptap transaction; the bound
|
|
501
|
-
// y-prosemirror binding (inside Collaboration) mirrors it
|
|
502
|
-
// into the fragment so every peer sees the seeded state.
|
|
503
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
-
editor.commands.setContent(initialContent as any)
|
|
505
|
-
}
|
|
506
|
-
if (editor) {
|
|
507
|
-
const value = storage === 'html' ? editor.getHTML() : JSON.stringify(editor.getJSON())
|
|
508
|
-
setSerialized(value)
|
|
509
|
-
}
|
|
510
|
-
},
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
// Cross-package suggestion bridge — sync the host's
|
|
514
|
-
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
515
|
-
// extension. No-op when no provider is mounted (default no-op context).
|
|
516
|
-
//
|
|
517
|
-
// Whole-field handling: NO chip widget here. The chip's `textContent`
|
|
518
|
-
// renderer would surface raw HTML tags as literal text inside the
|
|
519
|
-
// green pill — unparseable on multi-paragraph rewrites. Instead,
|
|
520
|
-
// `<AiSuggestionBanner>` mounts below the editor (see render below).
|
|
521
|
-
// Producer-supplied range suggestions still ride the inline chip —
|
|
522
|
-
// those have a precise anchor worth visualizing in context.
|
|
523
|
-
const applyWholeField = (value: string): void => {
|
|
524
|
-
if (!editor || editor.isDestroyed) return
|
|
525
|
-
editor.commands.setContent(value)
|
|
526
|
-
}
|
|
527
|
-
useAiSuggestionBridge(editor ?? null, name, {
|
|
528
|
-
onApplyWholeField: applyWholeField,
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
// Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
|
|
532
|
-
// HTML → ProseMirror Slice via the schema's DOMParser. Suggested values
|
|
533
|
-
// on a RichTextField are typically HTML (or marked-up JSON that the
|
|
534
|
-
// schema's DOMParser also handles via its serialized round-trip). For
|
|
535
|
-
// JSON suggestions, the schema may reject — falls back to banner-only.
|
|
536
|
-
useAiInlineDiff(editor ?? null, name, {
|
|
537
|
-
parseSuggestion: (ed, value) => {
|
|
538
|
-
try {
|
|
539
|
-
const container = document.createElement('div')
|
|
540
|
-
container.innerHTML = value
|
|
541
|
-
return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
|
|
542
|
-
} catch { return null }
|
|
543
|
-
},
|
|
544
|
-
})
|
|
545
|
-
const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
|
|
546
|
-
|
|
547
|
-
// Re-render the toolbar when the selection / marks change so active-state
|
|
548
|
-
// booleans stay fresh.
|
|
549
|
-
const tick = useEditorTick(editor)
|
|
550
|
-
|
|
551
|
-
return (
|
|
552
|
-
<div className="relative flex flex-col">
|
|
553
|
-
<input type="hidden" name={name} value={serialized} />
|
|
554
|
-
{editor && toolbarGroups && toolbarGroups.length > 0 && (
|
|
555
|
-
<Toolbar
|
|
556
|
-
editor={editor}
|
|
557
|
-
groups={toolbarGroups}
|
|
558
|
-
tick={tick}
|
|
559
|
-
textColors={textColors}
|
|
560
|
-
customTextColors={customTextColors}
|
|
561
|
-
highlightColors={highlightColors}
|
|
562
|
-
onAttachOpenChange={setAttachOpen}
|
|
563
|
-
/>
|
|
564
|
-
)}
|
|
565
|
-
{/* Single mount for the attach-files dialog — toolbar's `attachFiles`
|
|
566
|
-
button and slash menu's "Image" entry both flip `attachOpen`.
|
|
567
|
-
Mounted at the editor level (not the toolbar) so it stays available
|
|
568
|
-
when the toolbar is hidden via `.toolbar(false)`. */}
|
|
569
|
-
{editor && (
|
|
570
|
-
<AttachFilesDialog
|
|
571
|
-
open={attachOpen}
|
|
572
|
-
onOpenChange={setAttachOpen}
|
|
573
|
-
editor={editor}
|
|
574
|
-
fieldName={name}
|
|
575
|
-
{...(uploadUrl !== undefined ? { uploadUrl } : {})}
|
|
576
|
-
{...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {})}
|
|
577
|
-
{...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {})}
|
|
578
|
-
{...(attachmentDir !== undefined ? { directory: attachmentDir } : {})}
|
|
579
|
-
{...(attachmentVis !== undefined ? { visibility: attachmentVis } : {})}
|
|
580
|
-
/>
|
|
581
|
-
)}
|
|
582
|
-
<EditorContent editor={editor} />
|
|
583
|
-
<AiSuggestionBanner
|
|
584
|
-
fieldName={name}
|
|
585
|
-
onApplyWholeField={applyWholeField}
|
|
586
|
-
{...(isDiffActive && editor
|
|
587
|
-
? {
|
|
588
|
-
onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
|
|
589
|
-
onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
|
|
590
|
-
}
|
|
591
|
-
: {})}
|
|
592
|
-
/>
|
|
593
|
-
{editor && floatingEnabled && <FloatingToolbar editor={editor} />}
|
|
594
|
-
{editor && <TableFloatingToolbar editor={editor} />}
|
|
595
|
-
<SlashPopover state={slashState} keyHandlerRef={slashKeyRef} />
|
|
596
|
-
<MentionPopover state={mentionState} keyHandlerRef={mentionKeyRef} />
|
|
597
|
-
{editor && selectedBlock && (
|
|
598
|
-
<BlockSidePanel
|
|
599
|
-
key={`${selectedBlock.pos}:${selectedBlock.blockType}`}
|
|
600
|
-
editor={editor}
|
|
601
|
-
initialPos={selectedBlock.pos}
|
|
602
|
-
blockType={selectedBlock.blockType}
|
|
603
|
-
blocks={blocks}
|
|
604
|
-
onClose={closeBlockPanel}
|
|
605
|
-
/>
|
|
606
|
-
)}
|
|
607
|
-
</div>
|
|
608
|
-
)
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Cursor-anchored popover for the mention menu. Same Floating-UI / virtual-
|
|
613
|
-
* element pattern as the slash popover — a `clientRect` lambda from the
|
|
614
|
-
* Suggestion plugin powers a `getBoundingClientRect`-only anchor object.
|
|
615
|
-
*/
|
|
616
|
-
function MentionPopover({
|
|
617
|
-
state,
|
|
618
|
-
keyHandlerRef,
|
|
619
|
-
}: {
|
|
620
|
-
state: MentionState | null
|
|
621
|
-
keyHandlerRef: MentionKeyHandlerRef
|
|
622
|
-
}) {
|
|
623
|
-
const open = state !== null
|
|
624
|
-
|
|
625
|
-
const anchor = useMemo(() => {
|
|
626
|
-
if (!state) return null
|
|
627
|
-
return {
|
|
628
|
-
getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
|
|
629
|
-
}
|
|
630
|
-
}, [state])
|
|
631
|
-
|
|
632
|
-
return (
|
|
633
|
-
<Popover.Root open={open} onOpenChange={() => {}}>
|
|
634
|
-
<Popover.Portal>
|
|
635
|
-
<Popover.Positioner
|
|
636
|
-
anchor={anchor}
|
|
637
|
-
positionMethod="fixed"
|
|
638
|
-
side="bottom"
|
|
639
|
-
align="start"
|
|
640
|
-
sideOffset={6}
|
|
641
|
-
className="isolate z-50"
|
|
642
|
-
>
|
|
643
|
-
<Popover.Popup
|
|
644
|
-
initialFocus={false}
|
|
645
|
-
finalFocus={false}
|
|
646
|
-
tabIndex={-1}
|
|
647
|
-
className="origin-(--transform-origin) rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
|
648
|
-
>
|
|
649
|
-
{state && (
|
|
650
|
-
<MentionMenu
|
|
651
|
-
trigger={state.trigger}
|
|
652
|
-
items={state.items}
|
|
653
|
-
command={state.command}
|
|
654
|
-
keyHandlerRef={keyHandlerRef}
|
|
655
|
-
/>
|
|
656
|
-
)}
|
|
657
|
-
</Popover.Popup>
|
|
658
|
-
</Popover.Positioner>
|
|
659
|
-
</Popover.Portal>
|
|
660
|
-
</Popover.Root>
|
|
661
|
-
)
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Renders the slash menu inside a Base UI Popover anchored to the cursor's
|
|
666
|
-
* client rect. Floating UI under Base UI handles scroll/resize tracking and
|
|
667
|
-
* collision avoidance, so we never have to recompute position ourselves.
|
|
668
|
-
*/
|
|
669
|
-
function SlashPopover({
|
|
670
|
-
state,
|
|
671
|
-
keyHandlerRef,
|
|
672
|
-
}: {
|
|
673
|
-
state: SlashState | null
|
|
674
|
-
keyHandlerRef: SlashKeyHandlerRef
|
|
675
|
-
}) {
|
|
676
|
-
const open = state !== null
|
|
677
|
-
|
|
678
|
-
// Virtual element built from the suggestion plugin's clientRect lambda.
|
|
679
|
-
// The Positioner re-reads `getBoundingClientRect` on every layout tick,
|
|
680
|
-
// and `clientRect()` returns viewport-relative coords from PM, so scroll
|
|
681
|
-
// tracking is automatic.
|
|
682
|
-
const anchor = useMemo(() => {
|
|
683
|
-
if (!state) return null
|
|
684
|
-
return {
|
|
685
|
-
getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
|
|
686
|
-
}
|
|
687
|
-
}, [state])
|
|
688
|
-
|
|
689
|
-
return (
|
|
690
|
-
<Popover.Root open={open} onOpenChange={() => {}}>
|
|
691
|
-
<Popover.Portal>
|
|
692
|
-
<Popover.Positioner
|
|
693
|
-
anchor={anchor}
|
|
694
|
-
// `fixed` makes the popup's bounding rect viewport-relative, so the
|
|
695
|
-
// initial render (before Floating UI computes the anchor position)
|
|
696
|
-
// doesn't sit at body (0,0) — that would trigger the browser to
|
|
697
|
-
// scroll the page when the popup mounts and momentarily becomes
|
|
698
|
-
// the focus target.
|
|
699
|
-
positionMethod="fixed"
|
|
700
|
-
side="bottom"
|
|
701
|
-
align="start"
|
|
702
|
-
sideOffset={6}
|
|
703
|
-
className="isolate z-50"
|
|
704
|
-
>
|
|
705
|
-
<Popover.Popup
|
|
706
|
-
// Keep focus in the editor — keyboard navigation is driven via a
|
|
707
|
-
// document-level listener in TiptapEditor, never via DOM focus.
|
|
708
|
-
initialFocus={false}
|
|
709
|
-
finalFocus={false}
|
|
710
|
-
tabIndex={-1}
|
|
711
|
-
className="origin-(--transform-origin) rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
|
712
|
-
>
|
|
713
|
-
{state && (
|
|
714
|
-
<SlashMenu
|
|
715
|
-
items={state.items}
|
|
716
|
-
command={state.command}
|
|
717
|
-
keyHandlerRef={keyHandlerRef}
|
|
718
|
-
/>
|
|
719
|
-
)}
|
|
720
|
-
</Popover.Popup>
|
|
721
|
-
</Popover.Positioner>
|
|
722
|
-
</Popover.Portal>
|
|
723
|
-
</Popover.Root>
|
|
724
|
-
)
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Loose shape check — returns `true` only when the value looks like a Tiptap
|
|
729
|
-
* (ProseMirror JSON) document: either an HTML string or an object that opens
|
|
730
|
-
* with `{ type: 'doc' }` at the top level. Used by the collab seed effect to
|
|
731
|
-
* skip leftover content from previous editors (notably Lexical's
|
|
732
|
-
* `{ root: {...} }` envelope) without crashing Tiptap's strict node parser.
|
|
733
|
-
*
|
|
734
|
-
* The conservative posture: if we can't recognise the shape we don't seed.
|
|
735
|
-
* Worst case the user sees an empty editor on the first collab session and
|
|
736
|
-
* types fresh — better than the editor showing nothing because a parse threw.
|
|
737
|
-
*/
|
|
738
|
-
function isTiptapShapedContent(raw: unknown): boolean {
|
|
739
|
-
if (typeof raw === 'string') return true // HTML or raw text — Tiptap parses both.
|
|
740
|
-
if (raw === null || typeof raw !== 'object') return false
|
|
741
|
-
const obj = raw as { type?: unknown; content?: unknown; root?: unknown }
|
|
742
|
-
if (obj.root !== undefined) return false // Lexical state envelope — never Tiptap.
|
|
743
|
-
return obj.type === 'doc' // ProseMirror JSON always opens with `type:'doc'`.
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function parseInitialContent(raw: unknown): object | string | undefined {
|
|
747
|
-
if (raw === undefined || raw === null || raw === '') return undefined
|
|
748
|
-
if (typeof raw === 'object') return raw as object
|
|
749
|
-
if (typeof raw === 'string') {
|
|
750
|
-
const trimmed = raw.trim()
|
|
751
|
-
// Looks like JSON — try to parse. Otherwise treat as HTML and pass to
|
|
752
|
-
// Tiptap (it accepts an HTML string as `content`).
|
|
753
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
754
|
-
try { return JSON.parse(raw) } catch { return raw }
|
|
755
|
-
}
|
|
756
|
-
return raw
|
|
757
|
-
}
|
|
758
|
-
return undefined
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
function serializeForHidden(content: unknown, storage: RichTextStorage): string {
|
|
762
|
-
if (content === undefined || content === null) {
|
|
763
|
-
return storage === 'html' ? '' : JSON.stringify(null)
|
|
764
|
-
}
|
|
765
|
-
if (storage === 'html') {
|
|
766
|
-
return typeof content === 'string' ? content : ''
|
|
767
|
-
}
|
|
768
|
-
if (typeof content === 'object') return JSON.stringify(content)
|
|
769
|
-
if (typeof content === 'string') {
|
|
770
|
-
// Best-effort: a stored JSON string from the server should round-trip.
|
|
771
|
-
const trimmed = content.trim()
|
|
772
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return content
|
|
773
|
-
return JSON.stringify(null)
|
|
774
|
-
}
|
|
775
|
-
return JSON.stringify(null)
|
|
776
|
-
}
|