@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,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
+ }