@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,367 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { docToMarkdown } from '@tessera-editor/core'
|
|
5
|
+
import { IMPROVE_PRESETS, improveSelection } from '@tessera-editor/ai'
|
|
6
|
+
import type { SuggestionSession } from '@tessera-editor/ai'
|
|
7
|
+
import { TesseraContext } from './context'
|
|
8
|
+
import { useTesseraPortalRoot } from './portal'
|
|
9
|
+
import { CommentComposer } from './CommentPanel'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Selection toolbar (acceptance §4): 划词浮现。按钮序:Improve(AI) / B / I /
|
|
13
|
+
* U / S / 行内代码 / 颜色 / 高亮(5色) / 链接 / 转折叠块 / More(Copy as Markdown)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TEXT_COLORS = ['#262629', '#d9414f', '#e8912d', '#31a56f', '#2f9ea8', '#4f6df5', '#7a4fd8', '#98a0ab']
|
|
17
|
+
const HIGHLIGHT_COLORS = ['#fff2a8', '#d6eaff', '#d3f5df', '#fdd9e7', '#e6dcff']
|
|
18
|
+
|
|
19
|
+
type PopoverKind = null | 'color' | 'highlight' | 'link' | 'improve' | 'more' | 'comment'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Popover container that flips above the toolbar when there is not enough
|
|
23
|
+
* room below (Slite-style placement). Measured in a layout effect so the
|
|
24
|
+
* flip happens before the first paint.
|
|
25
|
+
*/
|
|
26
|
+
function FlipPopover({
|
|
27
|
+
posKey,
|
|
28
|
+
className = '',
|
|
29
|
+
testId,
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
posKey: unknown
|
|
33
|
+
className?: string
|
|
34
|
+
testId?: string
|
|
35
|
+
children: ReactNode
|
|
36
|
+
}) {
|
|
37
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
38
|
+
const [placement, setPlacement] = useState<'bottom' | 'top'>('bottom')
|
|
39
|
+
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined)
|
|
40
|
+
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const pop = ref.current
|
|
43
|
+
const toolbar = pop?.parentElement
|
|
44
|
+
if (!pop || !toolbar) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const rect = toolbar.getBoundingClientRect()
|
|
48
|
+
const spaceBelow = window.innerHeight - rect.bottom
|
|
49
|
+
const spaceAbove = rect.top
|
|
50
|
+
const natural = pop.offsetHeight
|
|
51
|
+
const flip = spaceBelow < natural + 12 && spaceAbove > spaceBelow
|
|
52
|
+
setPlacement(flip ? 'top' : 'bottom')
|
|
53
|
+
const avail = (flip ? spaceAbove : spaceBelow) - 12
|
|
54
|
+
setMaxHeight(avail > 100 && avail < natural ? avail : undefined)
|
|
55
|
+
}, [posKey])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={ref}
|
|
60
|
+
className={`tessera-popover${className ? ` ${className}` : ''}`}
|
|
61
|
+
data-placement={placement}
|
|
62
|
+
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
|
|
63
|
+
data-testid={testId}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SelectionToolbar({ onSession }: { onSession: (session: SuggestionSession | null) => void }) {
|
|
71
|
+
const { editor, t, ai } = useContext(TesseraContext)!
|
|
72
|
+
const [style, setStyle] = useState<CSSProperties | null>(null)
|
|
73
|
+
const [popover, setPopover] = useState<PopoverKind>(null)
|
|
74
|
+
const [linkValue, setLinkValue] = useState('')
|
|
75
|
+
const composingRef = useRef(false)
|
|
76
|
+
const portalRoot = useTesseraPortalRoot(editor)
|
|
77
|
+
|
|
78
|
+
const reposition = useCallback(() => {
|
|
79
|
+
if (composingRef.current || !editor.isFocused) {
|
|
80
|
+
setStyle(null)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const { from, to, empty } = editor.state.selection
|
|
84
|
+
if (empty) {
|
|
85
|
+
setStyle(null)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!editor.state.doc.textBetween(from, to, ' ').trim()) {
|
|
89
|
+
setStyle(null)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const start = editor.view.coordsAtPos(from)
|
|
93
|
+
const end = editor.view.coordsAtPos(to)
|
|
94
|
+
const left = (Math.min(start.left, end.left) + Math.min(Math.max(start.left, end.left), window.innerWidth - 20)) / 2
|
|
95
|
+
const top = Math.max(8, start.top - 48)
|
|
96
|
+
setStyle({
|
|
97
|
+
position: 'fixed',
|
|
98
|
+
top: `${top}px`,
|
|
99
|
+
left: `${Math.max(8, Math.min(left, window.innerWidth - 380))}px`,
|
|
100
|
+
})
|
|
101
|
+
}, [editor])
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const hide = () => {
|
|
105
|
+
setStyle(null)
|
|
106
|
+
setPopover(null)
|
|
107
|
+
}
|
|
108
|
+
const onSelectionUpdate = () => {
|
|
109
|
+
if (!editor.state.selection.empty) {
|
|
110
|
+
const existing = editor.getAttributes('link').href
|
|
111
|
+
setLinkValue(typeof existing === 'string' ? existing : '')
|
|
112
|
+
} else {
|
|
113
|
+
setPopover(null)
|
|
114
|
+
}
|
|
115
|
+
reposition()
|
|
116
|
+
}
|
|
117
|
+
const onCompositionStart = () => {
|
|
118
|
+
composingRef.current = true
|
|
119
|
+
setStyle(null)
|
|
120
|
+
setPopover(null)
|
|
121
|
+
}
|
|
122
|
+
const onCompositionEnd = () => {
|
|
123
|
+
composingRef.current = false
|
|
124
|
+
requestAnimationFrame(reposition)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const dom = editor.view.dom
|
|
128
|
+
editor.on('selectionUpdate', onSelectionUpdate)
|
|
129
|
+
editor.on('focus', reposition)
|
|
130
|
+
editor.on('blur', hide)
|
|
131
|
+
dom.addEventListener('compositionstart', onCompositionStart)
|
|
132
|
+
dom.addEventListener('compositionend', onCompositionEnd)
|
|
133
|
+
window.addEventListener('scroll', hide, true)
|
|
134
|
+
return () => {
|
|
135
|
+
editor.off('selectionUpdate', onSelectionUpdate)
|
|
136
|
+
editor.off('focus', reposition)
|
|
137
|
+
editor.off('blur', hide)
|
|
138
|
+
dom.removeEventListener('compositionstart', onCompositionStart)
|
|
139
|
+
dom.removeEventListener('compositionend', onCompositionEnd)
|
|
140
|
+
window.removeEventListener('scroll', hide, true)
|
|
141
|
+
}
|
|
142
|
+
}, [editor, reposition])
|
|
143
|
+
|
|
144
|
+
const chain = () => editor.chain().focus()
|
|
145
|
+
|
|
146
|
+
if (!style || !portalRoot) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const currentColor = (editor.getAttributes('textStyle').color as string | undefined) ?? null
|
|
151
|
+
|
|
152
|
+
return createPortal(
|
|
153
|
+
<div
|
|
154
|
+
className="tessera-selection-toolbar"
|
|
155
|
+
style={style}
|
|
156
|
+
data-testid="selection-toolbar"
|
|
157
|
+
onMouseDown={e => e.preventDefault()}
|
|
158
|
+
>
|
|
159
|
+
{ai ? (
|
|
160
|
+
<TbBtn title={t('tooltipImprove')} className="tessera-tb-ai" onClick={() => setPopover(p => (p === 'improve' ? null : 'improve'))}>
|
|
161
|
+
✨
|
|
162
|
+
</TbBtn>
|
|
163
|
+
) : null}
|
|
164
|
+
<TbBtn title={t('tooltipBold')} active={editor.isActive('bold')} onClick={() => chain().toggleBold().run()}>
|
|
165
|
+
<b>B</b>
|
|
166
|
+
</TbBtn>
|
|
167
|
+
<TbBtn title={t('tooltipItalic')} active={editor.isActive('italic')} onClick={() => chain().toggleItalic().run()}>
|
|
168
|
+
<i>I</i>
|
|
169
|
+
</TbBtn>
|
|
170
|
+
<TbBtn title={t('tooltipUnderline')} active={editor.isActive('underline')} onClick={() => chain().toggleUnderline().run()}>
|
|
171
|
+
<u>U</u>
|
|
172
|
+
</TbBtn>
|
|
173
|
+
<TbBtn title={t('tooltipStrike')} active={editor.isActive('strike')} onClick={() => chain().toggleStrike().run()}>
|
|
174
|
+
<s>S</s>
|
|
175
|
+
</TbBtn>
|
|
176
|
+
<TbBtn title={t('tooltipCode')} active={editor.isActive('code')} onClick={() => chain().toggleCode().run()}>
|
|
177
|
+
{'</>'}
|
|
178
|
+
</TbBtn>
|
|
179
|
+
<TbBtn title={t('tooltipColor')} active={!!currentColor} onClick={() => setPopover(p => (p === 'color' ? null : 'color'))}>
|
|
180
|
+
<span className="tessera-tb-colorchip" style={{ background: currentColor ?? '#262629' }} />
|
|
181
|
+
</TbBtn>
|
|
182
|
+
<TbBtn title={t('tooltipHighlight')} active={editor.isActive('highlight')} onClick={() => setPopover(p => (p === 'highlight' ? null : 'highlight'))}>
|
|
183
|
+
<span className="tessera-tb-colorchip" style={{ background: '#fff2a8' }} />
|
|
184
|
+
</TbBtn>
|
|
185
|
+
<TbBtn title={t('tooltipLink')} active={editor.isActive('link')} onClick={() => setPopover(p => (p === 'link' ? null : 'link'))}>
|
|
186
|
+
🔗
|
|
187
|
+
</TbBtn>
|
|
188
|
+
<TbBtn
|
|
189
|
+
title={t('tooltipCommentV11')}
|
|
190
|
+
active={editor.isActive('comment')}
|
|
191
|
+
onClick={() => setPopover(p => (p === 'comment' ? null : 'comment'))}
|
|
192
|
+
>
|
|
193
|
+
💬
|
|
194
|
+
</TbBtn>
|
|
195
|
+
<TbBtn title={t('tooltipTurnCollapsible')} onClick={() => chain().insertCollapsible().run()}>
|
|
196
|
+
▸
|
|
197
|
+
</TbBtn>
|
|
198
|
+
<TbBtn title={t('tooltipMore')} onClick={() => setPopover(p => (p === 'more' ? null : 'more'))}>
|
|
199
|
+
⋯
|
|
200
|
+
</TbBtn>
|
|
201
|
+
|
|
202
|
+
{popover === 'color' ? (
|
|
203
|
+
<FlipPopover posKey={style}>
|
|
204
|
+
<button type="button" className="tessera-color-swatch tessera-color-none" title={t('colorDefault')} onClick={() => chain().unsetColor().run()} />
|
|
205
|
+
{TEXT_COLORS.map(color => (
|
|
206
|
+
<button key={color} type="button" className="tessera-color-swatch" style={{ background: color }} onClick={() => chain().setColor(color).run()} />
|
|
207
|
+
))}
|
|
208
|
+
</FlipPopover>
|
|
209
|
+
) : null}
|
|
210
|
+
|
|
211
|
+
{popover === 'highlight' ? (
|
|
212
|
+
<FlipPopover posKey={style}>
|
|
213
|
+
<button type="button" className="tessera-color-swatch tessera-color-none" title={t('highlightNone')} onClick={() => chain().unsetHighlight().run()} />
|
|
214
|
+
{HIGHLIGHT_COLORS.map(color => (
|
|
215
|
+
<button key={color} type="button" className="tessera-color-swatch" style={{ background: color }} onClick={() => chain().toggleHighlight({ color }).run()} />
|
|
216
|
+
))}
|
|
217
|
+
</FlipPopover>
|
|
218
|
+
) : null}
|
|
219
|
+
|
|
220
|
+
{popover === 'link' ? (
|
|
221
|
+
<FlipPopover posKey={style} className="tessera-link-popover">
|
|
222
|
+
<input
|
|
223
|
+
autoFocus
|
|
224
|
+
value={linkValue}
|
|
225
|
+
placeholder={t('linkPlaceholder')}
|
|
226
|
+
onChange={e => setLinkValue(e.target.value)}
|
|
227
|
+
onKeyDown={e => {
|
|
228
|
+
if (e.key === 'Enter' && linkValue.trim()) {
|
|
229
|
+
chain().toggleLink({ href: linkValue.trim() }).run()
|
|
230
|
+
setPopover(null)
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={() => {
|
|
237
|
+
if (linkValue.trim()) {
|
|
238
|
+
chain().toggleLink({ href: linkValue.trim() }).run()
|
|
239
|
+
}
|
|
240
|
+
setPopover(null)
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
{t('linkApply')}
|
|
244
|
+
</button>
|
|
245
|
+
{editor.isActive('link') ? (
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
className="tessera-danger"
|
|
249
|
+
onClick={() => {
|
|
250
|
+
chain().unsetLink().run()
|
|
251
|
+
setPopover(null)
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{t('linkRemove')}
|
|
255
|
+
</button>
|
|
256
|
+
) : null}
|
|
257
|
+
</FlipPopover>
|
|
258
|
+
) : null}
|
|
259
|
+
|
|
260
|
+
{popover === 'more' ? (
|
|
261
|
+
<FlipPopover posKey={style} className="tessera-popover-menu">
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={async () => {
|
|
265
|
+
const { to } = editor.state.selection
|
|
266
|
+
const start = editor.state.selection.$from.before(1)
|
|
267
|
+
const sliced = editor.state.doc.cut(start, to)
|
|
268
|
+
await navigator.clipboard.writeText(docToMarkdown(sliced, editor.state.schema))
|
|
269
|
+
setPopover(null)
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{t('tooltipCopyMarkdown')}
|
|
273
|
+
</button>
|
|
274
|
+
</FlipPopover>
|
|
275
|
+
) : null}
|
|
276
|
+
|
|
277
|
+
{popover === 'improve' ? <ImprovePopover posKey={style} onSession={onSession} onClose={() => setPopover(null)} /> : null}
|
|
278
|
+
{popover === 'comment' ? <CommentComposer onClose={() => setPopover(null)} /> : null}
|
|
279
|
+
</div>,
|
|
280
|
+
portalRoot,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function TbBtn({
|
|
285
|
+
title,
|
|
286
|
+
active,
|
|
287
|
+
className,
|
|
288
|
+
onClick,
|
|
289
|
+
children,
|
|
290
|
+
}: {
|
|
291
|
+
title: string
|
|
292
|
+
active?: boolean
|
|
293
|
+
className?: string
|
|
294
|
+
onClick: () => void
|
|
295
|
+
children: ReactNode
|
|
296
|
+
}) {
|
|
297
|
+
return (
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
title={title}
|
|
301
|
+
aria-label={title}
|
|
302
|
+
className={`tessera-tb-btn${className ? ` ${className}` : ''}`}
|
|
303
|
+
data-active={active ?? false}
|
|
304
|
+
onClick={onClick}
|
|
305
|
+
>
|
|
306
|
+
{children}
|
|
307
|
+
</button>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ImprovePopover({
|
|
312
|
+
posKey,
|
|
313
|
+
onSession,
|
|
314
|
+
onClose,
|
|
315
|
+
}: {
|
|
316
|
+
posKey: unknown
|
|
317
|
+
onSession: (s: SuggestionSession | null) => void
|
|
318
|
+
onClose: () => void
|
|
319
|
+
}) {
|
|
320
|
+
const { editor, t, locale, ai } = useContext(TesseraContext)!
|
|
321
|
+
const [instruction, setInstruction] = useState('')
|
|
322
|
+
const [busy, setBusy] = useState(false)
|
|
323
|
+
const [error, setError] = useState<string | null>(null)
|
|
324
|
+
|
|
325
|
+
const run = async (presetInstruction?: string) => {
|
|
326
|
+
if (!ai) {
|
|
327
|
+
setError(t('aiRuntimeMissing'))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
onClose()
|
|
331
|
+
setBusy(true)
|
|
332
|
+
try {
|
|
333
|
+
const session = await improveSelection(editor, ai.runtime, { instruction: presetInstruction })
|
|
334
|
+
onSession(session)
|
|
335
|
+
} catch (err) {
|
|
336
|
+
setError(String(err))
|
|
337
|
+
} finally {
|
|
338
|
+
setBusy(false)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<FlipPopover posKey={posKey} className="tessera-improve-popover" testId="improve-popover">
|
|
344
|
+
{IMPROVE_PRESETS.map(preset => (
|
|
345
|
+
<button key={preset.id} type="button" disabled={busy} onClick={() => run(preset.instruction)}>
|
|
346
|
+
{locale === 'zh-CN' ? preset.labelZh : preset.labelEn}
|
|
347
|
+
</button>
|
|
348
|
+
))}
|
|
349
|
+
<div className="tessera-improve-custom">
|
|
350
|
+
<input
|
|
351
|
+
value={instruction}
|
|
352
|
+
placeholder={t('aiImprovePrompt')}
|
|
353
|
+
onChange={e => setInstruction(e.target.value)}
|
|
354
|
+
onKeyDown={e => {
|
|
355
|
+
if (e.key === 'Enter' && instruction.trim()) {
|
|
356
|
+
run(instruction.trim())
|
|
357
|
+
}
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
<button type="button" disabled={busy || !instruction.trim()} onClick={() => run(instruction.trim())}>
|
|
361
|
+
{t('aiImproveRun')}
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
{error ? <div className="tessera-popover-error">{error}</div> : null}
|
|
365
|
+
</FlipPopover>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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 { SlashMenuItem, TesseraTranslator } from '@tessera-editor/core'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Slash menu UI (acceptance §1): "/" 唤起、输入过滤(core 层完成)、
|
|
10
|
+
* 上下键导航、Return 插入、右侧显示快捷键、分组展示。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SlashMenuViewProps extends SuggestionProps<SlashMenuItem> {
|
|
14
|
+
t: TesseraTranslator
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SlashMenuView = forwardRef<{ onKeyDown: (props: SuggestionKeyDownProps) => boolean }, SlashMenuViewProps>(
|
|
18
|
+
function SlashMenuView({ items, command, clientRect, t }, ref) {
|
|
19
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
20
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
const [rect, setRect] = useState<DOMRect | null>(null)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setRect(clientRect?.() ?? null)
|
|
25
|
+
}, [items, clientRect])
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setSelectedIndex(0)
|
|
29
|
+
}, [items])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
listRef.current?.querySelector<HTMLElement>('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
|
33
|
+
}, [selectedIndex])
|
|
34
|
+
|
|
35
|
+
useImperativeHandle(ref, () => ({
|
|
36
|
+
onKeyDown: ({ event }) => {
|
|
37
|
+
if (event.key === 'ArrowDown') {
|
|
38
|
+
setSelectedIndex(i => (i + 1) % Math.max(items.length, 1))
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
if (event.key === 'ArrowUp') {
|
|
42
|
+
setSelectedIndex(i => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1))
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
if (event.key === 'Enter') {
|
|
46
|
+
const item = items[selectedIndex]
|
|
47
|
+
if (item) {
|
|
48
|
+
command(item)
|
|
49
|
+
}
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
},
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
if (items.length === 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const groups = groupBy(items, item => item.group)
|
|
61
|
+
let flatIndex = -1
|
|
62
|
+
|
|
63
|
+
const style: CSSProperties = rect
|
|
64
|
+
? (() => {
|
|
65
|
+
// flip by the MENU's max height, not the caret position: a menu
|
|
66
|
+
// opened mid-viewport would otherwise spill past the viewport bottom
|
|
67
|
+
const menuMax = 336 // max-height 320 + padding/border allowance
|
|
68
|
+
const fitsBelow = rect.bottom + 8 + menuMax <= window.innerHeight
|
|
69
|
+
const fitsAbove = rect.top - 8 - menuMax >= 0
|
|
70
|
+
const flip = !fitsBelow && fitsAbove
|
|
71
|
+
return {
|
|
72
|
+
position: 'fixed',
|
|
73
|
+
left: `${Math.min(rect.left, window.innerWidth - 340)}px`,
|
|
74
|
+
top: flip ? undefined : `${Math.min(rect.bottom + 8, window.innerHeight - menuMax)}px`,
|
|
75
|
+
bottom: flip ? `${window.innerHeight - rect.top + 8}px` : undefined,
|
|
76
|
+
width: 320,
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
: { position: 'fixed', left: -9999, top: -9999 }
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="tessera-slash-menu" style={style} data-testid="slash-menu">
|
|
83
|
+
{Array.from(groups.entries()).map(([group, groupItems]) => (
|
|
84
|
+
<div key={group} className="tessera-slash-group">
|
|
85
|
+
<div className="tessera-slash-group-title">
|
|
86
|
+
{group === 'ai' ? t('groupAi') : group === 'advanced' ? t('groupAdvanced') : t('groupBasic')}
|
|
87
|
+
</div>
|
|
88
|
+
{groupItems.map(item => {
|
|
89
|
+
flatIndex += 1
|
|
90
|
+
const idx = flatIndex
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
key={item.id}
|
|
94
|
+
type="button"
|
|
95
|
+
className="tessera-slash-item"
|
|
96
|
+
data-selected={idx === selectedIndex}
|
|
97
|
+
onMouseEnter={() => setSelectedIndex(idx)}
|
|
98
|
+
onClick={() => command(item)}
|
|
99
|
+
>
|
|
100
|
+
<span className="tessera-slash-item-title">{item.title}</span>
|
|
101
|
+
<span className="tessera-slash-item-desc">{item.description}</span>
|
|
102
|
+
{item.shortcut ? <span className="tessera-slash-item-shortcut">{item.shortcut}</span> : null}
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
|
|
114
|
+
const map = new Map<string, T[]>()
|
|
115
|
+
for (const item of items) {
|
|
116
|
+
const k = key(item)
|
|
117
|
+
const list = map.get(k) ?? []
|
|
118
|
+
list.push(item)
|
|
119
|
+
map.set(k, list)
|
|
120
|
+
}
|
|
121
|
+
return map
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Suggestion render factory: mounts the React view in a body-level wrapper. */
|
|
125
|
+
export function createSlashRenderer(t: TesseraTranslator) {
|
|
126
|
+
return () => {
|
|
127
|
+
let renderer: ReactRenderer | null = null
|
|
128
|
+
let wrapper: HTMLDivElement | null = null
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
onStart: (props: SuggestionProps<SlashMenuItem>) => {
|
|
132
|
+
renderer = new ReactRenderer(SlashMenuView, {
|
|
133
|
+
props: { ...props, t },
|
|
134
|
+
editor: props.editor as Editor,
|
|
135
|
+
})
|
|
136
|
+
wrapper = document.createElement('div')
|
|
137
|
+
wrapper.className = 'tessera-slash-wrapper'
|
|
138
|
+
wrapper.appendChild(renderer.element)
|
|
139
|
+
// theme tokens live on .tessera-root — a body portal renders the menu
|
|
140
|
+
// with unresolved (transparent) chrome
|
|
141
|
+
const host = props.editor.view.dom.closest('.tessera-root') ?? document.body
|
|
142
|
+
host.appendChild(wrapper)
|
|
143
|
+
},
|
|
144
|
+
onUpdate: (props: SuggestionProps<SlashMenuItem>) => {
|
|
145
|
+
renderer?.updateProps({ ...props, t })
|
|
146
|
+
},
|
|
147
|
+
onKeyDown: (props: SuggestionKeyDownProps) => {
|
|
148
|
+
if (props.event.key === 'Escape') {
|
|
149
|
+
wrapper?.remove()
|
|
150
|
+
renderer?.destroy()
|
|
151
|
+
renderer = null
|
|
152
|
+
wrapper = null
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
return (renderer?.ref as { onKeyDown?: (p: SuggestionKeyDownProps) => boolean } | null)?.onKeyDown?.(props) ?? false
|
|
156
|
+
},
|
|
157
|
+
onExit: () => {
|
|
158
|
+
wrapper?.remove()
|
|
159
|
+
renderer?.destroy()
|
|
160
|
+
renderer = null
|
|
161
|
+
wrapper = null
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|