@tessera-editor/react 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.
@@ -0,0 +1,192 @@
1
+ import { useContext, useEffect, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { getCommentStore, getIdentityService, listCommentRanges } from '@tessera-editor/core'
4
+ import type { CommentThread } from '@tessera-editor/core'
5
+ import { TesseraContext } from './context'
6
+ import { useTesseraPortalRoot } from './portal'
7
+
8
+ /**
9
+ * Inline comments (v1.1): comment composer on selection (⌘βŒ₯M or the πŸ’¬
10
+ * toolbar button), side panel with threads in document order, resolve /
11
+ * delete, ↑/↓ thread navigation. Data flows through the injected
12
+ * CommentStore + IdentityService.
13
+ */
14
+
15
+ interface ThreadView extends CommentThread {
16
+ from: number
17
+ }
18
+
19
+ function uid(prefix: string): string {
20
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
21
+ }
22
+
23
+ export function CommentPanel() {
24
+ const { editor, t } = useContext(TesseraContext)!
25
+ const [visible, setVisible] = useState(false)
26
+ const [threads, setThreads] = useState<ThreadView[]>([])
27
+
28
+ const refresh = async () => {
29
+ const store = getCommentStore(editor)
30
+ if (!store) {
31
+ return
32
+ }
33
+ const all = await store.list()
34
+ const ranges = listCommentRanges(editor.state)
35
+ const views: ThreadView[] = []
36
+ for (const range of ranges) {
37
+ const thread = all.find(tr => tr.id === range.threadId)
38
+ if (thread) {
39
+ views.push({ ...thread, from: range.from })
40
+ }
41
+ }
42
+ views.sort((a, b) => a.from - b.from)
43
+ setThreads(views)
44
+ }
45
+
46
+ useEffect(() => {
47
+ const open = () => {
48
+ setVisible(true)
49
+ void refresh()
50
+ }
51
+ editor.on('tessera:commentPanel', open)
52
+ editor.on('transaction', refresh)
53
+ return () => {
54
+ editor.off('tessera:commentPanel', open)
55
+ editor.off('transaction', refresh)
56
+ }
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [editor])
59
+
60
+ if (!visible) {
61
+ return null
62
+ }
63
+
64
+ const store = getCommentStore(editor)
65
+ const identity = getIdentityService(editor)
66
+ const me = identity?.getCurrentUser() ?? { id: 'anonymous', name: 'Anonymous' }
67
+
68
+ const toggleResolve = async (thread: ThreadView) => {
69
+ if (!store) {
70
+ return
71
+ }
72
+ const next = { ...thread, resolved: !thread.resolved }
73
+ delete (next as Partial<ThreadView>).from
74
+ await store.upsert(next as CommentThread)
75
+ editor.commands.setCommentResolved(thread.id, next.resolved)
76
+ await refresh()
77
+ }
78
+
79
+ const removeThread = async (thread: ThreadView) => {
80
+ if (!store) {
81
+ return
82
+ }
83
+ await store.remove(thread.id)
84
+ editor.commands.removeCommentThread(thread.id)
85
+ await refresh()
86
+ }
87
+
88
+ return (
89
+ <div className="tessera-comment-panel" data-testid="comment-panel">
90
+ <div className="tessera-ask-header">
91
+ <span>
92
+ {t('commentTitle')} Β· {threads.filter(x => !x.resolved).length}
93
+ </span>
94
+ <div className="tessera-history-actions">
95
+ <button type="button" title="↑" onClick={() => editor.commands.focusNextCommentThread()}>
96
+ ↑
97
+ </button>
98
+ <button type="button" title="↓" onClick={() => editor.commands.focusNextCommentThread()}>
99
+ ↓
100
+ </button>
101
+ <button type="button" onClick={() => setVisible(false)}>
102
+ Γ—
103
+ </button>
104
+ </div>
105
+ </div>
106
+ {!store ? <div className="tessera-ask-error">CommentStore not injected</div> : null}
107
+ <div className="tessera-ask-body">
108
+ {threads.length === 0 ? <div className="tessera-toc-empty">{t('commentEmpty')}</div> : null}
109
+ {threads.map(thread => (
110
+ <div key={thread.id} className={`tessera-thread${thread.resolved ? ' tessera-thread--resolved' : ''}`}>
111
+ <div className="tessera-thread-quote">β€œ{thread.quote}”</div>
112
+ {thread.entries.map(entry => (
113
+ <div key={entry.id} className="tessera-thread-entry">
114
+ <span className="tessera-thread-author">{entry.authorName}</span>
115
+ <span className="tessera-thread-text">{entry.text}</span>
116
+ </div>
117
+ ))}
118
+ <div className="tessera-thread-actions">
119
+ <button type="button" onClick={() => void toggleResolve(thread)}>
120
+ {thread.resolved ? t('commentReopen') : t('commentResolve')}
121
+ </button>
122
+ <button type="button" className="tessera-danger" onClick={() => void removeThread(thread)}>
123
+ {t('commentDelete')}
124
+ </button>
125
+ {thread.resolved ? <span className="tessera-thread-badge">{t('commentResolvedBadge')}</span> : null}
126
+ </div>
127
+ </div>
128
+ ))}
129
+ </div>
130
+ <div className="tessera-comment-hint">⌘βŒ₯M / πŸ’¬ Β· {me.name}</div>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ /** Selection-toolbar composer popover: quote + comment β†’ thread + mark. */
136
+ export function CommentComposer({ onClose }: { onClose: () => void }) {
137
+ const { editor, t } = useContext(TesseraContext)!
138
+ const [text, setText] = useState('')
139
+ const portalRoot = useTesseraPortalRoot(editor)
140
+ const quote = editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ')
141
+
142
+ const submit = async () => {
143
+ if (!text.trim()) {
144
+ return
145
+ }
146
+ const store = getCommentStore(editor)
147
+ if (!store) {
148
+ return
149
+ }
150
+ const identity = getIdentityService(editor)
151
+ const me = identity?.getCurrentUser() ?? { id: 'anonymous', name: 'Anonymous' }
152
+ const thread: CommentThread = {
153
+ id: uid('thread'),
154
+ quote,
155
+ resolved: false,
156
+ createdAt: Date.now(),
157
+ entries: [{ id: uid('c'), authorId: me.id, authorName: me.name, text: text.trim(), ts: Date.now() }],
158
+ }
159
+ await store.upsert(thread)
160
+ editor.commands.addCommentThread(thread.id)
161
+ onClose()
162
+ editor.emit('tessera:commentPanel', {})
163
+ }
164
+
165
+ if (!portalRoot) {
166
+ return null
167
+ }
168
+
169
+ return createPortal(
170
+ <div className="tessera-popover tessera-comment-composer" data-testid="comment-composer">
171
+ <div className="tessera-thread-quote">β€œ{quote.slice(0, 60)}{quote.length > 60 ? '…' : ''}”</div>
172
+ <textarea
173
+ autoFocus
174
+ value={text}
175
+ placeholder={t('commentPlaceholder')}
176
+ onChange={e => setText(e.target.value)}
177
+ onKeyDown={e => {
178
+ if (e.key === 'Enter' && !e.shiftKey) {
179
+ e.preventDefault()
180
+ void submit()
181
+ }
182
+ }}
183
+ />
184
+ <div className="tessera-comment-composer-actions">
185
+ <button type="button" disabled={!text.trim()} onClick={() => void submit()}>
186
+ {t('commentSend')}
187
+ </button>
188
+ </div>
189
+ </div>,
190
+ portalRoot,
191
+ )
192
+ }
@@ -0,0 +1,120 @@
1
+ import { useContext, useEffect, useState } from 'react'
2
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
3
+ import type { NodeViewProps } from '@tiptap/react'
4
+ import { EmbedBlock, TocBlock } from '@tessera-editor/core'
5
+ import { TesseraContext } from './context'
6
+
7
+ /** Sandboxed iframe embed (default: no scripts, no same-origin). */
8
+ function EmbedView({ node, updateAttributes, selected }: NodeViewProps) {
9
+ const { t } = useContext(TesseraContext)!
10
+ const [url, setUrl] = useState(typeof node.attrs.src === 'string' ? node.attrs.src : '')
11
+ const src = typeof node.attrs.src === 'string' ? node.attrs.src : ''
12
+ const height = typeof node.attrs.height === 'number' ? node.attrs.height : 360
13
+
14
+ if (!src) {
15
+ return (
16
+ <NodeViewWrapper className="tessera-embed tessera-embed-empty" data-selected={selected}>
17
+ <input
18
+ className="tessera-embed-input"
19
+ value={url}
20
+ placeholder={t('embedPlaceholder')}
21
+ onChange={e => setUrl(e.target.value)}
22
+ onKeyDown={e => {
23
+ if (e.key === 'Enter') {
24
+ apply()
25
+ }
26
+ }}
27
+ contentEditable={false}
28
+ />
29
+ <button type="button" className="tessera-embed-apply" onClick={apply} contentEditable={false}>
30
+ {t('embedApply')}
31
+ </button>
32
+ </NodeViewWrapper>
33
+ )
34
+ }
35
+
36
+ function apply() {
37
+ const next = url.trim()
38
+ if (/^https?:\/\/.+/.test(next)) {
39
+ updateAttributes({ src: next })
40
+ }
41
+ }
42
+
43
+ return (
44
+ <NodeViewWrapper className="tessera-embed" data-selected={selected}>
45
+ <div className="tessera-embed-frame" style={{ height: `${height}px` }} contentEditable={false}>
46
+ <iframe
47
+ src={src}
48
+ title={node.attrs.title ?? src}
49
+ sandbox=""
50
+ referrerPolicy="no-referrer"
51
+ loading="lazy"
52
+ />
53
+ </div>
54
+ <a className="tessera-embed-link" href={src} target="_blank" rel="noreferrer" contentEditable={false}>
55
+ {t('embedOpen')} β†—
56
+ </a>
57
+ </NodeViewWrapper>
58
+ )
59
+ }
60
+
61
+ /** Table of contents derived live from document headings. */
62
+ function TocView({ editor }: NodeViewProps) {
63
+ const { t } = useContext(TesseraContext)!
64
+ const [items, setItems] = useState<{ id: string; level: number; text: string }[]>([])
65
+
66
+ useEffect(() => {
67
+ const scan = () => {
68
+ const found: { id: string; level: number; text: string }[] = []
69
+ editor.state.doc.forEach((node, _offset, index) => {
70
+ void index
71
+ if (node.type.name === 'heading' && typeof node.attrs.id === 'string') {
72
+ found.push({ id: node.attrs.id, level: Number(node.attrs.level), text: node.textContent })
73
+ }
74
+ })
75
+ setItems(found)
76
+ }
77
+ scan()
78
+ editor.on('transaction', scan)
79
+ return () => {
80
+ editor.off('transaction', scan)
81
+ }
82
+ }, [editor])
83
+
84
+ return (
85
+ <NodeViewWrapper className="tessera-toc">
86
+ <div className="tessera-toc-title">{t('itemToc')}</div>
87
+ {items.length === 0 ? (
88
+ <div className="tessera-toc-empty">{t('tocEmpty')}</div>
89
+ ) : (
90
+ <div className="tessera-toc-list" contentEditable={false}>
91
+ {items.map(item => (
92
+ <button
93
+ key={item.id}
94
+ type="button"
95
+ className="tessera-toc-item"
96
+ data-level={item.level}
97
+ onClick={() => {
98
+ document.querySelector(`[data-id="${item.id}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
99
+ }}
100
+ >
101
+ {item.text}
102
+ </button>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </NodeViewWrapper>
107
+ )
108
+ }
109
+
110
+ export const EmbedBlockView = EmbedBlock.extend({
111
+ addNodeView() {
112
+ return ReactNodeViewRenderer(EmbedView)
113
+ },
114
+ })
115
+
116
+ export const TocBlockView = TocBlock.extend({
117
+ addNodeView() {
118
+ return ReactNodeViewRenderer(TocView)
119
+ },
120
+ })
@@ -0,0 +1,134 @@
1
+ import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import type { Editor } from '@tiptap/core'
4
+ import { ReactRenderer } from '@tiptap/react'
5
+ import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'
6
+ import type { EmojiItem } from '@tessera-editor/core'
7
+
8
+ /**
9
+ * Emoji picker UI (acceptance Β§2, v1.1): `:` opens a grid of emoji filtered
10
+ * by name/keywords; arrows + Return insert, click inserts, Esc closes.
11
+ * Same mounting contract as the slash menu (wrapper appended into
12
+ * .tessera-root so theme tokens resolve).
13
+ */
14
+
15
+ export const EmojiMenuView = forwardRef<
16
+ { onKeyDown: (props: SuggestionKeyDownProps) => boolean },
17
+ SuggestionProps<EmojiItem>
18
+ >(function EmojiMenuView({ items, command, clientRect }, ref) {
19
+ const [selectedIndex, setSelectedIndex] = useState(0)
20
+ const [rect, setRect] = useState<DOMRect | null>(null)
21
+ const gridRef = useRef<HTMLDivElement>(null)
22
+
23
+ useEffect(() => {
24
+ setRect(clientRect?.() ?? null)
25
+ }, [items, clientRect])
26
+
27
+ useEffect(() => {
28
+ setSelectedIndex(0)
29
+ }, [items])
30
+
31
+ useEffect(() => {
32
+ gridRef.current
33
+ ?.querySelectorAll<HTMLElement>('.tessera-emoji-item')
34
+ [selectedIndex]?.scrollIntoView({ block: 'nearest' })
35
+ }, [selectedIndex])
36
+
37
+ useImperativeHandle(ref, () => ({
38
+ onKeyDown: ({ event }: SuggestionKeyDownProps) => {
39
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
40
+ setSelectedIndex(i => (i + 1) % Math.max(items.length, 1))
41
+ return true
42
+ }
43
+ if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
44
+ setSelectedIndex(i => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1))
45
+ return true
46
+ }
47
+ if (event.key === 'Enter') {
48
+ const item = items[selectedIndex]
49
+ if (item) {
50
+ command(item)
51
+ }
52
+ return true
53
+ }
54
+ return false
55
+ },
56
+ }))
57
+
58
+ if (items.length === 0) {
59
+ return null
60
+ }
61
+
62
+ const menuMax = 264
63
+ const style: CSSProperties = rect
64
+ ? (() => {
65
+ const fitsBelow = rect.bottom + 8 + menuMax <= window.innerHeight
66
+ const fitsAbove = rect.top - 8 - menuMax >= 0
67
+ const flip = !fitsBelow && fitsAbove
68
+ return {
69
+ left: `${Math.min(rect.left, window.innerWidth - 300)}px`,
70
+ top: flip ? undefined : `${Math.min(rect.bottom + 8, window.innerHeight - menuMax)}px`,
71
+ bottom: flip ? `${window.innerHeight - rect.top + 8}px` : undefined,
72
+ }
73
+ })()
74
+ : { left: -9999, top: -9999 }
75
+
76
+ return (
77
+ <div ref={gridRef} className="tessera-emoji-menu" style={style} data-testid="emoji-menu">
78
+ {items.map((item, i) => (
79
+ <button
80
+ key={item.name}
81
+ type="button"
82
+ className="tessera-emoji-item"
83
+ title={`:${item.name}:`}
84
+ data-selected={i === selectedIndex}
85
+ onMouseEnter={() => setSelectedIndex(i)}
86
+ onClick={() => command(item)}
87
+ >
88
+ {item.char}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ )
93
+ })
94
+
95
+ /** Suggestion render factory: mounts the grid into the component root. */
96
+ export function createEmojiRenderer() {
97
+ return () => {
98
+ let renderer: ReactRenderer | null = null
99
+ let wrapper: HTMLDivElement | null = null
100
+
101
+ return {
102
+ onStart: (props: SuggestionProps<EmojiItem>) => {
103
+ renderer = new ReactRenderer(EmojiMenuView, {
104
+ props: { ...props },
105
+ editor: props.editor as Editor,
106
+ })
107
+ wrapper = document.createElement('div')
108
+ wrapper.className = 'tessera-emoji-wrapper'
109
+ wrapper.appendChild(renderer.element)
110
+ const host = props.editor.view.dom.closest('.tessera-root') ?? document.body
111
+ host.appendChild(wrapper)
112
+ },
113
+ onUpdate: (props: SuggestionProps<EmojiItem>) => {
114
+ renderer?.updateProps({ ...props })
115
+ },
116
+ onKeyDown: (props: SuggestionKeyDownProps) => {
117
+ if (props.event.key === 'Escape') {
118
+ wrapper?.remove()
119
+ renderer?.destroy()
120
+ renderer = null
121
+ wrapper = null
122
+ return true
123
+ }
124
+ return (renderer?.ref as { onKeyDown?: (p: SuggestionKeyDownProps) => boolean } | null)?.onKeyDown?.(props) ?? false
125
+ },
126
+ onExit: () => {
127
+ wrapper?.remove()
128
+ renderer?.destroy()
129
+ renderer = null
130
+ wrapper = null
131
+ },
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,215 @@
1
+ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { defaultSlashItems } from '@tessera-editor/core'
5
+ import type { SlashMenuItem, TesseraTranslator } from '@tessera-editor/core'
6
+ import { aiSlashItems } from '@tessera-editor/ai'
7
+ import type { Editor } from '@tiptap/react'
8
+ import { TesseraContext } from './context'
9
+ import { useTesseraPortalRoot } from './portal'
10
+
11
+ /**
12
+ * Slite-style empty-line toolbar (acceptance Β§5): appears when the caret sits
13
+ * in an EMPTY paragraph. Quick row + "β€Ί" expands the full block list (same
14
+ * items as the slash menu). NOT a Notion-style floating "+" button.
15
+ *
16
+ * IME guard: while an input method composition is active we neither show,
17
+ * hide, nor move the toolbar (M0 spike finding).
18
+ */
19
+
20
+ function buildItems(editor: Editor, t: TesseraTranslator, hasAi: boolean): SlashMenuItem[] {
21
+ const base = defaultSlashItems(t)
22
+ if (!hasAi) {
23
+ return base
24
+ }
25
+ // AI items need a runtime; context provides it β€” reconstructed here via
26
+ // context injection from the parent (see useItems below).
27
+ return base
28
+ }
29
+
30
+ export function EmptyLineToolbar() {
31
+ const { editor, t, ai } = useContext(TesseraContext)!
32
+ const [style, setStyle] = useState<CSSProperties | null>(null)
33
+ const [expanded, setExpanded] = useState(false)
34
+ const [panelPlacement, setPanelPlacement] = useState<'bottom' | 'top'>('bottom')
35
+ const [panelMaxHeight, setPanelMaxHeight] = useState<number | undefined>(undefined)
36
+ const composingRef = useRef(false)
37
+ const rootRef = useRef<HTMLDivElement>(null)
38
+ const panelRef = useRef<HTMLDivElement>(null)
39
+ const portalRoot = useTesseraPortalRoot(editor)
40
+
41
+ const hideAll = useCallback(() => {
42
+ setStyle(null)
43
+ setExpanded(false)
44
+ editor.view.dom.removeAttribute('data-toolbar-line')
45
+ }, [editor])
46
+
47
+ const reposition = useCallback(() => {
48
+ const dom = editor.view.dom
49
+ if (composingRef.current || !editor.isFocused) {
50
+ setStyle(null)
51
+ dom.removeAttribute('data-toolbar-line')
52
+ return
53
+ }
54
+ const { $from, empty } = editor.state.selection
55
+ const isEmptyParagraph =
56
+ empty && $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0
57
+ if (!isEmptyParagraph) {
58
+ setStyle(null)
59
+ setExpanded(false)
60
+ dom.removeAttribute('data-toolbar-line')
61
+ return
62
+ }
63
+ const coords = editor.view.coordsAtPos($from.pos)
64
+ // the toolbar is glued to the anchor line; once that line leaves the
65
+ // visible part of the editor (inner scroll or page scroll) it must go
66
+ const viewRect = dom.getBoundingClientRect()
67
+ if (
68
+ coords.top < Math.max(viewRect.top, 0) - 2 ||
69
+ coords.top > Math.min(viewRect.bottom, window.innerHeight) + 2
70
+ ) {
71
+ hideAll()
72
+ return
73
+ }
74
+ setStyle({
75
+ position: 'fixed',
76
+ // Float just ABOVE the empty line so the caret stays visible; the
77
+ // toolbar bottom sits a couple of pixels over the line's top edge.
78
+ top: `${Math.max(2, coords.top - 38)}px`,
79
+ left: `${coords.left}px`,
80
+ })
81
+ // hide the placeholder text while the toolbar owns this line
82
+ dom.setAttribute('data-toolbar-line', 'true')
83
+ }, [editor, hideAll])
84
+
85
+ useEffect(() => {
86
+ const hide = () => {
87
+ setStyle(null)
88
+ setExpanded(false)
89
+ }
90
+ const onCompositionStart = () => {
91
+ composingRef.current = true
92
+ setStyle(null)
93
+ }
94
+ const onCompositionEnd = () => {
95
+ composingRef.current = false
96
+ requestAnimationFrame(reposition)
97
+ }
98
+ // scroll inside our own panel (its scrollbar) must not close the menu;
99
+ // any other scroll keeps the toolbar glued to the anchor line via
100
+ // reposition (which hides it once the line leaves the viewport)
101
+ const onScroll = (event: Event) => {
102
+ const node = rootRef.current
103
+ if (node && event.target instanceof Node && node.contains(event.target)) {
104
+ return
105
+ }
106
+ requestAnimationFrame(reposition)
107
+ }
108
+
109
+ const dom = editor.view.dom
110
+ editor.on('selectionUpdate', reposition)
111
+ editor.on('focus', reposition)
112
+ editor.on('blur', hide)
113
+ dom.addEventListener('compositionstart', onCompositionStart)
114
+ dom.addEventListener('compositionend', onCompositionEnd)
115
+ window.addEventListener('scroll', onScroll, true)
116
+ return () => {
117
+ editor.off('selectionUpdate', reposition)
118
+ editor.off('focus', reposition)
119
+ editor.off('blur', hide)
120
+ dom.removeEventListener('compositionstart', onCompositionStart)
121
+ dom.removeEventListener('compositionend', onCompositionEnd)
122
+ window.removeEventListener('scroll', onScroll, true)
123
+ }
124
+ }, [editor, reposition])
125
+
126
+ // Slite-style placement: near the bottom of the viewport the panel opens
127
+ // upward and is clamped to the available space instead of spilling past
128
+ // the content edge
129
+ useLayoutEffect(() => {
130
+ const toolbar = rootRef.current
131
+ if (!expanded || !toolbar) {
132
+ setPanelPlacement('bottom')
133
+ setPanelMaxHeight(undefined)
134
+ return
135
+ }
136
+ const rect = toolbar.getBoundingClientRect()
137
+ const spaceBelow = window.innerHeight - rect.bottom
138
+ const spaceAbove = rect.top
139
+ const natural = panelRef.current?.offsetHeight ?? 320
140
+ const flip = spaceBelow < natural + 12 && spaceAbove > spaceBelow
141
+ setPanelPlacement(flip ? 'top' : 'bottom')
142
+ const avail = (flip ? spaceAbove : spaceBelow) - 12
143
+ setPanelMaxHeight(avail > 120 && avail < natural ? avail : undefined)
144
+ }, [expanded, style])
145
+
146
+ if (!style || !portalRoot) {
147
+ return null
148
+ }
149
+
150
+ const items = buildItems(editor, t, !!ai)
151
+
152
+ const runItem = (item: SlashMenuItem) => {
153
+ const { $from } = editor.state.selection
154
+ item.command({ editor, range: { from: $from.pos, to: $from.pos } })
155
+ setExpanded(false)
156
+ }
157
+
158
+ return createPortal(
159
+ <div
160
+ ref={rootRef}
161
+ className="tessera-emptyline-toolbar"
162
+ style={style}
163
+ data-testid="empty-line-toolbar"
164
+ onMouseDown={e => e.preventDefault()}
165
+ >
166
+ <button type="button" className="tessera-tb-btn" title={t('itemText')} onClick={() => runItem(items.find(i => i.id === 'text')!)}>
167
+ ΒΆ
168
+ </button>
169
+ <button type="button" className="tessera-tb-btn" title={t('itemH2')} onClick={() => runItem(items.find(i => i.id === 'h2')!)}>
170
+ H2
171
+ </button>
172
+ <button type="button" className="tessera-tb-btn" title={t('itemBullet')} onClick={() => runItem(items.find(i => i.id === 'bulletList')!)}>
173
+ β€’
174
+ </button>
175
+ <button type="button" className="tessera-tb-btn" title={t('itemTask')} onClick={() => runItem(items.find(i => i.id === 'taskList')!)}>
176
+ β˜‘
177
+ </button>
178
+ <button type="button" className="tessera-tb-btn" title={t('itemQuote')} onClick={() => runItem(items.find(i => i.id === 'quote')!)}>
179
+ ❝
180
+ </button>
181
+ <button type="button" className="tessera-tb-btn" title={t('itemCode')} onClick={() => runItem(items.find(i => i.id === 'codeBlock')!)}>
182
+ {'</>'}
183
+ </button>
184
+ <button
185
+ type="button"
186
+ className="tessera-tb-btn tessera-emptyline-expand"
187
+ title={t('emptyLineExpand')}
188
+ data-expanded={expanded}
189
+ onClick={() => setExpanded(v => !v)}
190
+ >
191
+ β€Ί
192
+ </button>
193
+
194
+ {expanded ? (
195
+ <div
196
+ ref={panelRef}
197
+ className="tessera-emptyline-panel"
198
+ data-placement={panelPlacement}
199
+ style={panelMaxHeight ? { maxHeight: panelMaxHeight } : undefined}
200
+ data-testid="empty-line-panel"
201
+ >
202
+ {items.map(item => (
203
+ <button key={item.id} type="button" className="tessera-slash-item" onClick={() => runItem(item)}>
204
+ <span className="tessera-slash-item-title">{item.title}</span>
205
+ <span className="tessera-slash-item-desc">{item.description}</span>
206
+ </button>
207
+ ))}
208
+ </div>
209
+ ) : null}
210
+ </div>,
211
+ portalRoot,
212
+ )
213
+ }
214
+
215
+ export { aiSlashItems }