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