@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/dist/react/CollabTextRenderer.d.ts.map +1 -1
  8. package/dist/react/CollabTextRenderer.js +4 -4
  9. package/dist/react/CollabTextRenderer.js.map +1 -1
  10. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  11. package/dist/react/MarkdownEditor.js +4 -5
  12. package/dist/react/MarkdownEditor.js.map +1 -1
  13. package/dist/react/TiptapEditor.d.ts.map +1 -1
  14. package/dist/react/TiptapEditor.js +8 -7
  15. package/dist/react/TiptapEditor.js.map +1 -1
  16. package/package.json +6 -3
  17. package/dist/collabShapes.d.ts +0 -22
  18. package/dist/collabShapes.d.ts.map +0 -1
  19. package/dist/collabShapes.js +0 -2
  20. package/dist/collabShapes.js.map +0 -1
  21. package/src/Block.ts +0 -75
  22. package/src/MentionProvider.ts +0 -153
  23. package/src/PlainTextEditor.dom.test.ts +0 -111
  24. package/src/PlainTextEditor.test.ts +0 -158
  25. package/src/PlainTextEditor.ts +0 -229
  26. package/src/RichTextField.test.ts +0 -447
  27. package/src/RichTextField.ts +0 -508
  28. package/src/collabShapes.ts +0 -22
  29. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  30. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  31. package/src/extensions/AiSuggestionExtension.ts +0 -522
  32. package/src/extensions/BlockNodeExtension.ts +0 -134
  33. package/src/extensions/DragHandleExtension.ts +0 -184
  34. package/src/extensions/GridExtension.test.ts +0 -31
  35. package/src/extensions/GridExtension.ts +0 -138
  36. package/src/extensions/MentionExtension.ts +0 -248
  37. package/src/extensions/MergeTagExtension.ts +0 -75
  38. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  39. package/src/extensions/SlashCommandExtension.ts +0 -332
  40. package/src/extensions/TextSizeMarks.ts +0 -73
  41. package/src/index.ts +0 -62
  42. package/src/markdownExtension.ts +0 -19
  43. package/src/markdownStorage.ts +0 -49
  44. package/src/plugin.test.ts +0 -19
  45. package/src/plugin.ts +0 -26
  46. package/src/react/AiSuggestionBanner.tsx +0 -185
  47. package/src/react/BlockNodeView.tsx +0 -99
  48. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  49. package/src/react/BlockSidePanel.test.ts +0 -412
  50. package/src/react/BlockSidePanel.tsx +0 -451
  51. package/src/react/CollabTextRenderer.tsx +0 -230
  52. package/src/react/FloatingToolbar.tsx +0 -304
  53. package/src/react/MarkdownEditor.tsx +0 -606
  54. package/src/react/MentionMenu.tsx +0 -120
  55. package/src/react/Palette.tsx +0 -86
  56. package/src/react/SlashMenu.tsx +0 -129
  57. package/src/react/TableFloatingToolbar.tsx +0 -154
  58. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  59. package/src/react/TiptapEditor.tsx +0 -776
  60. package/src/react/Toolbar.tsx +0 -438
  61. package/src/react/toolbarButtons.tsx +0 -579
  62. package/src/react/useAiInlineDiff.ts +0 -342
  63. package/src/react/useAiSuggestionBridge.ts +0 -223
  64. package/src/register.test.ts +0 -14
  65. package/src/register.ts +0 -42
  66. package/src/render.test.ts +0 -745
  67. package/src/render.ts +0 -480
  68. package/src/surgicalOps.ts +0 -205
  69. package/src/test/setup.ts +0 -64
