@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,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
|
+
})
|
package/src/Tessera.tsx
ADDED
|
@@ -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
|
+
}
|