@pilotiq/tiptap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/Block.d.ts +47 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +56 -0
- package/dist/Block.js.map +1 -0
- package/dist/MentionProvider.d.ts +97 -0
- package/dist/MentionProvider.d.ts.map +1 -0
- package/dist/MentionProvider.js +104 -0
- package/dist/MentionProvider.js.map +1 -0
- package/dist/RichTextField.d.ts +286 -0
- package/dist/RichTextField.d.ts.map +1 -0
- package/dist/RichTextField.js +369 -0
- package/dist/RichTextField.js.map +1 -0
- package/dist/extensions/BlockNodeExtension.d.ts +41 -0
- package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
- package/dist/extensions/BlockNodeExtension.js +103 -0
- package/dist/extensions/BlockNodeExtension.js.map +1 -0
- package/dist/extensions/DragHandleExtension.d.ts +19 -0
- package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
- package/dist/extensions/DragHandleExtension.js +166 -0
- package/dist/extensions/DragHandleExtension.js.map +1 -0
- package/dist/extensions/GridExtension.d.ts +49 -0
- package/dist/extensions/GridExtension.d.ts.map +1 -0
- package/dist/extensions/GridExtension.js +105 -0
- package/dist/extensions/GridExtension.js.map +1 -0
- package/dist/extensions/MentionExtension.d.ts +71 -0
- package/dist/extensions/MentionExtension.d.ts.map +1 -0
- package/dist/extensions/MentionExtension.js +165 -0
- package/dist/extensions/MentionExtension.js.map +1 -0
- package/dist/extensions/MergeTagExtension.d.ts +24 -0
- package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
- package/dist/extensions/MergeTagExtension.js +57 -0
- package/dist/extensions/MergeTagExtension.js.map +1 -0
- package/dist/extensions/SlashCommandExtension.d.ts +71 -0
- package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
- package/dist/extensions/SlashCommandExtension.js +244 -0
- package/dist/extensions/SlashCommandExtension.js.map +1 -0
- package/dist/extensions/TextSizeMarks.d.ts +33 -0
- package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
- package/dist/extensions/TextSizeMarks.js +47 -0
- package/dist/extensions/TextSizeMarks.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/react/BlockNodeView.d.ts +19 -0
- package/dist/react/BlockNodeView.d.ts.map +1 -0
- package/dist/react/BlockNodeView.js +60 -0
- package/dist/react/BlockNodeView.js.map +1 -0
- package/dist/react/BlockSidePanel.d.ts +105 -0
- package/dist/react/BlockSidePanel.d.ts.map +1 -0
- package/dist/react/BlockSidePanel.js +339 -0
- package/dist/react/BlockSidePanel.js.map +1 -0
- package/dist/react/FloatingToolbar.d.ts +13 -0
- package/dist/react/FloatingToolbar.d.ts.map +1 -0
- package/dist/react/FloatingToolbar.js +113 -0
- package/dist/react/FloatingToolbar.js.map +1 -0
- package/dist/react/MentionMenu.d.ts +26 -0
- package/dist/react/MentionMenu.d.ts.map +1 -0
- package/dist/react/MentionMenu.js +64 -0
- package/dist/react/MentionMenu.js.map +1 -0
- package/dist/react/Palette.d.ts +26 -0
- package/dist/react/Palette.d.ts.map +1 -0
- package/dist/react/Palette.js +21 -0
- package/dist/react/Palette.js.map +1 -0
- package/dist/react/SlashMenu.d.ts +24 -0
- package/dist/react/SlashMenu.d.ts.map +1 -0
- package/dist/react/SlashMenu.js +74 -0
- package/dist/react/SlashMenu.js.map +1 -0
- package/dist/react/TableFloatingToolbar.d.ts +7 -0
- package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
- package/dist/react/TableFloatingToolbar.js +108 -0
- package/dist/react/TableFloatingToolbar.js.map +1 -0
- package/dist/react/TiptapEditor.d.ts +20 -0
- package/dist/react/TiptapEditor.d.ts.map +1 -0
- package/dist/react/TiptapEditor.js +398 -0
- package/dist/react/TiptapEditor.js.map +1 -0
- package/dist/react/Toolbar.d.ts +45 -0
- package/dist/react/Toolbar.d.ts.map +1 -0
- package/dist/react/Toolbar.js +204 -0
- package/dist/react/Toolbar.js.map +1 -0
- package/dist/react/toolbarButtons.d.ts +36 -0
- package/dist/react/toolbarButtons.d.ts.map +1 -0
- package/dist/react/toolbarButtons.js +300 -0
- package/dist/react/toolbarButtons.js.map +1 -0
- package/dist/register.d.ts +20 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +27 -0
- package/dist/register.js.map +1 -0
- package/dist/render.d.ts +89 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/package.json +92 -0
- package/src/Block.ts +75 -0
- package/src/MentionProvider.ts +153 -0
- package/src/RichTextField.test.ts +447 -0
- package/src/RichTextField.ts +508 -0
- package/src/extensions/BlockNodeExtension.ts +134 -0
- package/src/extensions/DragHandleExtension.ts +184 -0
- package/src/extensions/GridExtension.test.ts +31 -0
- package/src/extensions/GridExtension.ts +138 -0
- package/src/extensions/MentionExtension.ts +248 -0
- package/src/extensions/MergeTagExtension.ts +75 -0
- package/src/extensions/SlashCommandExtension.test.ts +147 -0
- package/src/extensions/SlashCommandExtension.ts +332 -0
- package/src/extensions/TextSizeMarks.ts +73 -0
- package/src/index.ts +28 -0
- package/src/plugin.test.ts +19 -0
- package/src/plugin.ts +26 -0
- package/src/react/BlockNodeView.tsx +99 -0
- package/src/react/BlockSidePanel.test.ts +412 -0
- package/src/react/BlockSidePanel.tsx +451 -0
- package/src/react/FloatingToolbar.tsx +304 -0
- package/src/react/MentionMenu.tsx +120 -0
- package/src/react/Palette.tsx +86 -0
- package/src/react/SlashMenu.tsx +129 -0
- package/src/react/TableFloatingToolbar.tsx +154 -0
- package/src/react/TiptapEditor.tsx +535 -0
- package/src/react/Toolbar.tsx +438 -0
- package/src/react/toolbarButtons.tsx +579 -0
- package/src/register.test.ts +14 -0
- package/src/register.ts +27 -0
- package/src/render.test.ts +745 -0
- package/src/render.ts +480 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { Tooltip } from '@base-ui/react/tooltip'
|
|
4
|
+
import type { ToolbarButtonId } from '../RichTextField.js'
|
|
5
|
+
import { TOOLBAR_BUTTONS, type ToolbarButtonDef } from './toolbarButtons.js'
|
|
6
|
+
|
|
7
|
+
interface TableFloatingToolbarProps {
|
|
8
|
+
editor: Editor
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Cell-management toolbar shown whenever the cursor is inside a table. Pinned
|
|
13
|
+
* to the top edge of the enclosing `<table>`, viewport-relative so it tracks
|
|
14
|
+
* scroll without forcing the editor wrapper to be `position: relative`.
|
|
15
|
+
*
|
|
16
|
+
* Buttons map directly onto the table-* ids registered in `toolbarButtons.tsx`,
|
|
17
|
+
* so the icons / disabled gates / commands stay in sync with the top-level
|
|
18
|
+
* toolbar's table buttons.
|
|
19
|
+
*/
|
|
20
|
+
const TABLE_BUTTON_GROUPS: ToolbarButtonId[][] = [
|
|
21
|
+
['tableAddColumnBefore', 'tableAddColumnAfter', 'tableDeleteColumn'],
|
|
22
|
+
['tableAddRowBefore', 'tableAddRowAfter', 'tableDeleteRow'],
|
|
23
|
+
['tableMergeCells', 'tableSplitCell'],
|
|
24
|
+
['tableToggleHeaderRow', 'tableToggleHeaderCell'],
|
|
25
|
+
['tableDelete'],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export function TableFloatingToolbar({ editor }: TableFloatingToolbarProps) {
|
|
29
|
+
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
30
|
+
// Force re-render when the selection moves so isActive / isDisabled flip.
|
|
31
|
+
const [, setTick] = useState(0)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const update = (): void => {
|
|
35
|
+
if (!editor.isActive('table')) { setPos(null); return }
|
|
36
|
+
const tableDom = findEnclosingTable(editor)
|
|
37
|
+
if (!tableDom) { setPos(null); return }
|
|
38
|
+
const rect = tableDom.getBoundingClientRect()
|
|
39
|
+
// Lift the toolbar above the table — height of the strip + breathing room.
|
|
40
|
+
// Bump if the strip grows.
|
|
41
|
+
const top = rect.top - 44
|
|
42
|
+
const left = rect.left + rect.width / 2
|
|
43
|
+
setPos({ top, left })
|
|
44
|
+
}
|
|
45
|
+
const close = (): void => setPos(null)
|
|
46
|
+
update()
|
|
47
|
+
editor.on('selectionUpdate', update)
|
|
48
|
+
editor.on('transaction', update)
|
|
49
|
+
editor.on('blur', close)
|
|
50
|
+
window.addEventListener('scroll', update, true)
|
|
51
|
+
window.addEventListener('resize', update)
|
|
52
|
+
return () => {
|
|
53
|
+
editor.off('selectionUpdate', update)
|
|
54
|
+
editor.off('transaction', update)
|
|
55
|
+
editor.off('blur', close)
|
|
56
|
+
window.removeEventListener('scroll', update, true)
|
|
57
|
+
window.removeEventListener('resize', update)
|
|
58
|
+
}
|
|
59
|
+
}, [editor])
|
|
60
|
+
|
|
61
|
+
// Refresh the disabled/active state predicates on every tx — the buttons
|
|
62
|
+
// read these inline against the live editor.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!editor) return
|
|
65
|
+
const bump = (): void => setTick((t) => t + 1)
|
|
66
|
+
editor.on('selectionUpdate', bump)
|
|
67
|
+
editor.on('transaction', bump)
|
|
68
|
+
return () => {
|
|
69
|
+
editor.off('selectionUpdate', bump)
|
|
70
|
+
editor.off('transaction', bump)
|
|
71
|
+
}
|
|
72
|
+
}, [editor])
|
|
73
|
+
|
|
74
|
+
if (!pos) return null
|
|
75
|
+
|
|
76
|
+
const groups = TABLE_BUTTON_GROUPS
|
|
77
|
+
.map((g) => g.map((id) => TOOLBAR_BUTTONS[id]).filter((b): b is ToolbarButtonDef => Boolean(b?.available)))
|
|
78
|
+
.filter((g) => g.length > 0)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Tooltip.Provider delay={400}>
|
|
82
|
+
<div
|
|
83
|
+
className="fixed z-40 flex items-center gap-0.5 rounded-md border bg-popover px-1 py-1 text-popover-foreground shadow-md"
|
|
84
|
+
style={{ top: pos.top, left: pos.left, transform: 'translateX(-50%)' }}
|
|
85
|
+
// mousedown shouldn't steal focus — keeps the cell selection alive
|
|
86
|
+
// while the command runs.
|
|
87
|
+
onMouseDown={(e) => { e.preventDefault() }}
|
|
88
|
+
>
|
|
89
|
+
{groups.map((group, gi) => (
|
|
90
|
+
<div key={gi} className="flex items-center gap-0.5">
|
|
91
|
+
{gi > 0 && <span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-border" />}
|
|
92
|
+
{group.map((btn) => (
|
|
93
|
+
<TableButton key={btn.id} def={btn} editor={editor} />
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</Tooltip.Provider>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function TableButton({ def, editor }: { def: ToolbarButtonDef; editor: Editor }) {
|
|
103
|
+
const active = def.isActive?.(editor) ?? false
|
|
104
|
+
const disabled = def.isDisabled?.(editor) ?? false
|
|
105
|
+
return (
|
|
106
|
+
<Tooltip.Root>
|
|
107
|
+
<Tooltip.Trigger
|
|
108
|
+
render={(props) => (
|
|
109
|
+
<button
|
|
110
|
+
{...props}
|
|
111
|
+
type="button"
|
|
112
|
+
disabled={disabled}
|
|
113
|
+
onClick={() => def.command(editor)}
|
|
114
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
|
115
|
+
active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
|
|
116
|
+
}`}
|
|
117
|
+
aria-label={def.label}
|
|
118
|
+
aria-pressed={active}
|
|
119
|
+
>
|
|
120
|
+
{def.icon}
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
/>
|
|
124
|
+
<Tooltip.Portal>
|
|
125
|
+
<Tooltip.Positioner side="top" sideOffset={6} className="isolate z-50">
|
|
126
|
+
<Tooltip.Popup className="rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md 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">
|
|
127
|
+
{def.label}
|
|
128
|
+
</Tooltip.Popup>
|
|
129
|
+
</Tooltip.Positioner>
|
|
130
|
+
</Tooltip.Portal>
|
|
131
|
+
</Tooltip.Root>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Walk up from the current selection to find the enclosing `<table>` DOM node.
|
|
137
|
+
* Returns `null` if the cursor isn't inside one. Uses `view.domAtPos` rather
|
|
138
|
+
* than walking the document tree — works even when the cell is inside a
|
|
139
|
+
* resize-NodeView wrapper.
|
|
140
|
+
*/
|
|
141
|
+
function findEnclosingTable(editor: Editor): HTMLElement | null {
|
|
142
|
+
const { from } = editor.state.selection
|
|
143
|
+
let dom: Node | null
|
|
144
|
+
try {
|
|
145
|
+
dom = editor.view.domAtPos(from).node
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
while (dom && dom !== editor.view.dom) {
|
|
150
|
+
if (dom instanceof HTMLElement && dom.tagName === 'TABLE') return dom
|
|
151
|
+
dom = dom.parentNode
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
@@ -0,0 +1,535 @@
|
|
|
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 { FieldRendererProps } from '@pilotiq/pilotiq/react'
|
|
17
|
+
import type { BlockMeta } from '../Block.js'
|
|
18
|
+
import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
|
|
19
|
+
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
|
|
20
|
+
import {
|
|
21
|
+
SlashCommandExtension,
|
|
22
|
+
type SlashState,
|
|
23
|
+
} from '../extensions/SlashCommandExtension.js'
|
|
24
|
+
import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
|
|
25
|
+
import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
|
|
26
|
+
import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
|
|
27
|
+
import {
|
|
28
|
+
MentionExtension,
|
|
29
|
+
type MentionState,
|
|
30
|
+
} from '../extensions/MentionExtension.js'
|
|
31
|
+
import type { MentionProviderMeta } from '../MentionProvider.js'
|
|
32
|
+
import { SlashMenu, type SlashKeyHandlerRef } from './SlashMenu.js'
|
|
33
|
+
import { MentionMenu, type MentionKeyHandlerRef } from './MentionMenu.js'
|
|
34
|
+
import { FloatingToolbar } from './FloatingToolbar.js'
|
|
35
|
+
import { TableFloatingToolbar } from './TableFloatingToolbar.js'
|
|
36
|
+
import { Toolbar, AttachFilesDialog, useEditorTick } from './Toolbar.js'
|
|
37
|
+
import { BlockSidePanel } from './BlockSidePanel.js'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The pilotiq field renderer for `RichTextField`. Registered globally via
|
|
41
|
+
* `registerTiptap()`; pilotiq's `SchemaRenderer` looks it up by `fieldType:
|
|
42
|
+
* 'richtext'` and mounts it inline inside the form.
|
|
43
|
+
*
|
|
44
|
+
* Wiring (Phase A):
|
|
45
|
+
* - StarterKit + Underline + Subscript + Superscript + TextAlign
|
|
46
|
+
* - Placeholder
|
|
47
|
+
* - BlockNodeExtension (custom-block storage + React NodeView)
|
|
48
|
+
* - SlashCommandExtension (`/` opens menu, items derived from `blocks`)
|
|
49
|
+
* - DragHandleExtension (hover gutter handle)
|
|
50
|
+
*
|
|
51
|
+
* Form integration: a hidden `<input type="hidden" name={field}>` carries
|
|
52
|
+
* the editor's serialized output. Storage format depends on the field's
|
|
53
|
+
* `.storage('json' | 'html')` setting — JSON parses on the server,
|
|
54
|
+
* HTML is passed through.
|
|
55
|
+
*/
|
|
56
|
+
export function TiptapEditor(props: FieldRendererProps) {
|
|
57
|
+
// useEditor + ProseMirror touch the DOM during construction — render a
|
|
58
|
+
// static placeholder during SSR so Vike's first paint doesn't crash.
|
|
59
|
+
// Hydration mounts the real editor on the client.
|
|
60
|
+
const [mounted, setMounted] = useState(false)
|
|
61
|
+
useEffect(() => { setMounted(true) }, [])
|
|
62
|
+
|
|
63
|
+
if (!mounted) {
|
|
64
|
+
const storage = (props.el['storage'] as RichTextStorage | undefined) ?? 'json'
|
|
65
|
+
const initialValue = serializeForHidden(props.defaultValue, storage)
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex flex-col gap-1">
|
|
68
|
+
<input type="hidden" name={props.name} value={initialValue} />
|
|
69
|
+
<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">
|
|
70
|
+
{props.placeholder ?? 'Start writing…'}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return <ClientEditor {...props} />
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ClientEditor(props: FieldRendererProps) {
|
|
80
|
+
const { el, name, defaultValue, placeholder, disabled } = props
|
|
81
|
+
|
|
82
|
+
const blocks = (el['blocks'] as BlockMeta[] | undefined) ?? []
|
|
83
|
+
const slashEnabled = (el['slashCommand'] as boolean | undefined) ?? true
|
|
84
|
+
const toolbarGroups = (el['toolbarGroups'] as ToolbarGroups | null | undefined) ?? null
|
|
85
|
+
const floatingEnabled = (el['floatingToolbar'] as boolean | undefined) ?? true
|
|
86
|
+
const storage = (el['storage'] as RichTextStorage | undefined) ?? 'json'
|
|
87
|
+
const textColors = (el['textColors'] as ColorSwatch[] | undefined) ?? []
|
|
88
|
+
const customTextColors = (el['customTextColors'] as boolean | undefined) ?? false
|
|
89
|
+
const highlightColors = (el['highlightColors'] as ColorSwatch[] | undefined) ?? []
|
|
90
|
+
const resizableImages = (el['resizableImages'] as boolean | undefined) ?? false
|
|
91
|
+
const uploadUrl = (el['uploadUrl'] as string | undefined)
|
|
92
|
+
const acceptedFileTypes = (el['fileAttachmentsAcceptedFileTypes'] as string[] | undefined)
|
|
93
|
+
const maxAttachmentSize = (el['fileAttachmentsMaxSize'] as number | undefined)
|
|
94
|
+
const attachmentDir = (el['fileAttachmentsDirectory'] as string | undefined)
|
|
95
|
+
const attachmentVis = (el['fileAttachmentsVisibility'] as ('public' | 'private') | undefined)
|
|
96
|
+
const mergeTags = (el['mergeTags'] as string[] | undefined) ?? []
|
|
97
|
+
const mentionProviders = (el['mentions'] as MentionProviderMeta[] | undefined) ?? []
|
|
98
|
+
const mentionsUrl = (el['mentionsUrl'] as string | undefined)
|
|
99
|
+
|
|
100
|
+
const initialContent = parseInitialContent(defaultValue)
|
|
101
|
+
const [serialized, setSerialized] = useState(() => serializeForHidden(initialContent, storage))
|
|
102
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
103
|
+
|
|
104
|
+
// Slash-menu state. `rawState` is what the extension last emitted.
|
|
105
|
+
// `dismissed` is the Escape latch — while true, the popover stays hidden
|
|
106
|
+
// even if the suggestion plugin keeps firing onUpdate. It clears when the
|
|
107
|
+
// suggestion plugin formally exits (cursor leaves the slash range).
|
|
108
|
+
const [rawState, setRawState] = useState<SlashState | null>(null)
|
|
109
|
+
const [dismissed, setDismissed] = useState(false)
|
|
110
|
+
const slashState = dismissed ? null : rawState
|
|
111
|
+
const slashKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
|
|
112
|
+
|
|
113
|
+
const handleStateChange = useCallback((s: SlashState | null) => {
|
|
114
|
+
if (s === null) setDismissed(false)
|
|
115
|
+
setRawState(s)
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
// Mention popover state — symmetrical to slash, with its own dismiss latch
|
|
119
|
+
// so Escape closes the mention popup without affecting slash.
|
|
120
|
+
const [rawMentionState, setRawMentionState] = useState<MentionState | null>(null)
|
|
121
|
+
const [mentionDismissed, setMentionDismissed] = useState(false)
|
|
122
|
+
const mentionState = mentionDismissed ? null : rawMentionState
|
|
123
|
+
const mentionKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
|
|
124
|
+
|
|
125
|
+
// Lifted upload-dialog state — the toolbar's `attachFiles` button and the
|
|
126
|
+
// slash menu's "Image" entry both flip this flag. Single source of truth
|
|
127
|
+
// keeps the dialog mounted in one place (inside `Toolbar`) regardless of
|
|
128
|
+
// which trigger fired.
|
|
129
|
+
const [attachOpen, setAttachOpen] = useState(false)
|
|
130
|
+
|
|
131
|
+
const handleMentionStateChange = useCallback((s: MentionState | null) => {
|
|
132
|
+
if (s === null) setMentionDismissed(false)
|
|
133
|
+
setRawMentionState(s)
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
// Custom-block side panel — opens when a block's NodeView fires its
|
|
137
|
+
// Edit button. The NodeView lives in a separate React tree and reaches
|
|
138
|
+
// us via `BlockNodeExtension.options.onEdit` (set during configure()
|
|
139
|
+
// below). Stores `pos` + `blockType` at open-time; `BlockSidePanel`
|
|
140
|
+
// tracks the position forward through transactions and writes attrs
|
|
141
|
+
// back via setNodeMarkup. Closing nullifies the slot — re-opening
|
|
142
|
+
// remounts the panel fresh, including a re-snapshot of `blockData`.
|
|
143
|
+
const [selectedBlock, setSelectedBlock] = useState<{ pos: number; blockType: string } | null>(null)
|
|
144
|
+
const handleEditBlock = useCallback((pos: number) => {
|
|
145
|
+
// We resolve `blockType` here against the current doc so a stale
|
|
146
|
+
// pos (e.g. the block was just deleted before the click landed)
|
|
147
|
+
// produces a no-op rather than an empty panel.
|
|
148
|
+
setSelectedBlock((prev) => {
|
|
149
|
+
// Read from the editor lazily — the editor ref isn't stable yet
|
|
150
|
+
// on the very first render where this callback is created, so
|
|
151
|
+
// defer the lookup to call time.
|
|
152
|
+
const ed = editorRef.current
|
|
153
|
+
if (!ed) return prev
|
|
154
|
+
const node = (ed.state.doc as unknown as { nodeAt: (p: number) => { type: { name: string }; attrs: Record<string, unknown> } | null }).nodeAt(pos)
|
|
155
|
+
if (!node || node.type.name !== 'pilotiqBlock') return prev
|
|
156
|
+
return { pos, blockType: String(node.attrs['blockType'] ?? '') }
|
|
157
|
+
})
|
|
158
|
+
}, [])
|
|
159
|
+
const closeBlockPanel = useCallback(() => { setSelectedBlock(null) }, [])
|
|
160
|
+
|
|
161
|
+
// editorRef gives the onEdit callback access to the editor instance
|
|
162
|
+
// without re-creating the callback on every render (which would force
|
|
163
|
+
// the extension config to re-evaluate, triggering a full editor reset).
|
|
164
|
+
const editorRef = useRef<Editor | null>(null)
|
|
165
|
+
|
|
166
|
+
const editor = useEditor({
|
|
167
|
+
editable: !disabled,
|
|
168
|
+
extensions: [
|
|
169
|
+
// StarterKit 3.22+ ships Link AND Underline; configure through the
|
|
170
|
+
// kit rather than re-adding (else "Duplicate extension names" warns).
|
|
171
|
+
StarterKit.configure({
|
|
172
|
+
link: { openOnClick: false, autolink: true },
|
|
173
|
+
}),
|
|
174
|
+
Subscript,
|
|
175
|
+
Superscript,
|
|
176
|
+
LeadMarkExtension,
|
|
177
|
+
SmallMarkExtension,
|
|
178
|
+
// textAlign needs to be told which node types it can target. Headings
|
|
179
|
+
// + paragraphs are the standard set. Blockquote alignment is handled
|
|
180
|
+
// by aligning the inner paragraph.
|
|
181
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
182
|
+
// TextStyle is a no-op mark on its own, but Color decorates it with the
|
|
183
|
+
// `color` attribute so `.setColor(...)` works. Loading them as a pair
|
|
184
|
+
// keeps the extension surface complete.
|
|
185
|
+
TextStyle,
|
|
186
|
+
Color,
|
|
187
|
+
Highlight.configure({ multicolor: true }),
|
|
188
|
+
Image.configure({
|
|
189
|
+
// Inline images break under prose's `figure` margin reset; the
|
|
190
|
+
// editor uses block images by default, matching the read-side
|
|
191
|
+
// renderer's `<img>` output.
|
|
192
|
+
inline: false,
|
|
193
|
+
// Most upload adapters return URLs — base64 inflates the doc and
|
|
194
|
+
// re-uploads on every save. Opt back in only if your adapter
|
|
195
|
+
// explicitly stores data URLs.
|
|
196
|
+
allowBase64: false,
|
|
197
|
+
resize: resizableImages
|
|
198
|
+
? { enabled: true, alwaysPreserveAspectRatio: true }
|
|
199
|
+
: false,
|
|
200
|
+
}),
|
|
201
|
+
// Tables — the four nodes ship from one peer (`@tiptap/extension-table`).
|
|
202
|
+
// `resizable: true` mounts the built-in column-resize NodeView so users
|
|
203
|
+
// can drag column dividers; `lastColumnResizable: false` keeps the
|
|
204
|
+
// right-edge handle from creating an unbounded growth target when the
|
|
205
|
+
// table sits inside a constrained-width form.
|
|
206
|
+
Table.configure({ resizable: true, lastColumnResizable: false }),
|
|
207
|
+
TableRow,
|
|
208
|
+
TableHeader,
|
|
209
|
+
TableCell,
|
|
210
|
+
// Collapsible `<details>` blocks. `persist: true` round-trips the
|
|
211
|
+
// open/closed state through the document attrs so SSR + reload pick up
|
|
212
|
+
// the same state the author left it in. The default summary text on
|
|
213
|
+
// insert ("Title") gives the user something to overwrite.
|
|
214
|
+
Details.configure({ persist: true, HTMLAttributes: { class: 'details' } }),
|
|
215
|
+
DetailsSummary,
|
|
216
|
+
DetailsContent,
|
|
217
|
+
// Multi-column grid blocks (`grid` + `gridColumn`). Custom node pair —
|
|
218
|
+
// Tiptap doesn't ship a first-party grid extension. Schema constrains
|
|
219
|
+
// grids to 2 or 3 columns; consumer owns the CSS for `pilotiq-grid` /
|
|
220
|
+
// `pilotiq-grid-cols-N`.
|
|
221
|
+
Grid,
|
|
222
|
+
GridColumn,
|
|
223
|
+
Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
|
|
224
|
+
// BlockNodeExtension carries the block registry on its options —
|
|
225
|
+
// NodeViews mount in a separate React tree and can't see context.
|
|
226
|
+
// `onEdit` is the bridge back to the host editor's tree where the
|
|
227
|
+
// side panel lives; the NodeView's Edit button calls it with its
|
|
228
|
+
// own `getPos()`.
|
|
229
|
+
BlockNodeExtension.configure({ blocks, onEdit: handleEditBlock }),
|
|
230
|
+
...(slashEnabled ? [SlashCommandExtension.configure({
|
|
231
|
+
blocks,
|
|
232
|
+
mergeTags,
|
|
233
|
+
onStateChange: handleStateChange,
|
|
234
|
+
hasUpload: Boolean(uploadUrl),
|
|
235
|
+
onInsertImage: () => setAttachOpen(true),
|
|
236
|
+
})] : []),
|
|
237
|
+
// MergeTagExtension provides the `mergeTag` node type even when no tags
|
|
238
|
+
// are configured — the slash menu is the gate for *inserting* them, but
|
|
239
|
+
// the schema needs to know about the node either way (otherwise loading
|
|
240
|
+
// an existing doc that contains one throws a parse error).
|
|
241
|
+
MergeTagExtension,
|
|
242
|
+
...(mentionProviders.length > 0 ? [MentionExtension.configure({
|
|
243
|
+
providers: mentionProviders,
|
|
244
|
+
onStateChange: handleMentionStateChange,
|
|
245
|
+
...(mentionsUrl ? { mentionsUrl } : {}),
|
|
246
|
+
fieldName: name,
|
|
247
|
+
})] : [MentionExtension]),
|
|
248
|
+
DragHandleExtension,
|
|
249
|
+
],
|
|
250
|
+
content: initialContent ?? '',
|
|
251
|
+
onUpdate: ({ editor: ed }) => {
|
|
252
|
+
// Debounce serialization — every keystroke fires onUpdate.
|
|
253
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
254
|
+
debounceRef.current = setTimeout(() => {
|
|
255
|
+
const value = storage === 'html' ? ed.getHTML() : JSON.stringify(ed.getJSON())
|
|
256
|
+
setSerialized(value)
|
|
257
|
+
}, 250)
|
|
258
|
+
},
|
|
259
|
+
editorProps: {
|
|
260
|
+
attributes: {
|
|
261
|
+
// Drop the top border-radius when the toolbar is on so the toolbar
|
|
262
|
+
// and editor body read as a single chrome.
|
|
263
|
+
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 ${
|
|
264
|
+
toolbarGroups && toolbarGroups.length > 0
|
|
265
|
+
? 'rounded-b-md border-t-0'
|
|
266
|
+
: 'rounded-md'
|
|
267
|
+
}`,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Document-level keyboard handler for the slash menu. Capture phase so we
|
|
273
|
+
// run before ProseMirror's `view.dom` keydown listener — that way Enter
|
|
274
|
+
// doesn't split the paragraph and Arrows don't move the cursor while
|
|
275
|
+
// navigating the menu. Listen at `document` because Base UI's focus manager
|
|
276
|
+
// can briefly pull focus into the popup when it mounts.
|
|
277
|
+
const open = slashState !== null
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!open) return
|
|
280
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
281
|
+
if (e.key === 'Escape') {
|
|
282
|
+
setDismissed(true)
|
|
283
|
+
e.preventDefault()
|
|
284
|
+
e.stopPropagation()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
288
|
+
const handled = slashKeyRef.current?.(e) ?? false
|
|
289
|
+
if (handled) {
|
|
290
|
+
e.preventDefault()
|
|
291
|
+
e.stopPropagation()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
296
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
297
|
+
}, [open])
|
|
298
|
+
|
|
299
|
+
// Mirror keyboard handling for the mention popover. Capture-phase listener
|
|
300
|
+
// anchored to `document` for the same reason the slash menu uses it —
|
|
301
|
+
// Base UI's focus manager can briefly steal focus to its popup.
|
|
302
|
+
const mentionOpen = mentionState !== null
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (!mentionOpen) return
|
|
305
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
306
|
+
if (e.key === 'Escape') {
|
|
307
|
+
setMentionDismissed(true)
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
e.stopPropagation()
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
313
|
+
const handled = mentionKeyRef.current?.(e) ?? false
|
|
314
|
+
if (handled) {
|
|
315
|
+
e.preventDefault()
|
|
316
|
+
e.stopPropagation()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
321
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
322
|
+
}, [mentionOpen])
|
|
323
|
+
|
|
324
|
+
useEffect(() => () => {
|
|
325
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
326
|
+
}, [])
|
|
327
|
+
|
|
328
|
+
// Mirror the editor instance into a ref so callbacks captured during
|
|
329
|
+
// `useEditor`'s extension config (notably the BlockNode `onEdit`
|
|
330
|
+
// bridge) can reach the live editor without re-creating themselves
|
|
331
|
+
// every render. Re-creation would force the editor to rebuild from
|
|
332
|
+
// scratch on every keystroke.
|
|
333
|
+
useEffect(() => { editorRef.current = editor ?? null }, [editor])
|
|
334
|
+
|
|
335
|
+
// Re-render the toolbar when the selection / marks change so active-state
|
|
336
|
+
// booleans stay fresh.
|
|
337
|
+
const tick = useEditorTick(editor)
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="relative flex flex-col gap-1">
|
|
341
|
+
<input type="hidden" name={name} value={serialized} />
|
|
342
|
+
{editor && toolbarGroups && toolbarGroups.length > 0 && (
|
|
343
|
+
<Toolbar
|
|
344
|
+
editor={editor}
|
|
345
|
+
groups={toolbarGroups}
|
|
346
|
+
tick={tick}
|
|
347
|
+
textColors={textColors}
|
|
348
|
+
customTextColors={customTextColors}
|
|
349
|
+
highlightColors={highlightColors}
|
|
350
|
+
onAttachOpenChange={setAttachOpen}
|
|
351
|
+
/>
|
|
352
|
+
)}
|
|
353
|
+
{/* Single mount for the attach-files dialog — toolbar's `attachFiles`
|
|
354
|
+
button and slash menu's "Image" entry both flip `attachOpen`.
|
|
355
|
+
Mounted at the editor level (not the toolbar) so it stays available
|
|
356
|
+
when the toolbar is hidden via `.toolbar(false)`. */}
|
|
357
|
+
{editor && (
|
|
358
|
+
<AttachFilesDialog
|
|
359
|
+
open={attachOpen}
|
|
360
|
+
onOpenChange={setAttachOpen}
|
|
361
|
+
editor={editor}
|
|
362
|
+
fieldName={name}
|
|
363
|
+
{...(uploadUrl !== undefined ? { uploadUrl } : {})}
|
|
364
|
+
{...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {})}
|
|
365
|
+
{...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {})}
|
|
366
|
+
{...(attachmentDir !== undefined ? { directory: attachmentDir } : {})}
|
|
367
|
+
{...(attachmentVis !== undefined ? { visibility: attachmentVis } : {})}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
<EditorContent editor={editor} />
|
|
371
|
+
{editor && floatingEnabled && <FloatingToolbar editor={editor} />}
|
|
372
|
+
{editor && <TableFloatingToolbar editor={editor} />}
|
|
373
|
+
<SlashPopover state={slashState} keyHandlerRef={slashKeyRef} />
|
|
374
|
+
<MentionPopover state={mentionState} keyHandlerRef={mentionKeyRef} />
|
|
375
|
+
{editor && selectedBlock && (
|
|
376
|
+
<BlockSidePanel
|
|
377
|
+
key={`${selectedBlock.pos}:${selectedBlock.blockType}`}
|
|
378
|
+
editor={editor}
|
|
379
|
+
initialPos={selectedBlock.pos}
|
|
380
|
+
blockType={selectedBlock.blockType}
|
|
381
|
+
blocks={blocks}
|
|
382
|
+
onClose={closeBlockPanel}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Cursor-anchored popover for the mention menu. Same Floating-UI / virtual-
|
|
391
|
+
* element pattern as the slash popover — a `clientRect` lambda from the
|
|
392
|
+
* Suggestion plugin powers a `getBoundingClientRect`-only anchor object.
|
|
393
|
+
*/
|
|
394
|
+
function MentionPopover({
|
|
395
|
+
state,
|
|
396
|
+
keyHandlerRef,
|
|
397
|
+
}: {
|
|
398
|
+
state: MentionState | null
|
|
399
|
+
keyHandlerRef: MentionKeyHandlerRef
|
|
400
|
+
}) {
|
|
401
|
+
const open = state !== null
|
|
402
|
+
|
|
403
|
+
const anchor = useMemo(() => {
|
|
404
|
+
if (!state) return null
|
|
405
|
+
return {
|
|
406
|
+
getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
|
|
407
|
+
}
|
|
408
|
+
}, [state])
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<Popover.Root open={open} onOpenChange={() => {}}>
|
|
412
|
+
<Popover.Portal>
|
|
413
|
+
<Popover.Positioner
|
|
414
|
+
anchor={anchor}
|
|
415
|
+
positionMethod="fixed"
|
|
416
|
+
side="bottom"
|
|
417
|
+
align="start"
|
|
418
|
+
sideOffset={6}
|
|
419
|
+
className="isolate z-50"
|
|
420
|
+
>
|
|
421
|
+
<Popover.Popup
|
|
422
|
+
initialFocus={false}
|
|
423
|
+
finalFocus={false}
|
|
424
|
+
tabIndex={-1}
|
|
425
|
+
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"
|
|
426
|
+
>
|
|
427
|
+
{state && (
|
|
428
|
+
<MentionMenu
|
|
429
|
+
trigger={state.trigger}
|
|
430
|
+
items={state.items}
|
|
431
|
+
command={state.command}
|
|
432
|
+
keyHandlerRef={keyHandlerRef}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</Popover.Popup>
|
|
436
|
+
</Popover.Positioner>
|
|
437
|
+
</Popover.Portal>
|
|
438
|
+
</Popover.Root>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Renders the slash menu inside a Base UI Popover anchored to the cursor's
|
|
444
|
+
* client rect. Floating UI under Base UI handles scroll/resize tracking and
|
|
445
|
+
* collision avoidance, so we never have to recompute position ourselves.
|
|
446
|
+
*/
|
|
447
|
+
function SlashPopover({
|
|
448
|
+
state,
|
|
449
|
+
keyHandlerRef,
|
|
450
|
+
}: {
|
|
451
|
+
state: SlashState | null
|
|
452
|
+
keyHandlerRef: SlashKeyHandlerRef
|
|
453
|
+
}) {
|
|
454
|
+
const open = state !== null
|
|
455
|
+
|
|
456
|
+
// Virtual element built from the suggestion plugin's clientRect lambda.
|
|
457
|
+
// The Positioner re-reads `getBoundingClientRect` on every layout tick,
|
|
458
|
+
// and `clientRect()` returns viewport-relative coords from PM, so scroll
|
|
459
|
+
// tracking is automatic.
|
|
460
|
+
const anchor = useMemo(() => {
|
|
461
|
+
if (!state) return null
|
|
462
|
+
return {
|
|
463
|
+
getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
|
|
464
|
+
}
|
|
465
|
+
}, [state])
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<Popover.Root open={open} onOpenChange={() => {}}>
|
|
469
|
+
<Popover.Portal>
|
|
470
|
+
<Popover.Positioner
|
|
471
|
+
anchor={anchor}
|
|
472
|
+
// `fixed` makes the popup's bounding rect viewport-relative, so the
|
|
473
|
+
// initial render (before Floating UI computes the anchor position)
|
|
474
|
+
// doesn't sit at body (0,0) — that would trigger the browser to
|
|
475
|
+
// scroll the page when the popup mounts and momentarily becomes
|
|
476
|
+
// the focus target.
|
|
477
|
+
positionMethod="fixed"
|
|
478
|
+
side="bottom"
|
|
479
|
+
align="start"
|
|
480
|
+
sideOffset={6}
|
|
481
|
+
className="isolate z-50"
|
|
482
|
+
>
|
|
483
|
+
<Popover.Popup
|
|
484
|
+
// Keep focus in the editor — keyboard navigation is driven via a
|
|
485
|
+
// document-level listener in TiptapEditor, never via DOM focus.
|
|
486
|
+
initialFocus={false}
|
|
487
|
+
finalFocus={false}
|
|
488
|
+
tabIndex={-1}
|
|
489
|
+
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"
|
|
490
|
+
>
|
|
491
|
+
{state && (
|
|
492
|
+
<SlashMenu
|
|
493
|
+
items={state.items}
|
|
494
|
+
command={state.command}
|
|
495
|
+
keyHandlerRef={keyHandlerRef}
|
|
496
|
+
/>
|
|
497
|
+
)}
|
|
498
|
+
</Popover.Popup>
|
|
499
|
+
</Popover.Positioner>
|
|
500
|
+
</Popover.Portal>
|
|
501
|
+
</Popover.Root>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseInitialContent(raw: unknown): object | string | undefined {
|
|
506
|
+
if (raw === undefined || raw === null || raw === '') return undefined
|
|
507
|
+
if (typeof raw === 'object') return raw as object
|
|
508
|
+
if (typeof raw === 'string') {
|
|
509
|
+
const trimmed = raw.trim()
|
|
510
|
+
// Looks like JSON — try to parse. Otherwise treat as HTML and pass to
|
|
511
|
+
// Tiptap (it accepts an HTML string as `content`).
|
|
512
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
513
|
+
try { return JSON.parse(raw) } catch { return raw }
|
|
514
|
+
}
|
|
515
|
+
return raw
|
|
516
|
+
}
|
|
517
|
+
return undefined
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function serializeForHidden(content: unknown, storage: RichTextStorage): string {
|
|
521
|
+
if (content === undefined || content === null) {
|
|
522
|
+
return storage === 'html' ? '' : JSON.stringify(null)
|
|
523
|
+
}
|
|
524
|
+
if (storage === 'html') {
|
|
525
|
+
return typeof content === 'string' ? content : ''
|
|
526
|
+
}
|
|
527
|
+
if (typeof content === 'object') return JSON.stringify(content)
|
|
528
|
+
if (typeof content === 'string') {
|
|
529
|
+
// Best-effort: a stored JSON string from the server should round-trip.
|
|
530
|
+
const trimmed = content.trim()
|
|
531
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return content
|
|
532
|
+
return JSON.stringify(null)
|
|
533
|
+
}
|
|
534
|
+
return JSON.stringify(null)
|
|
535
|
+
}
|