@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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/Block.d.ts +47 -0
  4. package/dist/Block.d.ts.map +1 -0
  5. package/dist/Block.js +56 -0
  6. package/dist/Block.js.map +1 -0
  7. package/dist/MentionProvider.d.ts +97 -0
  8. package/dist/MentionProvider.d.ts.map +1 -0
  9. package/dist/MentionProvider.js +104 -0
  10. package/dist/MentionProvider.js.map +1 -0
  11. package/dist/RichTextField.d.ts +286 -0
  12. package/dist/RichTextField.d.ts.map +1 -0
  13. package/dist/RichTextField.js +369 -0
  14. package/dist/RichTextField.js.map +1 -0
  15. package/dist/extensions/BlockNodeExtension.d.ts +41 -0
  16. package/dist/extensions/BlockNodeExtension.d.ts.map +1 -0
  17. package/dist/extensions/BlockNodeExtension.js +103 -0
  18. package/dist/extensions/BlockNodeExtension.js.map +1 -0
  19. package/dist/extensions/DragHandleExtension.d.ts +19 -0
  20. package/dist/extensions/DragHandleExtension.d.ts.map +1 -0
  21. package/dist/extensions/DragHandleExtension.js +166 -0
  22. package/dist/extensions/DragHandleExtension.js.map +1 -0
  23. package/dist/extensions/GridExtension.d.ts +49 -0
  24. package/dist/extensions/GridExtension.d.ts.map +1 -0
  25. package/dist/extensions/GridExtension.js +105 -0
  26. package/dist/extensions/GridExtension.js.map +1 -0
  27. package/dist/extensions/MentionExtension.d.ts +71 -0
  28. package/dist/extensions/MentionExtension.d.ts.map +1 -0
  29. package/dist/extensions/MentionExtension.js +165 -0
  30. package/dist/extensions/MentionExtension.js.map +1 -0
  31. package/dist/extensions/MergeTagExtension.d.ts +24 -0
  32. package/dist/extensions/MergeTagExtension.d.ts.map +1 -0
  33. package/dist/extensions/MergeTagExtension.js +57 -0
  34. package/dist/extensions/MergeTagExtension.js.map +1 -0
  35. package/dist/extensions/SlashCommandExtension.d.ts +71 -0
  36. package/dist/extensions/SlashCommandExtension.d.ts.map +1 -0
  37. package/dist/extensions/SlashCommandExtension.js +244 -0
  38. package/dist/extensions/SlashCommandExtension.js.map +1 -0
  39. package/dist/extensions/TextSizeMarks.d.ts +33 -0
  40. package/dist/extensions/TextSizeMarks.d.ts.map +1 -0
  41. package/dist/extensions/TextSizeMarks.js +47 -0
  42. package/dist/extensions/TextSizeMarks.js.map +1 -0
  43. package/dist/index.d.ts +8 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +8 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/plugin.d.ts +18 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +25 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/react/BlockNodeView.d.ts +19 -0
  52. package/dist/react/BlockNodeView.d.ts.map +1 -0
  53. package/dist/react/BlockNodeView.js +60 -0
  54. package/dist/react/BlockNodeView.js.map +1 -0
  55. package/dist/react/BlockSidePanel.d.ts +105 -0
  56. package/dist/react/BlockSidePanel.d.ts.map +1 -0
  57. package/dist/react/BlockSidePanel.js +339 -0
  58. package/dist/react/BlockSidePanel.js.map +1 -0
  59. package/dist/react/FloatingToolbar.d.ts +13 -0
  60. package/dist/react/FloatingToolbar.d.ts.map +1 -0
  61. package/dist/react/FloatingToolbar.js +113 -0
  62. package/dist/react/FloatingToolbar.js.map +1 -0
  63. package/dist/react/MentionMenu.d.ts +26 -0
  64. package/dist/react/MentionMenu.d.ts.map +1 -0
  65. package/dist/react/MentionMenu.js +64 -0
  66. package/dist/react/MentionMenu.js.map +1 -0
  67. package/dist/react/Palette.d.ts +26 -0
  68. package/dist/react/Palette.d.ts.map +1 -0
  69. package/dist/react/Palette.js +21 -0
  70. package/dist/react/Palette.js.map +1 -0
  71. package/dist/react/SlashMenu.d.ts +24 -0
  72. package/dist/react/SlashMenu.d.ts.map +1 -0
  73. package/dist/react/SlashMenu.js +74 -0
  74. package/dist/react/SlashMenu.js.map +1 -0
  75. package/dist/react/TableFloatingToolbar.d.ts +7 -0
  76. package/dist/react/TableFloatingToolbar.d.ts.map +1 -0
  77. package/dist/react/TableFloatingToolbar.js +108 -0
  78. package/dist/react/TableFloatingToolbar.js.map +1 -0
  79. package/dist/react/TiptapEditor.d.ts +20 -0
  80. package/dist/react/TiptapEditor.d.ts.map +1 -0
  81. package/dist/react/TiptapEditor.js +398 -0
  82. package/dist/react/TiptapEditor.js.map +1 -0
  83. package/dist/react/Toolbar.d.ts +45 -0
  84. package/dist/react/Toolbar.d.ts.map +1 -0
  85. package/dist/react/Toolbar.js +204 -0
  86. package/dist/react/Toolbar.js.map +1 -0
  87. package/dist/react/toolbarButtons.d.ts +36 -0
  88. package/dist/react/toolbarButtons.d.ts.map +1 -0
  89. package/dist/react/toolbarButtons.js +300 -0
  90. package/dist/react/toolbarButtons.js.map +1 -0
  91. package/dist/register.d.ts +20 -0
  92. package/dist/register.d.ts.map +1 -0
  93. package/dist/register.js +27 -0
  94. package/dist/register.js.map +1 -0
  95. package/dist/render.d.ts +89 -0
  96. package/dist/render.d.ts.map +1 -0
  97. package/dist/render.js +439 -0
  98. package/dist/render.js.map +1 -0
  99. package/package.json +92 -0
  100. package/src/Block.ts +75 -0
  101. package/src/MentionProvider.ts +153 -0
  102. package/src/RichTextField.test.ts +447 -0
  103. package/src/RichTextField.ts +508 -0
  104. package/src/extensions/BlockNodeExtension.ts +134 -0
  105. package/src/extensions/DragHandleExtension.ts +184 -0
  106. package/src/extensions/GridExtension.test.ts +31 -0
  107. package/src/extensions/GridExtension.ts +138 -0
  108. package/src/extensions/MentionExtension.ts +248 -0
  109. package/src/extensions/MergeTagExtension.ts +75 -0
  110. package/src/extensions/SlashCommandExtension.test.ts +147 -0
  111. package/src/extensions/SlashCommandExtension.ts +332 -0
  112. package/src/extensions/TextSizeMarks.ts +73 -0
  113. package/src/index.ts +28 -0
  114. package/src/plugin.test.ts +19 -0
  115. package/src/plugin.ts +26 -0
  116. package/src/react/BlockNodeView.tsx +99 -0
  117. package/src/react/BlockSidePanel.test.ts +412 -0
  118. package/src/react/BlockSidePanel.tsx +451 -0
  119. package/src/react/FloatingToolbar.tsx +304 -0
  120. package/src/react/MentionMenu.tsx +120 -0
  121. package/src/react/Palette.tsx +86 -0
  122. package/src/react/SlashMenu.tsx +129 -0
  123. package/src/react/TableFloatingToolbar.tsx +154 -0
  124. package/src/react/TiptapEditor.tsx +535 -0
  125. package/src/react/Toolbar.tsx +438 -0
  126. package/src/react/toolbarButtons.tsx +579 -0
  127. package/src/register.test.ts +14 -0
  128. package/src/register.ts +27 -0
  129. package/src/render.test.ts +745 -0
  130. 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
+ }