@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +2006 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
- package/src/BlockMenu.tsx +131 -0
- package/src/CommentPanel.tsx +192 -0
- package/src/EmbedTocViews.tsx +120 -0
- package/src/EmojiMenu.tsx +134 -0
- package/src/EmptyLineToolbar.tsx +215 -0
- package/src/HistoryPanel.tsx +166 -0
- package/src/ImageNodeView.tsx +120 -0
- package/src/Panels.tsx +205 -0
- package/src/SelectionToolbar.tsx +367 -0
- package/src/SlashMenu.tsx +165 -0
- package/src/TableNodeViews.tsx +276 -0
- package/src/Tessera.tsx +315 -0
- package/src/__tests__/tessera.test.tsx +84 -0
- package/src/context.ts +21 -0
- package/src/index.ts +14 -0
- package/src/portal.ts +36 -0
|
@@ -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 }
|