@@ -1,120 +0,0 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
2
- import type { MentionItem } from '../MentionProvider.js'
3
-
4
- /**
5
- * Mutable ref the document-level keydown listener in `TiptapEditor` reads.
6
- * `MentionMenu` installs its keyboard handler on mount, clears on unmount —
7
- * same protocol as `SlashMenu`'s `keyHandlerRef`.
8
- */
9
- export type MentionKeyHandlerRef = { current: ((event: KeyboardEvent) => boolean) | null }
10
-
11
- interface MentionMenuProps {
12
- trigger: string
13
- items: MentionItem[]
14
- command: (item: MentionItem) => void
15
- keyHandlerRef: MentionKeyHandlerRef
16
- }
17
-
18
- /**
19
- * Floating list of mention items. Mirrors `SlashMenu` but uses the lighter
20
- * `MentionItem` shape (no command thunk per item — the `command` prop is
21
- * pre-curried by the Suggestion plugin).
22
- *
23
- * Optional `group` strings on items render as section headings; items
24
- * without a group land under "Suggestions".
25
- */
26
- export function MentionMenu({ trigger, items, command, keyHandlerRef }: MentionMenuProps) {
27
- const [active, setActive] = useState(0)
28
- const containerRef = useRef<HTMLDivElement | null>(null)
29
-
30
- const grouped = useMemo(
31
- () => groupBy(items, (it) => it.group ?? 'Suggestions'),
32
- [items],
33
- )
34
- const renderOrder = useMemo(
35
- () => Array.from(grouped.values()).flat(),
36
- [grouped],
37
- )
38
-
39
- useEffect(() => { setActive(0) }, [renderOrder])
40
-
41
- useEffect(() => {
42
- const el = containerRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)
43
- el?.scrollIntoView({ block: 'nearest' })
44
- }, [active])
45
-
46
- useEffect(() => {
47
- keyHandlerRef.current = (event) => {
48
- const len = renderOrder.length
49
- if (event.key === 'ArrowDown') {
50
- setActive((i) => (len === 0 ? 0 : (i + 1) % len))
51
- return true
52
- }
53
- if (event.key === 'ArrowUp') {
54
- setActive((i) => (len === 0 ? 0 : (i - 1 + len) % len))
55
- return true
56
- }
57
- if (event.key === 'Enter') {
58
- const item = renderOrder[active]
59
- if (item) command(item)
60
- return true
61
- }
62
- return false
63
- }
64
- return () => { keyHandlerRef.current = null }
65
- }, [renderOrder, active, command, keyHandlerRef])
66
-
67
- if (renderOrder.length === 0) {
68
- return (
69
- <div className="px-3 py-2 text-xs text-muted-foreground">
70
- No matches
71
- </div>
72
- )
73
- }
74
-
75
- let runningIndex = 0
76
- return (
77
- <div ref={containerRef} className="max-h-72 w-64 overflow-y-auto p-1 text-sm">
78
- {Array.from(grouped.entries()).map(([groupName, groupItems]) => (
79
- <div key={groupName}>
80
- <div className="px-2 pt-2 pb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
81
- {groupName}
82
- </div>
83
- {groupItems.map((item) => {
84
- const idx = runningIndex++
85
- const isActive = idx === active
86
- return (
87
- <button
88
- key={item.id}
89
- data-index={idx}
90
- type="button"
91
- onMouseDown={(e) => { e.preventDefault(); command(item) }}
92
- onMouseEnter={() => setActive(idx)}
93
- className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left ${
94
- isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
95
- }`}
96
- >
97
- <span className="flex size-6 items-center justify-center rounded border bg-background text-xs">
98
- {trigger}
99
- </span>
100
- <span className="flex-1">{item.label}</span>
101
- <span className="text-xs text-muted-foreground">{item.id}</span>
102
- </button>
103
- )
104
- })}
105
- </div>
106
- ))}
107
- </div>
108
- )
109
- }
110
-
111
- function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
112
- const out = new Map<string, T[]>()
113
- for (const item of items) {
114
- const k = key(item)
115
- const list = out.get(k)
116
- if (list) list.push(item)
117
- else out.set(k, [item])
118
- }
119
- return out
120
- }
@@ -1,86 +0,0 @@
1
- import { useState, type ReactNode } from 'react'
2
- import { Popover } from '@base-ui/react/popover'
3
- import type { ColorSwatch } from '../RichTextField.js'
4
-
5
- interface PaletteProps {
6
- /** Trigger button — usually the toolbar's `textColor` / `highlight` button. */
7
- trigger: ReactNode
8
- swatches: ColorSwatch[]
9
- /** Whether to render a free-form color picker below the swatches. */
10
- custom: boolean
11
- /** Currently active color, when known. Used to show the highlight ring. */
12
- activeColor?: string | undefined
13
- /** Pick a swatch (or the custom-picker value). */
14
- onPick: (value: string) => void
15
- /** Clear the color (removes the mark). */
16
- onClear: () => void
17
- clearLabel?: string
18
- }
19
-
20
- /**
21
- * Swatch popover anchored to a toolbar button. Drives `textColor` and
22
- * `highlight` — both share the same UI shape, only the swatches and the
23
- * `onPick`/`onClear` wiring differ.
24
- *
25
- * Mounts open / closed itself; consumers don't manage the open state.
26
- */
27
- export function Palette({
28
- trigger, swatches, custom, activeColor, onPick, onClear, clearLabel = 'No color',
29
- }: PaletteProps) {
30
- const [open, setOpen] = useState(false)
31
-
32
- const close = (): void => setOpen(false)
33
- const pick = (value: string): void => { onPick(value); close() }
34
- const clear = (): void => { onClear(); close() }
35
-
36
- return (
37
- <Popover.Root open={open} onOpenChange={setOpen}>
38
- <Popover.Trigger render={trigger as React.ReactElement} />
39
- <Popover.Portal>
40
- <Popover.Positioner side="bottom" align="start" sideOffset={6} className="isolate z-50">
41
- <Popover.Popup
42
- className="rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-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"
43
- >
44
- <div className="grid grid-cols-5 gap-1">
45
- {swatches.map((s) => {
46
- const isActive = activeColor && activeColor.toLowerCase() === s.value.toLowerCase()
47
- return (
48
- <button
49
- key={s.value}
50
- type="button"
51
- title={s.label}
52
- aria-label={s.label}
53
- aria-pressed={Boolean(isActive)}
54
- onClick={() => pick(s.value)}
55
- className={`h-6 w-6 rounded border transition-transform hover:scale-110 ${
56
- isActive ? 'ring-2 ring-ring ring-offset-1' : 'border-border/60'
57
- }`}
58
- style={{ background: s.value }}
59
- />
60
- )
61
- })}
62
- </div>
63
- {custom && (
64
- <label className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
65
- <span>Custom</span>
66
- <input
67
- type="color"
68
- defaultValue={activeColor ?? '#000000'}
69
- onChange={(e) => onPick(e.target.value)}
70
- className="h-6 w-12 cursor-pointer rounded border-0 bg-transparent p-0"
71
- />
72
- </label>
73
- )}
74
- <button
75
- type="button"
76
- onClick={clear}
77
- className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
78
- >
79
- {clearLabel}
80
- </button>
81
- </Popover.Popup>
82
- </Popover.Positioner>
83
- </Popover.Portal>
84
- </Popover.Root>
85
- )
86
- }
@@ -1,129 +0,0 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
2
- import type { SlashItem } from '../extensions/SlashCommandExtension.js'
3
-
4
- /**
5
- * Mutable ref the document-level keydown listener in `TiptapEditor` reads.
6
- * `SlashMenu` installs its keyboard handler on mount, clears on unmount.
7
- */
8
- export type SlashKeyHandlerRef = { current: ((event: KeyboardEvent) => boolean) | null }
9
-
10
- interface SlashMenuProps {
11
- items: SlashItem[]
12
- command: (item: SlashItem) => void
13
- keyHandlerRef: SlashKeyHandlerRef
14
- }
15
-
16
- /**
17
- * Floating list of slash items. Mounted by the Base UI Popover in
18
- * TiptapEditor; the popover's anchor is a virtual element, so we don't need
19
- * to position the menu ourselves.
20
- *
21
- * Keys: ArrowUp / ArrowDown to move, Enter to pick. Escape is handled in
22
- * `SlashCommandExtension.onKeyDown` (closes the popup directly).
23
- */
24
- export function SlashMenu({ items, command, keyHandlerRef }: SlashMenuProps) {
25
- const [active, setActive] = useState(0)
26
- const containerRef = useRef<HTMLDivElement | null>(null)
27
-
28
- // Group items for visual organisation, then derive the flat render-order
29
- // array. `items` is in plugin order (paragraph, h1, h2, h3, …) but we
30
- // render grouped (Basic/Headings/Lists/Blocks). The active index must
31
- // track render order — otherwise ArrowDown highlights item N visually
32
- // but Enter inserts items[N] from the plugin-order array, which is a
33
- // different item.
34
- const grouped = useMemo(
35
- () => groupBy(items, (it) => it.group ?? 'Other'),
36
- [items],
37
- )
38
- const renderOrder = useMemo(
39
- () => Array.from(grouped.values()).flat(),
40
- [grouped],
41
- )
42
-
43
- // Reset selection when the filtered list changes.
44
- useEffect(() => { setActive(0) }, [renderOrder])
45
-
46
- // Keep the active item in view inside the scroll container.
47
- useEffect(() => {
48
- const el = containerRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)
49
- el?.scrollIntoView({ block: 'nearest' })
50
- }, [active])
51
-
52
- // Install the keyboard bridge for the document-level listener in
53
- // TiptapEditor.
54
- useEffect(() => {
55
- keyHandlerRef.current = (event) => {
56
- const len = renderOrder.length
57
- if (event.key === 'ArrowDown') {
58
- setActive((i) => (len === 0 ? 0 : (i + 1) % len))
59
- return true
60
- }
61
- if (event.key === 'ArrowUp') {
62
- setActive((i) => (len === 0 ? 0 : (i - 1 + len) % len))
63
- return true
64
- }
65
- if (event.key === 'Enter') {
66
- const item = renderOrder[active]
67
- if (item) command(item)
68
- return true
69
- }
70
- return false
71
- }
72
- return () => { keyHandlerRef.current = null }
73
- }, [renderOrder, active, command, keyHandlerRef])
74
-
75
- if (renderOrder.length === 0) {
76
- return (
77
- <div className="px-3 py-2 text-xs text-muted-foreground">
78
- No matches
79
- </div>
80
- )
81
- }
82
-
83
- let runningIndex = 0
84
- return (
85
- <div ref={containerRef} className="max-h-72 w-64 overflow-y-auto p-1 text-sm">
86
- {Array.from(grouped.entries()).map(([groupName, groupItems]) => (
87
- <div key={groupName}>
88
- <div className="px-2 pt-2 pb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
89
- {groupName}
90
- </div>
91
- {groupItems.map((item) => {
92
- const idx = runningIndex++
93
- const isActive = idx === active
94
- return (
95
- <button
96
- key={item.key}
97
- data-index={idx}
98
- type="button"
99
- onMouseDown={(e) => { e.preventDefault(); command(item) }}
100
- onMouseEnter={() => setActive(idx)}
101
- className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left ${
102
- isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60'
103
- }`}
104
- >
105
- {item.icon && (
106
- <span className="flex size-6 items-center justify-center rounded border bg-background text-xs">
107
- {item.icon}
108
- </span>
109
- )}
110
- <span>{item.label}</span>
111
- </button>
112
- )
113
- })}
114
- </div>
115
- ))}
116
- </div>
117
- )
118
- }
119
-
120
- function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
121
- const out = new Map<string, T[]>()
122
- for (const item of items) {
123
- const k = key(item)
124
- const list = out.get(k)
125
- if (list) list.push(item)
126
- else out.set(k, [item])
127
- }
128
- return out
129
- }
@@ -1,154 +0,0 @@
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
- }
@@ -1,112 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import React from 'react'
4
- import { render, cleanup, waitFor } from '@testing-library/react'
5
-
6
- import { TiptapEditor } from './TiptapEditor.js'
7
-
8
- /**
9
- * Behavioral coverage for the rich-text field renderer. The matching
10
- * pure-data tests under `RichTextField.test.ts` cover `toMeta()` / option
11
- * resolution; this file proves the React renderer actually mounts in our
12
- * jsdom + RTL environment and produces the FormData wiring downstream
13
- * consumers depend on.
14
- *
15
- * Scope is deliberately narrow — slash menu, floating toolbar, mention
16
- * popover, side panel, and AI suggestion bridge all need additional
17
- * fixtures (focus traps, document-level key handlers, context providers)
18
- * and are covered by the playground + Playwright e2e suite. The asserts
19
- * here are the ones that would catch a "renderer crashes at mount" or
20
- * "hidden input wire-name drift" regression cheaply.
21
- */
22
- describe('TiptapEditor (DOM)', () => {
23
- function renderEditor(opts: {
24
- name: string
25
- defaultValue?: unknown
26
- placeholder?: string
27
- }) {
28
- const { name, defaultValue = '', placeholder = 'Write…' } = opts
29
- return render(
30
- <TiptapEditor
31
- el={{ type: 'field', fieldType: 'richtext', name }}
32
- name={name}
33
- defaultValue={defaultValue}
34
- required={false}
35
- disabled={false}
36
- placeholder={placeholder}
37
- />,
38
- )
39
- }
40
-
41
- it('mounts the editor on hydration and exposes the hidden FormData input', async () => {
42
- const { container } = renderEditor({ name: 'bio' })
43
- try {
44
- // Initial render is the SSR placeholder gated on `mounted`. After
45
- // the mount-effect flips, `ClientEditor` mounts → Tiptap defers
46
- // editor construction to its own effect under `immediatelyRender:
47
- // false` → the `.ProseMirror` contenteditable lands in the DOM.
48
- await waitFor(() => {
49
- assert.ok(
50
- container.querySelector('.ProseMirror'),
51
- 'ProseMirror contenteditable mounts after hydration',
52
- )
53
- })
54
- const hidden = container.querySelector<HTMLInputElement>(
55
- 'input[type="hidden"][name="bio"]',
56
- )
57
- assert.ok(hidden, 'hidden FormData input present alongside the editor')
58
- } finally {
59
- cleanup()
60
- }
61
- })
62
-
63
- it('serializes a JSON `defaultValue` into the hidden input on first paint', async () => {
64
- // Tiptap doc shape: paragraph with one text node. The renderer's
65
- // `serializeForHidden` round-trips this verbatim under the default
66
- // `storage: 'json'` setting, so the hidden input should hold the
67
- // JSON string at the very first render (before the editor itself
68
- // has even mounted — the SSR placeholder ships the same serialized
69
- // value so submit-on-mount works).
70
- const defaultValue = {
71
- type: 'doc',
72
- content: [
73
- { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
74
- ],
75
- }
76
- const { container } = renderEditor({ name: 'body', defaultValue })
77
- try {
78
- const hidden = container.querySelector<HTMLInputElement>(
79
- 'input[type="hidden"][name="body"]',
80
- )
81
- assert.ok(hidden, 'hidden input present')
82
- const parsed = JSON.parse(hidden.value)
83
- assert.equal(parsed.type, 'doc', 'value parses to a doc')
84
- assert.equal(parsed.content[0].content[0].text, 'hello')
85
- } finally {
86
- cleanup()
87
- }
88
- })
89
-
90
- it('uses the field `name` for the hidden input wire-name', async () => {
91
- // Pilotiq forms post FormData keyed by field name; renaming the wire
92
- // input here would silently drop the field on submit. The non-default
93
- // `name` ("article_body" with an underscore) doubles as a regression
94
- // guard for any future serializer that tries to clean / normalize
95
- // names — the value the host passes in is the value posted back.
96
- const { container } = renderEditor({ name: 'article_body' })
97
- try {
98
- await waitFor(() => {
99
- assert.ok(
100
- container.querySelector('.ProseMirror'),
101
- 'editor mounted (post-hydration probe so the test isn\'t racing the SSR branch)',
102
- )
103
- })
104
- const hidden = container.querySelector<HTMLInputElement>(
105
- 'input[type="hidden"][name="article_body"]',
106
- )
107
- assert.ok(hidden, 'wire-name matches `name` prop verbatim')
108
- } finally {
109
- cleanup()
110
- }
111
- })
112
- })