@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,276 @@
1
+ import { useContext, useState } from 'react'
2
+ import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'
3
+ import type { NodeViewProps } from '@tiptap/react'
4
+ import {
5
+ AiTableCell,
6
+ AiTableHeader,
7
+ TABLE_COLUMN_KINDS,
8
+ tableToCsvAt,
9
+ } from '@tessera-editor/core'
10
+ import type { TableColumnKind } from '@tessera-editor/core'
11
+ import { TesseraContext } from './context'
12
+
13
+ /**
14
+ * Table NodeViews (v1.1):
15
+ * - header cell: label + ⋮ column menu (type / sort / insert / delete / table ops)
16
+ * - body cell: typed controls for non-text columns, plain content for text
17
+ */
18
+
19
+ function useT() {
20
+ const { t } = useContext(TesseraContext)!
21
+ return t
22
+ }
23
+ void useT
24
+
25
+ function kindLabel(t: ReturnType<typeof useT>, kind: TableColumnKind): string {
26
+ switch (kind) {
27
+ case 'checkbox':
28
+ return t('colTypeCheckbox')
29
+ case 'select':
30
+ return t('colTypeSelect')
31
+ case 'multiSelect':
32
+ return t('colTypeMultiSelect')
33
+ case 'number':
34
+ return t('colTypeNumber')
35
+ case 'date':
36
+ return t('colTypeDate')
37
+ case 'link':
38
+ return t('colTypeLink')
39
+ default:
40
+ return t('colTypeText')
41
+ }
42
+ }
43
+
44
+ function columnIndex(props: NodeViewProps): number {
45
+ const pos = props.getPos()
46
+ if (typeof pos !== 'number') {
47
+ return 0
48
+ }
49
+ const $pos = props.editor.state.doc.resolve(pos)
50
+ return $pos.index($pos.depth)
51
+ }
52
+
53
+ /** Header cell with the column menu. */
54
+ function AiTableHeaderView(props: NodeViewProps) {
55
+ const { editor, t } = useContext(TesseraContext)!
56
+ const [open, setOpen] = useState(false)
57
+ const index = columnIndex(props)
58
+
59
+ const run = (fn: () => unknown) => {
60
+ setOpen(false)
61
+ // re-select into this cell so table commands locate the table
62
+ const pos = props.getPos()
63
+ if (typeof pos === 'number') {
64
+ editor.commands.setTextSelection(pos + 2)
65
+ }
66
+ void fn()
67
+ }
68
+
69
+ return (
70
+ <NodeViewWrapper as="th" className="tessera-th" data-index={index}>
71
+ <NodeViewContent className="tessera-th-content" />
72
+ <button
73
+ type="button"
74
+ className="tessera-col-menu-btn"
75
+ contentEditable={false}
76
+ title={t('colMenuTitle')}
77
+ onMouseDown={e => e.preventDefault()}
78
+ onClick={() => setOpen(v => !v)}
79
+ >
80
+
81
+ </button>
82
+ {open ? (
83
+ <div className="tessera-popover tessera-col-menu" contentEditable={false} onMouseDown={e => e.preventDefault()}>
84
+ <div className="tessera-menu-section">
85
+ {TABLE_COLUMN_KINDS.map(kind => (
86
+ <button key={kind} type="button" onClick={() => run(() => editor.commands.setColumnType(index, kind))}>
87
+ {kindLabel(t, kind)}
88
+ </button>
89
+ ))}
90
+ </div>
91
+ <div className="tessera-menu-sep" />
92
+ <button type="button" onClick={() => run(() => editor.commands.sortTableByColumn(index, 'asc'))}>
93
+ {t('colMenuSortAsc')}
94
+ </button>
95
+ <button type="button" onClick={() => run(() => editor.commands.sortTableByColumn(index, 'desc'))}>
96
+ {t('colMenuSortDesc')}
97
+ </button>
98
+ <div className="tessera-menu-sep" />
99
+ <button type="button" onClick={() => run(() => editor.commands.addColumnBefore())}>
100
+ {t('colMenuInsertLeft')}
101
+ </button>
102
+ <button type="button" onClick={() => run(() => editor.commands.addColumnAfter())}>
103
+ {t('colMenuInsertRight')}
104
+ </button>
105
+ <button type="button" className="tessera-danger" onClick={() => run(() => editor.commands.deleteColumn())}>
106
+ {t('colMenuDelete')}
107
+ </button>
108
+ <div className="tessera-menu-sep" />
109
+ <button type="button" onClick={() => run(() => editor.commands.toggleHeaderRow())}>
110
+ {t('tableToggleHeader')}
111
+ </button>
112
+ <button
113
+ type="button"
114
+ onClick={() =>
115
+ run(() => {
116
+ const csv = tableToCsvAt(editor)
117
+ if (csv) {
118
+ void navigator.clipboard.writeText(csv)
119
+ }
120
+ })
121
+ }
122
+ >
123
+ {t('tableCopyCsv')}
124
+ </button>
125
+ <button type="button" className="tessera-danger" onClick={() => run(() => editor.commands.deleteTable())}>
126
+ {t('tableDelete')}
127
+ </button>
128
+ </div>
129
+ ) : null}
130
+ </NodeViewWrapper>
131
+ )
132
+ }
133
+
134
+ function AiTableCellView(props: NodeViewProps) {
135
+ const { editor } = useContext(TesseraContext)!
136
+ const value = props.node.attrs.value as unknown
137
+
138
+ // find this column's kind: walk up to the table attrs via position math
139
+ const kind: TableColumnKind = (() => {
140
+ const pos = props.getPos()
141
+ if (typeof pos !== 'number') {
142
+ return 'text'
143
+ }
144
+ const $pos = editor.state.doc.resolve(pos)
145
+ let tableDepth = -1
146
+ for (let d = $pos.depth; d >= 1; d--) {
147
+ if ($pos.node(d).type.name === 'table') {
148
+ tableDepth = d
149
+ break
150
+ }
151
+ }
152
+ if (tableDepth < 1) {
153
+ return 'text'
154
+ }
155
+ const table = $pos.node(tableDepth)
156
+ const rowDepth = tableDepth + 1
157
+ const cellIndex = $pos.index(rowDepth)
158
+ const types = Array.isArray(table.attrs.types) ? (table.attrs.types as TableColumnKind[]) : []
159
+ return types[cellIndex] ?? 'text'
160
+ })()
161
+
162
+ if (kind === 'text') {
163
+ return (
164
+ <NodeViewWrapper as="td" className="tessera-td">
165
+ <NodeViewContent />
166
+ </NodeViewWrapper>
167
+ )
168
+ }
169
+
170
+ const setValue = (next: unknown) => props.updateAttributes({ value: next })
171
+
172
+ return (
173
+ <NodeViewWrapper as="td" className={`tessera-td tessera-td--${kind}`}>
174
+ <NodeViewContent className="tessera-td-hidden" />
175
+ {kind === 'checkbox' ? (
176
+ <input
177
+ type="checkbox"
178
+ className="tessera-cell-checkbox"
179
+ checked={value === true || value === 'true'}
180
+ onChange={e => setValue(e.target.checked)}
181
+ contentEditable={false}
182
+ />
183
+ ) : null}
184
+ {kind === 'number' ? (
185
+ <input
186
+ type="number"
187
+ className="tessera-cell-input"
188
+ value={typeof value === 'number' ? value : ''}
189
+ placeholder="—"
190
+ onChange={e => setValue(e.target.value === '' ? null : Number(e.target.value))}
191
+ contentEditable={false}
192
+ />
193
+ ) : null}
194
+ {kind === 'date' ? (
195
+ <input
196
+ type="date"
197
+ className="tessera-cell-input"
198
+ value={typeof value === 'string' ? value : ''}
199
+ onChange={e => setValue(e.target.value || null)}
200
+ contentEditable={false}
201
+ />
202
+ ) : null}
203
+ {kind === 'select' || kind === 'multiSelect' ? (
204
+ <TagEditor
205
+ multi={kind === 'multiSelect'}
206
+ value={Array.isArray(value) ? (value as string[]) : value ? [String(value)] : []}
207
+ onChange={tags => setValue(kind === 'multiSelect' ? tags : (tags[0] ?? null))}
208
+ />
209
+ ) : null}
210
+ {kind === 'link' ? (
211
+ <input
212
+ type="url"
213
+ className="tessera-cell-input"
214
+ placeholder="https://…"
215
+ value={typeof value === 'string' ? value : ''}
216
+ onChange={e => setValue(e.target.value || null)}
217
+ contentEditable={false}
218
+ />
219
+ ) : null}
220
+ </NodeViewWrapper>
221
+ )
222
+ }
223
+
224
+ function TagEditor({ multi, value, onChange }: { multi: boolean; value: string[]; onChange: (tags: string[]) => void }) {
225
+ const [input, setInput] = useState('')
226
+ const commit = () => {
227
+ const tag = input.trim()
228
+ if (!tag) {
229
+ return
230
+ }
231
+ const next = multi ? Array.from(new Set([...value, tag])) : [tag]
232
+ onChange(next)
233
+ setInput('')
234
+ }
235
+ return (
236
+ <div className="tessera-cell-tags" contentEditable={false}>
237
+ {value.map(tag => (
238
+ <span key={tag} className="tessera-tag">
239
+ {tag}
240
+ <button
241
+ type="button"
242
+ className="tessera-tag-x"
243
+ onClick={() => onChange(value.filter(v => v !== tag))}
244
+ >
245
+ ×
246
+ </button>
247
+ </span>
248
+ ))}
249
+ <input
250
+ className="tessera-cell-input tessera-cell-taginput"
251
+ value={input}
252
+ placeholder={multi ? '+ 标签' : '+ 标签'}
253
+ onChange={e => setInput(e.target.value)}
254
+ onKeyDown={e => {
255
+ if (e.key === 'Enter') {
256
+ e.preventDefault()
257
+ commit()
258
+ }
259
+ }}
260
+ onBlur={commit}
261
+ />
262
+ </div>
263
+ )
264
+ }
265
+
266
+ export const AiTableCellViewExtension = AiTableCell.extend({
267
+ addNodeView() {
268
+ return ReactNodeViewRenderer(AiTableCellView)
269
+ },
270
+ })
271
+
272
+ export const AiTableHeaderViewExtension = AiTableHeader.extend({
273
+ addNodeView() {
274
+ return ReactNodeViewRenderer(AiTableHeaderView)
275
+ },
276
+ })
@@ -0,0 +1,315 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import { EditorContent, useEditor } from '@tiptap/react'
4
+ import type { Editor } from '@tiptap/react'
5
+ import type { Content, EditorOptions } from '@tiptap/core'
6
+ import { DragHandle } from '@tiptap/extension-drag-handle-react'
7
+ import {
8
+ createTesseraExtensions,
9
+ createTesseraT,
10
+ } from '@tessera-editor/core'
11
+ import type {
12
+ TesseraLocale,
13
+ UploadService,
14
+ StorageService,
15
+ CommentStore,
16
+ IdentityService,
17
+ } from '@tessera-editor/core'
18
+ import { createAiController, aiSlashItems } from '@tessera-editor/ai'
19
+ import type { AIRuntime, SuggestionSession } from '@tessera-editor/ai'
20
+ import { ImageBlockView } from './ImageNodeView'
21
+ import { AiTableCellViewExtension, AiTableHeaderViewExtension } from './TableNodeViews'
22
+ import { EmbedBlockView, TocBlockView } from './EmbedTocViews'
23
+ import { createSlashRenderer } from './SlashMenu'
24
+ import { createEmojiRenderer } from './EmojiMenu'
25
+ import { EmptyLineToolbar } from './EmptyLineToolbar'
26
+ import { SelectionToolbar } from './SelectionToolbar'
27
+ import { FindReplacePanel, AskPanel, SuggestionBar } from './Panels'
28
+ import { HistoryPanel } from './HistoryPanel'
29
+ import { CommentPanel } from './CommentPanel'
30
+ import { BlockContextMenuUI } from './BlockMenu'
31
+ import { TesseraContext } from './context'
32
+ import '@tessera-editor/core/styles.css'
33
+
34
+ export interface TesseraProps {
35
+ /** Initial document (JSON / HTML / markdown string). Host owns persistence. */
36
+ content?: Content
37
+ locale?: TesseraLocale
38
+ /** Injected image upload capability (ADR-0001 family). */
39
+ upload?: UploadService
40
+ /** v1.1: version-history snapshot storage. */
41
+ storage?: StorageService
42
+ /** v1.1: inline comment persistence. */
43
+ comments?: CommentStore
44
+ /** v1.1: current user (comment authorship). */
45
+ identity?: IdentityService
46
+ /** Injected model runtime; presence enables all AI surfaces. */
47
+ ai?: AIRuntime
48
+ /** v1.1: idle window for history auto-capture (playground uses short ones) */
49
+ historyIdleMs?: number
50
+ /** v1.1: document column max-width in px (Slite-style centered column). */
51
+ docWidth?: number
52
+ onUpdate?: (editor: Editor) => void
53
+ onCreate?: (editor: Editor) => void
54
+ }
55
+
56
+ /**
57
+ * Batteries-included editor (M1+M2 scope). Slite-style: empty-line toolbar,
58
+ * slash menu, selection toolbar, drag handle — deliberately NOT Notion's
59
+ * floating "+".
60
+ */
61
+
62
+ /** Stable reference is required: see the DragHandle usage comment below. */
63
+ const DRAG_HANDLE_POSITION_CONFIG = { placement: 'left', strategy: 'absolute' } as const
64
+ // NOTE: DragHandle `nested` mode (per-item list dragging) was evaluated and
65
+ // deferred — with nested rules active the handle stopped appearing on plain
66
+ // hover in smoke checks. Revisit with:
67
+ // nested={{ edgeDetection: 'left', allowedContainers: ['bulletList', 'orderedList', 'taskList'] }}
68
+ export function Tessera({
69
+ content,
70
+ locale = 'zh-CN',
71
+ upload,
72
+ storage,
73
+ comments,
74
+ identity,
75
+ ai: runtime,
76
+ historyIdleMs,
77
+ docWidth,
78
+ onUpdate,
79
+ onCreate,
80
+ }: TesseraProps) {
81
+ const t = useMemo(() => createTesseraT(locale), [locale])
82
+ const [session, setSession] = useState<SuggestionSession | null>(null)
83
+ const fileInputRef = useRef<HTMLInputElement>(null)
84
+ type DragNodeData = {
85
+ node: { attrs: Record<string, unknown>; type: { name: string } } | null
86
+ pos: number
87
+ }
88
+ const dragNodeRef = useRef<DragNodeData | null>(null)
89
+ const handleDragNodeChange = useCallback((data: DragNodeData) => {
90
+ dragNodeRef.current = data.node ? data : null
91
+ }, [])
92
+
93
+ // Everything handed to useEditor must be referentially stable: an unstable
94
+ // option (any inline callback) makes @tiptap/react call editor.setOptions
95
+ // on every render, and TipTap's setOptions runs view.updateState, which
96
+ // destroys and recreates ALL plugin views. A recreated suggestion view
97
+ // starts from an already-active state with no "started" transition, so
98
+ // renderer.onStart never fires and the slash menu UI never mounts.
99
+ // Latest prop values are therefore read through refs instead.
100
+ const onUpdateRef = useRef(onUpdate)
101
+ onUpdateRef.current = onUpdate
102
+ const onCreateRef = useRef(onCreate)
103
+ onCreateRef.current = onCreate
104
+ const runtimeRef = useRef(runtime)
105
+ runtimeRef.current = runtime
106
+ const initialContentRef = useRef(content)
107
+ const editorRef = useRef<Editor | null>(null)
108
+
109
+ const extensions = useMemo(
110
+ () =>
111
+ createTesseraExtensions({ locale, historyIdleMs }).map(ext => {
112
+ if (ext.name === 'tesseraSlashMenu') {
113
+ return ext.configure({
114
+ render: createSlashRenderer(t),
115
+ extraItems: (ctx: Parameters<NonNullable<import('@tessera-editor/core').SlashMenuOptions['extraItems']>>[0]) => {
116
+ const rt = runtimeRef.current
117
+ return rt ? aiSlashItems({ editor: ctx.editor, runtime: rt, t: ctx.t }) : []
118
+ },
119
+ })
120
+ }
121
+ if (ext.name === 'tesseraEmojiMenu') {
122
+ return ext.configure({ render: createEmojiRenderer() })
123
+ }
124
+ if (ext.name === 'imageBlock') {
125
+ return ImageBlockView
126
+ }
127
+ if (ext.name === 'tableCell') {
128
+ return AiTableCellViewExtension
129
+ }
130
+ if (ext.name === 'tableHeader') {
131
+ return AiTableHeaderViewExtension
132
+ }
133
+ if (ext.name === 'embedBlock') {
134
+ return EmbedBlockView
135
+ }
136
+ if (ext.name === 'tocBlock') {
137
+ return TocBlockView
138
+ }
139
+ return ext
140
+ }),
141
+ // runtime is read through runtimeRef on purpose: rebuilding the extension
142
+ // list after mount cannot apply anyway (extensions are only read when the
143
+ // editor is created) and would only trigger the setOptions churn above
144
+ [locale, t, historyIdleMs],
145
+ )
146
+
147
+ async function uploadAndInsert(file: File) {
148
+ const ed = editorRef.current
149
+ if (!ed) {
150
+ return
151
+ }
152
+ const storage = ed.storage as unknown as Record<string, { upload?: UploadService }>
153
+ const svc = storage.tesseraServices?.upload
154
+ if (!svc) {
155
+ return
156
+ }
157
+ try {
158
+ const asset = await svc.uploadImage(file)
159
+ ed.chain().focus().setImage({ src: asset.url, alt: asset.name }).run()
160
+ } catch (err) {
161
+ console.error('[Tessera] image upload failed:', err)
162
+ }
163
+ }
164
+ const uploadAndInsertRef = useRef(uploadAndInsert)
165
+ uploadAndInsertRef.current = uploadAndInsert
166
+
167
+ const editorProps: EditorOptions['editorProps'] = useMemo(
168
+ () => ({
169
+ attributes: {
170
+ class: 'tessera-doc',
171
+ spellcheck: 'false',
172
+ },
173
+ handlePaste: (_view, event) => {
174
+ const file = Array.from(event.clipboardData?.files ?? []).find(f => f.type.startsWith('image/'))
175
+ if (file) {
176
+ void uploadAndInsertRef.current(file)
177
+ return true
178
+ }
179
+ return false
180
+ },
181
+ handleDrop: (_view, event, _slice, moved) => {
182
+ if (moved) {
183
+ return false
184
+ }
185
+ const file = Array.from(event.dataTransfer?.files ?? []).find(f => f.type.startsWith('image/'))
186
+ if (file) {
187
+ event.preventDefault()
188
+ void uploadAndInsertRef.current(file)
189
+ return true
190
+ }
191
+ return false
192
+ },
193
+ }),
194
+ [],
195
+ )
196
+
197
+ const handleUpdate = useCallback(({ editor: e }: { editor: Editor }) => onUpdateRef.current?.(e), [])
198
+ const handleCreate = useCallback(({ editor: e }: { editor: Editor }) => onCreateRef.current?.(e), [])
199
+
200
+ const editor = useEditor({
201
+ extensions,
202
+ content: initialContentRef.current,
203
+ onUpdate: handleUpdate,
204
+ onCreate: handleCreate,
205
+ editorProps,
206
+ })
207
+
208
+ editorRef.current = editor ?? null
209
+
210
+ // injected services stay current
211
+ useEffect(() => {
212
+ if (editor) {
213
+ const bag = (editor.storage as unknown as Record<string, Record<string, unknown>>).tesseraServices ?? {}
214
+ ;(editor.storage as unknown as Record<string, Record<string, unknown>>).tesseraServices = {
215
+ ...bag,
216
+ upload,
217
+ storage,
218
+ comments,
219
+ identity,
220
+ }
221
+ }
222
+ }, [editor, upload, storage, comments, identity])
223
+
224
+ // editor events → UI (image picker, sessions from slash AI items)
225
+ useEffect(() => {
226
+ if (!editor) {
227
+ return
228
+ }
229
+ const pickImage = () => fileInputRef.current?.click()
230
+ const onSession = (payload: { session: SuggestionSession | null }) => setSession(payload.session)
231
+ editor.on('tessera:insertImage', pickImage)
232
+ editor.on('tessera:session', onSession as never)
233
+ return () => {
234
+ editor.off('tessera:insertImage', pickImage)
235
+ editor.off('tessera:session', onSession as never)
236
+ }
237
+ }, [editor])
238
+
239
+ const ai = useMemo(
240
+ () => (editor && runtime ? createAiController(editor, runtime, locale) : null),
241
+ [editor, runtime, locale],
242
+ )
243
+
244
+ if (!editor) {
245
+ return null
246
+ }
247
+
248
+ return (
249
+ <TesseraContext.Provider value={{ editor, locale, t, ai }}>
250
+ <div
251
+ className="tessera-root"
252
+ style={{ '--te-doc-max-width': docWidth ? `${docWidth}px` : undefined } as CSSProperties}
253
+ >
254
+ <EditorContent editor={editor} />
255
+ {/* 'left' (vertical center) instead of the default 'left-start': the
256
+ handle must sit mid-row like Slite, not above multi-line blocks.
257
+ The config object must be referentially stable — DragHandle
258
+ re-registers its plugin whenever its props change identity, and
259
+ that re-registration recreates every PM plugin view (killing any
260
+ active slash-menu UI). */}
261
+ <DragHandle
262
+ editor={editor}
263
+ pluginKey="tesseraDragHandle"
264
+ computePositionConfig={DRAG_HANDLE_POSITION_CONFIG}
265
+ onNodeChange={handleDragNodeChange}
266
+ >
267
+ <div
268
+ className="tessera-drag-handle"
269
+ onClick={event => {
270
+ // handle click opens the block menu (drag still reorders);
271
+ // context menu UI selects the block and positions at pointer
272
+ const data = dragNodeRef.current
273
+ if (!data?.node) {
274
+ return
275
+ }
276
+ const id = (data.node.attrs as { id?: string }).id
277
+ if (!id) {
278
+ return
279
+ }
280
+ editor.emit('tessera:blockMenu', {
281
+ blockId: id,
282
+ blockType: data.node.type.name,
283
+ clientX: event.clientX,
284
+ clientY: event.clientY,
285
+ } as never)
286
+ }}
287
+ >
288
+
289
+ </div>
290
+ </DragHandle>
291
+ <EmptyLineToolbar />
292
+ <SelectionToolbar onSession={setSession} />
293
+ <FindReplacePanel />
294
+ <AskPanel />
295
+ <HistoryPanel />
296
+ <CommentPanel />
297
+ <BlockContextMenuUI />
298
+ <SuggestionBar session={session} onClear={() => setSession(null)} />
299
+ <input
300
+ ref={fileInputRef}
301
+ type="file"
302
+ accept="image/*"
303
+ hidden
304
+ onChange={e => {
305
+ const file = e.target.files?.[0]
306
+ if (file) {
307
+ void uploadAndInsert(file)
308
+ }
309
+ e.target.value = ''
310
+ }}
311
+ />
312
+ </div>
313
+ </TesseraContext.Provider>
314
+ )
315
+ }
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { render, act, waitFor } from '@testing-library/react'
4
+ import { Tessera } from '../Tessera'
5
+ import type { Editor } from '@tiptap/core'
6
+
7
+ /**
8
+ * Regression tests for the React binding. The most important one guards the
9
+ * slash-menu fix: any option handed to useEditor with an unstable identity
10
+ * (inline callbacks / config objects) makes @tiptap/react call
11
+ * editor.setOptions on every render, and TipTap's setOptions recreates all
12
+ * plugin views — which silently killed the slash menu UI after the first
13
+ * document change.
14
+ */
15
+
16
+ const demoDoc = {
17
+ type: 'doc',
18
+ content: [
19
+ { type: 'paragraph', content: [{ type: 'text', text: 'Hello Tessera' }] },
20
+ ],
21
+ }
22
+
23
+ function mountTessera() {
24
+ let editor: Editor | null = null
25
+ const utils = render(
26
+ <Tessera locale="zh-CN" content={demoDoc} onCreate={ed => { editor = ed }} />,
27
+ )
28
+ return { utils, getEditor: () => editor }
29
+ }
30
+
31
+ describe('Tessera (react binding)', () => {
32
+ it('mounts an editor with the document content', async () => {
33
+ const { getEditor } = mountTessera()
34
+ await waitFor(() => expect(getEditor()).not.toBeNull())
35
+ const ed = getEditor()!
36
+ expect(ed)
37
+ expect(ed.state.doc.textContent).toContain('Hello Tessera')
38
+ })
39
+
40
+ it('re-renders do not churn plugin views (unregisterPlugin must not fire)', async () => {
41
+ const { utils, getEditor } = mountTessera()
42
+ await waitFor(() => expect(getEditor()).not.toBeNull())
43
+ const ed = getEditor()!
44
+ const unregisterSpy = vi.spyOn(ed, 'unregisterPlugin')
45
+
46
+ for (let i = 0; i < 3; i++) {
47
+ utils.rerender(<Tessera locale="zh-CN" content={demoDoc} onCreate={() => {}} />)
48
+ await act(async () => {})
49
+ }
50
+ expect(unregisterSpy).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it('slash menu activates on insert and survives re-renders', async () => {
54
+ const { utils, getEditor } = mountTessera()
55
+ await act(async () => {})
56
+ const ed = getEditor()!
57
+
58
+ const slashPlugin = () =>
59
+ ed!.state.plugins.find(p => (p as unknown as { key: string }).key.startsWith('tesseraSlashMenu'))
60
+
61
+ // activate the suggestion by inserting the trigger character
62
+ await act(async () => {
63
+ ed!.commands.insertContentAt(ed!.state.doc.content.size, '/')
64
+ })
65
+ const pluginAfterInsert = slashPlugin()
66
+ const pluginKey = (pluginAfterInsert as unknown as { key: string }).key
67
+ const stateAfterInsert = pluginAfterInsert
68
+ ? JSON.parse(JSON.stringify((ed!.state as unknown as Record<string, unknown>)[pluginKey]))
69
+ : null
70
+ expect(stateAfterInsert?.active).toBe(true)
71
+
72
+ // re-renders must not destroy the suggestion (the historic bug: the
73
+ // started wrapper was removed by a plugin-view destroy on next render)
74
+ const unregisterSpy = vi.spyOn(ed!, 'unregisterPlugin')
75
+ for (let i = 0; i < 3; i++) {
76
+ utils.rerender(<Tessera locale="zh-CN" content={demoDoc} onCreate={() => {}} />)
77
+ await act(async () => {})
78
+ }
79
+ expect(unregisterSpy).not.toHaveBeenCalled()
80
+ expect(slashPlugin()).toBe(pluginAfterInsert)
81
+ const stateAfterRerenders = JSON.parse(JSON.stringify((ed!.state as unknown as Record<string, unknown>)[pluginKey]))
82
+ expect(stateAfterRerenders.active).toBe(true)
83
+ })
84
+ })
package/src/context.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { Editor } from '@tiptap/react'
3
+ import type { TesseraLocale, TesseraTranslator } from '@tessera-editor/core'
4
+ import type { AiController } from '@tessera-editor/ai'
5
+
6
+ export interface TesseraContextValue {
7
+ editor: Editor
8
+ locale: TesseraLocale
9
+ t: TesseraTranslator
10
+ ai: AiController | null
11
+ }
12
+
13
+ export const TesseraContext = createContext<TesseraContextValue | null>(null)
14
+
15
+ export function useTesseraContext(): TesseraContextValue {
16
+ const ctx = useContext(TesseraContext)
17
+ if (!ctx) {
18
+ throw new Error('Tessera: useTesseraContext outside provider')
19
+ }
20
+ return ctx
21
+ }