@tessera-editor/react 0.1.0 → 0.2.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/dist/index.d.ts +35 -13
- package/dist/index.js +456 -387
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/EmptyLineToolbar.tsx +8 -11
- package/src/ImageNodeView.tsx +38 -3
- package/src/LinkEditor.tsx +121 -0
- package/src/SelectionToolbar.tsx +21 -11
- package/src/SlashMenu.tsx +1 -1
- package/src/TableNodeViews.tsx +59 -9
- package/src/Tessera.tsx +62 -18
- package/src/__tests__/host-config.test.tsx +117 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/__tests__/slash-menu-scroll.test.tsx +53 -0
- package/src/context.ts +7 -0
- package/src/index.ts +2 -1
- package/src/HistoryPanel.tsx +0 -166
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tessera-editor/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Tessera React binding with the full default UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"@tiptap/react": "^3.31.3",
|
|
31
31
|
"@tiptap/starter-kit": "^3.31.3",
|
|
32
32
|
"@tiptap/suggestion": "^3.31.3",
|
|
33
|
-
"@tessera-editor/ai": "0.1.
|
|
34
|
-
"@tessera-editor/core": "0.
|
|
33
|
+
"@tessera-editor/ai": "0.1.1",
|
|
34
|
+
"@tessera-editor/core": "0.2.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/EmptyLineToolbar.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import type { CSSProperties } from 'react'
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
|
-
import { defaultSlashItems } from '@tessera-editor/core'
|
|
4
|
+
import { defaultSlashItems, filterSlashItems } from '@tessera-editor/core'
|
|
5
5
|
import type { SlashMenuItem, TesseraTranslator } from '@tessera-editor/core'
|
|
6
6
|
import { aiSlashItems } from '@tessera-editor/ai'
|
|
7
7
|
import type { Editor } from '@tiptap/react'
|
|
@@ -17,18 +17,15 @@ import { useTesseraPortalRoot } from './portal'
|
|
|
17
17
|
* hide, nor move the toolbar (M0 spike finding).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// AI items need a runtime; context provides it — reconstructed here via
|
|
26
|
-
// context injection from the parent (see useItems below).
|
|
27
|
-
return base
|
|
20
|
+
/** The expanded panel mirrors the slash menu, minus host-excluded blocks. */
|
|
21
|
+
function buildItems(editor: Editor, t: TesseraTranslator): SlashMenuItem[] {
|
|
22
|
+
const slash = editor.extensionManager.extensions.find(ext => ext.name === 'tesseraSlashMenu')
|
|
23
|
+
const excluded = (slash?.options as { excludeItems?: string[] } | undefined)?.excludeItems
|
|
24
|
+
return filterSlashItems(defaultSlashItems(t), excluded)
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
export function EmptyLineToolbar() {
|
|
31
|
-
const { editor, t
|
|
28
|
+
const { editor, t } = useContext(TesseraContext)!
|
|
32
29
|
const [style, setStyle] = useState<CSSProperties | null>(null)
|
|
33
30
|
const [expanded, setExpanded] = useState(false)
|
|
34
31
|
const [panelPlacement, setPanelPlacement] = useState<'bottom' | 'top'>('bottom')
|
|
@@ -147,7 +144,7 @@ export function EmptyLineToolbar() {
|
|
|
147
144
|
return null
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
const items = buildItems(editor, t
|
|
147
|
+
const items = buildItems(editor, t)
|
|
151
148
|
|
|
152
149
|
const runItem = (item: SlashMenuItem) => {
|
|
153
150
|
const { $from } = editor.state.selection
|
package/src/ImageNodeView.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import { TesseraContext } from './context'
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
function AiImageNodeView({ node, updateAttributes, selected, decorations }: NodeViewProps) {
|
|
15
|
-
console.log('[imgview] decorations:', Array.isArray(decorations), decorations?.length)
|
|
16
15
|
const galleryAttrs = (() => {
|
|
17
16
|
for (const deco of decorations ?? []) {
|
|
18
17
|
const attrs = (deco as unknown as { type?: { attrs?: Record<string, string> } }).type?.attrs
|
|
@@ -25,14 +24,31 @@ function AiImageNodeView({ node, updateAttributes, selected, decorations }: Node
|
|
|
25
24
|
const { t } = useContext(TesseraContext)!
|
|
26
25
|
const imgRef = useRef<HTMLImageElement>(null)
|
|
27
26
|
const [preview, setPreview] = useState<string | null>(null)
|
|
27
|
+
const [zoom, setZoom] = useState(1)
|
|
28
|
+
const clampZoom = (z: number) => Math.min(8, Math.max(0.2, z))
|
|
28
29
|
|
|
29
30
|
useEffect(() => {
|
|
30
31
|
if (!preview) return
|
|
32
|
+
setZoom(1)
|
|
31
33
|
const close = (e: KeyboardEvent) => {
|
|
32
34
|
if (e.key === 'Escape') setPreview(null)
|
|
35
|
+
else if (e.key === '+' || e.key === '=') setZoom(z => clampZoom(z * 1.25))
|
|
36
|
+
else if (e.key === '-') setZoom(z => clampZoom(z / 1.25))
|
|
37
|
+
else if (e.key === '0') setZoom(1)
|
|
38
|
+
}
|
|
39
|
+
// ctrl+wheel needs a non-passive listener so preventDefault can
|
|
40
|
+
// suppress the browser's own page zoom
|
|
41
|
+
const onWheel = (e: WheelEvent) => {
|
|
42
|
+
if (!e.ctrlKey) return
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
setZoom(z => clampZoom(z * (e.deltaY < 0 ? 1.15 : 1 / 1.15)))
|
|
33
45
|
}
|
|
34
46
|
window.addEventListener('keydown', close)
|
|
35
|
-
|
|
47
|
+
window.addEventListener('wheel', onWheel, { passive: false })
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener('keydown', close)
|
|
50
|
+
window.removeEventListener('wheel', onWheel)
|
|
51
|
+
}
|
|
36
52
|
}, [preview])
|
|
37
53
|
const { src, alt, width, align } = node.attrs as {
|
|
38
54
|
src: string
|
|
@@ -92,7 +108,26 @@ function AiImageNodeView({ node, updateAttributes, selected, decorations }: Node
|
|
|
92
108
|
{preview
|
|
93
109
|
? createPortal(
|
|
94
110
|
<div className="tessera-lightbox" onClick={() => setPreview(null)}>
|
|
95
|
-
<img
|
|
111
|
+
<img
|
|
112
|
+
src={preview}
|
|
113
|
+
alt={alt ?? ''}
|
|
114
|
+
style={{ transform: `scale(${zoom})` }}
|
|
115
|
+
onClick={e => e.stopPropagation()}
|
|
116
|
+
onDoubleClick={() => setZoom(1)}
|
|
117
|
+
/>
|
|
118
|
+
<div className="tessera-lightbox-bar" onClick={e => e.stopPropagation()}>
|
|
119
|
+
<button type="button" title={t('imageZoomOut')} onClick={() => setZoom(z => clampZoom(z / 1.25))}>
|
|
120
|
+
−
|
|
121
|
+
</button>
|
|
122
|
+
<span className="tessera-lightbox-scale">{Math.round(zoom * 100)}%</span>
|
|
123
|
+
<button type="button" title={t('imageZoomIn')} onClick={() => setZoom(z => clampZoom(z * 1.25))}>
|
|
124
|
+
+
|
|
125
|
+
</button>
|
|
126
|
+
<button type="button" title={t('imageZoomReset')} onClick={() => setZoom(1)}>
|
|
127
|
+
1:1
|
|
128
|
+
</button>
|
|
129
|
+
<span className="tessera-lightbox-hint">{t('imageZoomHint')}</span>
|
|
130
|
+
</div>
|
|
96
131
|
</div>,
|
|
97
132
|
document.body,
|
|
98
133
|
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CSSProperties } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { findLinkRange, removeLinkRange, saveLinkRange } from '@tessera-editor/core'
|
|
5
|
+
import type { TesseraLinkRange } from '@tessera-editor/core'
|
|
6
|
+
import { TesseraContext } from './context'
|
|
7
|
+
import { useTesseraPortalRoot } from './portal'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Click-to-edit link panel: clicking a link in the document opens a compact
|
|
11
|
+
* editor for its display text and href; 移除链接 degrades it back to plain
|
|
12
|
+
* text. Doc semantics live in core (linkedit.ts) so both bindings match.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function LinkEditor() {
|
|
16
|
+
const { editor, t } = useContext(TesseraContext)!
|
|
17
|
+
const portalRoot = useTesseraPortalRoot(editor)
|
|
18
|
+
const [range, setRange] = useState<TesseraLinkRange | null>(null)
|
|
19
|
+
const [text, setText] = useState('')
|
|
20
|
+
const [href, setHref] = useState('')
|
|
21
|
+
const [style, setStyle] = useState<CSSProperties>({})
|
|
22
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const onClick = (e: MouseEvent) => {
|
|
26
|
+
const target = e.target as HTMLElement
|
|
27
|
+
const anchor = target.closest('a[href]')
|
|
28
|
+
if (!anchor || !editor.view.dom.contains(target)) {
|
|
29
|
+
setRange(null)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
// read-only: a link click opens the target instead of the editor panel
|
|
33
|
+
if (!editor.isEditable) {
|
|
34
|
+
const href = anchor.getAttribute('href')
|
|
35
|
+
if (href) {
|
|
36
|
+
window.open(href, '_blank', 'noopener,noreferrer')
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const found = findLinkRange(editor, editor.view.posAtDOM(anchor, 0))
|
|
41
|
+
if (found) openPanel(found)
|
|
42
|
+
}
|
|
43
|
+
const dom = editor.view.dom
|
|
44
|
+
dom.addEventListener('click', onClick)
|
|
45
|
+
return () => dom.removeEventListener('click', onClick)
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
47
|
+
}, [editor])
|
|
48
|
+
|
|
49
|
+
// any mousedown outside the panel dismisses it; a click on another link
|
|
50
|
+
// re-opens with that link via the handler above
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!range) return
|
|
53
|
+
const onDown = (e: MouseEvent) => {
|
|
54
|
+
if (!panelRef.current?.contains(e.target as Node)) setRange(null)
|
|
55
|
+
}
|
|
56
|
+
window.addEventListener('mousedown', onDown)
|
|
57
|
+
return () => window.removeEventListener('mousedown', onDown)
|
|
58
|
+
}, [range])
|
|
59
|
+
|
|
60
|
+
function openPanel(found: TesseraLinkRange) {
|
|
61
|
+
setRange(found)
|
|
62
|
+
setText(found.text)
|
|
63
|
+
setHref(found.href)
|
|
64
|
+
const a = editor.view.coordsAtPos(found.from)
|
|
65
|
+
const b = editor.view.coordsAtPos(found.to)
|
|
66
|
+
const left = Math.min(Math.max(8, (a.left + b.left) / 2 - 160), window.innerWidth - 328)
|
|
67
|
+
const bottom = Math.max(a.bottom, b.bottom)
|
|
68
|
+
const panelMax = 170
|
|
69
|
+
const flip = bottom + 8 + panelMax > window.innerHeight && a.top - 8 - panelMax >= 0
|
|
70
|
+
setStyle(
|
|
71
|
+
flip
|
|
72
|
+
? { left: `${left}px`, bottom: `${window.innerHeight - Math.min(a.top, b.top) + 8}px` }
|
|
73
|
+
: { left: `${left}px`, top: `${bottom + 8}px` },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function save() {
|
|
78
|
+
if (!range || !text.trim()) return
|
|
79
|
+
saveLinkRange(editor, range, { text, href })
|
|
80
|
+
setRange(null)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function remove() {
|
|
84
|
+
if (!range) return
|
|
85
|
+
removeLinkRange(editor, range)
|
|
86
|
+
setRange(null)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onInputKeyDown(e: React.KeyboardEvent) {
|
|
90
|
+
if (e.key === 'Enter') save()
|
|
91
|
+
if (e.key === 'Escape') setRange(null)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!range || !portalRoot) return null
|
|
95
|
+
return createPortal(
|
|
96
|
+
<div ref={panelRef} className="tessera-link-editor" style={style} onMouseDown={e => e.stopPropagation()}>
|
|
97
|
+
<input
|
|
98
|
+
autoFocus
|
|
99
|
+
value={text}
|
|
100
|
+
placeholder={t('linkTextPlaceholder')}
|
|
101
|
+
onChange={e => setText(e.target.value)}
|
|
102
|
+
onKeyDown={onInputKeyDown}
|
|
103
|
+
/>
|
|
104
|
+
<input
|
|
105
|
+
value={href}
|
|
106
|
+
placeholder={t('linkPlaceholder')}
|
|
107
|
+
onChange={e => setHref(e.target.value)}
|
|
108
|
+
onKeyDown={onInputKeyDown}
|
|
109
|
+
/>
|
|
110
|
+
<div className="tessera-link-editor-row">
|
|
111
|
+
<button type="button" disabled={!text.trim()} onClick={save}>
|
|
112
|
+
{t('linkSave')}
|
|
113
|
+
</button>
|
|
114
|
+
<button type="button" className="tessera-danger" onClick={remove}>
|
|
115
|
+
{t('linkRemove')}
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>,
|
|
119
|
+
portalRoot,
|
|
120
|
+
)
|
|
121
|
+
}
|
package/src/SelectionToolbar.tsx
CHANGED
|
@@ -68,12 +68,17 @@ function FlipPopover({
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function SelectionToolbar({ onSession }: { onSession: (session: SuggestionSession | null) => void }) {
|
|
71
|
-
const { editor, t, ai } = useContext(TesseraContext)!
|
|
71
|
+
const { editor, t, ai, extraSelectionItems } = useContext(TesseraContext)!
|
|
72
72
|
const [style, setStyle] = useState<CSSProperties | null>(null)
|
|
73
73
|
const [popover, setPopover] = useState<PopoverKind>(null)
|
|
74
74
|
const [linkValue, setLinkValue] = useState('')
|
|
75
75
|
const composingRef = useRef(false)
|
|
76
76
|
const portalRoot = useTesseraPortalRoot(editor)
|
|
77
|
+
// Capability-gated buttons: comments need a CommentStore, the collapsible
|
|
78
|
+
// button needs the collapsible node (hosts may exclude it).
|
|
79
|
+
const services = (editor.storage as unknown as Record<string, Record<string, unknown>>).tesseraServices
|
|
80
|
+
const hasComments = !!services?.comments
|
|
81
|
+
const hasCollapsible = editor.extensionManager.extensions.some(ext => ext.name === 'collapsible')
|
|
77
82
|
|
|
78
83
|
const reposition = useCallback(() => {
|
|
79
84
|
if (composingRef.current || !editor.isFocused) {
|
|
@@ -185,19 +190,24 @@ export function SelectionToolbar({ onSession }: { onSession: (session: Suggestio
|
|
|
185
190
|
<TbBtn title={t('tooltipLink')} active={editor.isActive('link')} onClick={() => setPopover(p => (p === 'link' ? null : 'link'))}>
|
|
186
191
|
🔗
|
|
187
192
|
</TbBtn>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
193
|
+
{hasComments ? (
|
|
194
|
+
<TbBtn
|
|
195
|
+
title={t('tooltipCommentV11')}
|
|
196
|
+
active={editor.isActive('comment')}
|
|
197
|
+
onClick={() => setPopover(p => (p === 'comment' ? null : 'comment'))}
|
|
198
|
+
>
|
|
199
|
+
💬
|
|
200
|
+
</TbBtn>
|
|
201
|
+
) : null}
|
|
202
|
+
{hasCollapsible ? (
|
|
203
|
+
<TbBtn title={t('tooltipTurnCollapsible')} onClick={() => chain().insertCollapsible().run()}>
|
|
204
|
+
▸
|
|
205
|
+
</TbBtn>
|
|
206
|
+
) : null}
|
|
198
207
|
<TbBtn title={t('tooltipMore')} onClick={() => setPopover(p => (p === 'more' ? null : 'more'))}>
|
|
199
208
|
⋯
|
|
200
209
|
</TbBtn>
|
|
210
|
+
{typeof extraSelectionItems === 'function' ? extraSelectionItems({ editor, t }) : extraSelectionItems}
|
|
201
211
|
|
|
202
212
|
{popover === 'color' ? (
|
|
203
213
|
<FlipPopover posKey={style}>
|
package/src/SlashMenu.tsx
CHANGED
|
@@ -79,7 +79,7 @@ export const SlashMenuView = forwardRef<{ onKeyDown: (props: SuggestionKeyDownPr
|
|
|
79
79
|
: { position: 'fixed', left: -9999, top: -9999 }
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
|
-
<div className="tessera-slash-menu" style={style} data-testid="slash-menu">
|
|
82
|
+
<div ref={listRef} className="tessera-slash-menu" style={style} data-testid="slash-menu">
|
|
83
83
|
{Array.from(groups.entries()).map(([group, groupItems]) => (
|
|
84
84
|
<div key={group} className="tessera-slash-group">
|
|
85
85
|
<div className="tessera-slash-group-title">
|
package/src/TableNodeViews.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { useContext, useState } from 'react'
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CSSProperties } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
2
4
|
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'
|
|
3
5
|
import type { NodeViewProps } from '@tiptap/react'
|
|
4
6
|
import {
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
} from '@tessera-editor/core'
|
|
10
12
|
import type { TableColumnKind } from '@tessera-editor/core'
|
|
11
13
|
import { TesseraContext } from './context'
|
|
14
|
+
import { useTesseraPortalRoot } from './portal'
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Table NodeViews (v1.1):
|
|
@@ -53,10 +56,49 @@ function columnIndex(props: NodeViewProps): number {
|
|
|
53
56
|
/** Header cell with the column menu. */
|
|
54
57
|
function AiTableHeaderView(props: NodeViewProps) {
|
|
55
58
|
const { editor, t } = useContext(TesseraContext)!
|
|
59
|
+
const portalRoot = useTesseraPortalRoot(editor)
|
|
56
60
|
const [open, setOpen] = useState(false)
|
|
61
|
+
const [style, setStyle] = useState<CSSProperties>({})
|
|
62
|
+
const thRef = useRef<HTMLDivElement>(null)
|
|
63
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
57
64
|
const index = columnIndex(props)
|
|
58
65
|
|
|
66
|
+
// the table has overflow:hidden (corner rounding), so an inline dropdown
|
|
67
|
+
// would be clipped away — the menu portals out of the table and anchors
|
|
68
|
+
// to the header cell with fixed positioning, flipping near the viewport
|
|
69
|
+
// bottom like every other popover
|
|
70
|
+
const toggle = () => {
|
|
71
|
+
const th = thRef.current
|
|
72
|
+
if (th) {
|
|
73
|
+
const r = th.getBoundingClientRect()
|
|
74
|
+
const flip = window.innerHeight - r.bottom < 340 && r.top > 340
|
|
75
|
+
const left = Math.min(Math.max(8, r.left), window.innerWidth - 198)
|
|
76
|
+
setStyle(
|
|
77
|
+
flip
|
|
78
|
+
? { left: `${left}px`, top: 'auto', bottom: `${window.innerHeight - r.top + 6}px` }
|
|
79
|
+
: { left: `${left}px`, top: `${r.bottom + 6}px`, bottom: 'auto' },
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
setOpen(v => !v)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!open) return
|
|
87
|
+
const onDown = (e: MouseEvent) => {
|
|
88
|
+
const target = e.target as HTMLElement
|
|
89
|
+
if (menuRef.current?.contains(target) || target.closest('.tessera-col-menu-btn')) return
|
|
90
|
+
setOpen(false)
|
|
91
|
+
}
|
|
92
|
+
window.addEventListener('mousedown', onDown)
|
|
93
|
+
return () => window.removeEventListener('mousedown', onDown)
|
|
94
|
+
}, [open])
|
|
95
|
+
|
|
59
96
|
const run = (fn: () => unknown) => {
|
|
97
|
+
// read-only docs must not mutate, however the menu was triggered
|
|
98
|
+
if (!editor.isEditable) {
|
|
99
|
+
setOpen(false)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
60
102
|
setOpen(false)
|
|
61
103
|
// re-select into this cell so table commands locate the table
|
|
62
104
|
const pos = props.getPos()
|
|
@@ -67,7 +109,7 @@ function AiTableHeaderView(props: NodeViewProps) {
|
|
|
67
109
|
}
|
|
68
110
|
|
|
69
111
|
return (
|
|
70
|
-
<NodeViewWrapper as="th" className="tessera-th" data-index={index}>
|
|
112
|
+
<NodeViewWrapper ref={thRef} as="th" className="tessera-th" data-index={index}>
|
|
71
113
|
<NodeViewContent className="tessera-th-content" />
|
|
72
114
|
<button
|
|
73
115
|
type="button"
|
|
@@ -75,12 +117,19 @@ function AiTableHeaderView(props: NodeViewProps) {
|
|
|
75
117
|
contentEditable={false}
|
|
76
118
|
title={t('colMenuTitle')}
|
|
77
119
|
onMouseDown={e => e.preventDefault()}
|
|
78
|
-
onClick={
|
|
120
|
+
onClick={toggle}
|
|
79
121
|
>
|
|
80
122
|
⌄
|
|
81
123
|
</button>
|
|
82
|
-
{open
|
|
83
|
-
|
|
124
|
+
{open && portalRoot
|
|
125
|
+
? createPortal(
|
|
126
|
+
<div
|
|
127
|
+
ref={menuRef}
|
|
128
|
+
className="tessera-popover tessera-col-menu"
|
|
129
|
+
style={{ position: 'fixed', ...style }}
|
|
130
|
+
contentEditable={false}
|
|
131
|
+
onMouseDown={e => e.preventDefault()}
|
|
132
|
+
>
|
|
84
133
|
<div className="tessera-menu-section">
|
|
85
134
|
{TABLE_COLUMN_KINDS.map(kind => (
|
|
86
135
|
<button key={kind} type="button" onClick={() => run(() => editor.commands.setColumnType(index, kind))}>
|
|
@@ -125,8 +174,9 @@ function AiTableHeaderView(props: NodeViewProps) {
|
|
|
125
174
|
<button type="button" className="tessera-danger" onClick={() => run(() => editor.commands.deleteTable())}>
|
|
126
175
|
{t('tableDelete')}
|
|
127
176
|
</button>
|
|
128
|
-
|
|
129
|
-
|
|
177
|
+
</div>,
|
|
178
|
+
portalRoot,
|
|
179
|
+
) : null}
|
|
130
180
|
</NodeViewWrapper>
|
|
131
181
|
)
|
|
132
182
|
}
|
|
@@ -161,7 +211,7 @@ function AiTableCellView(props: NodeViewProps) {
|
|
|
161
211
|
|
|
162
212
|
if (kind === 'text') {
|
|
163
213
|
return (
|
|
164
|
-
<NodeViewWrapper as="td" className="tessera-td">
|
|
214
|
+
<NodeViewWrapper as="td" className="tessera-td" data-index={columnIndex(props)}>
|
|
165
215
|
<NodeViewContent />
|
|
166
216
|
</NodeViewWrapper>
|
|
167
217
|
)
|
|
@@ -170,7 +220,7 @@ function AiTableCellView(props: NodeViewProps) {
|
|
|
170
220
|
const setValue = (next: unknown) => props.updateAttributes({ value: next })
|
|
171
221
|
|
|
172
222
|
return (
|
|
173
|
-
<NodeViewWrapper as="td" className={`tessera-td tessera-td--${kind}`}>
|
|
223
|
+
<NodeViewWrapper as="td" className={`tessera-td tessera-td--${kind}`} data-index={columnIndex(props)}>
|
|
174
224
|
<NodeViewContent className="tessera-td-hidden" />
|
|
175
225
|
{kind === 'checkbox' ? (
|
|
176
226
|
<input
|
package/src/Tessera.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import type { CSSProperties } from 'react'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
3
|
import { EditorContent, useEditor } from '@tiptap/react'
|
|
4
4
|
import type { Editor } from '@tiptap/react'
|
|
5
5
|
import type { Content, EditorOptions } from '@tiptap/core'
|
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
} from '@tessera-editor/core'
|
|
11
11
|
import type {
|
|
12
12
|
TesseraLocale,
|
|
13
|
+
TesseraMessageOverrides,
|
|
14
|
+
TesseraTranslator,
|
|
13
15
|
UploadService,
|
|
14
|
-
StorageService,
|
|
15
16
|
CommentStore,
|
|
16
17
|
IdentityService,
|
|
17
18
|
} from '@tessera-editor/core'
|
|
@@ -23,9 +24,9 @@ import { EmbedBlockView, TocBlockView } from './EmbedTocViews'
|
|
|
23
24
|
import { createSlashRenderer } from './SlashMenu'
|
|
24
25
|
import { createEmojiRenderer } from './EmojiMenu'
|
|
25
26
|
import { EmptyLineToolbar } from './EmptyLineToolbar'
|
|
27
|
+
import { LinkEditor } from './LinkEditor'
|
|
26
28
|
import { SelectionToolbar } from './SelectionToolbar'
|
|
27
29
|
import { FindReplacePanel, AskPanel, SuggestionBar } from './Panels'
|
|
28
|
-
import { HistoryPanel } from './HistoryPanel'
|
|
29
30
|
import { CommentPanel } from './CommentPanel'
|
|
30
31
|
import { BlockContextMenuUI } from './BlockMenu'
|
|
31
32
|
import { TesseraContext } from './context'
|
|
@@ -35,20 +36,33 @@ export interface TesseraProps {
|
|
|
35
36
|
/** Initial document (JSON / HTML / markdown string). Host owns persistence. */
|
|
36
37
|
content?: Content
|
|
37
38
|
locale?: TesseraLocale
|
|
39
|
+
/** Empty-paragraph placeholder text (default: the i18n `placeholderEmpty` string). */
|
|
40
|
+
placeholder?: string
|
|
41
|
+
/** Per-key overrides of the built-in UI dictionary (slash items, tooltips…). */
|
|
42
|
+
messages?: TesseraMessageOverrides
|
|
43
|
+
/** Render the editor read-only (default: editable). */
|
|
44
|
+
editable?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Host block policy: top-level block types to exclude entirely (see
|
|
47
|
+
* `TesseraPresetOptions.excludeBlocks`). Read once at editor creation.
|
|
48
|
+
*/
|
|
49
|
+
excludeBlocks?: string[]
|
|
38
50
|
/** Injected image upload capability (ADR-0001 family). */
|
|
39
51
|
upload?: UploadService
|
|
40
|
-
/** v1.1: version-history snapshot storage. */
|
|
41
|
-
storage?: StorageService
|
|
42
52
|
/** v1.1: inline comment persistence. */
|
|
43
53
|
comments?: CommentStore
|
|
44
54
|
/** v1.1: current user (comment authorship). */
|
|
45
55
|
identity?: IdentityService
|
|
46
56
|
/** Injected model runtime; presence enables all AI surfaces. */
|
|
47
57
|
ai?: AIRuntime
|
|
48
|
-
/** v1.1: idle window for history auto-capture (playground uses short ones) */
|
|
49
|
-
historyIdleMs?: number
|
|
50
58
|
/** v1.1: document column max-width in px (Slite-style centered column). */
|
|
51
59
|
docWidth?: number
|
|
60
|
+
/**
|
|
61
|
+
* Host buttons appended to the selection toolbar (e.g. custom AI actions).
|
|
62
|
+
* A render function receives the live editor + translator, so a host button
|
|
63
|
+
* like 「让 AI 改写此段」 can read the current selection on click.
|
|
64
|
+
*/
|
|
65
|
+
extraSelectionItems?: ReactNode | ((ctx: { editor: Editor; t: TesseraTranslator }) => ReactNode)
|
|
52
66
|
onUpdate?: (editor: Editor) => void
|
|
53
67
|
onCreate?: (editor: Editor) => void
|
|
54
68
|
}
|
|
@@ -68,17 +82,19 @@ const DRAG_HANDLE_POSITION_CONFIG = { placement: 'left', strategy: 'absolute' }
|
|
|
68
82
|
export function Tessera({
|
|
69
83
|
content,
|
|
70
84
|
locale = 'zh-CN',
|
|
85
|
+
placeholder,
|
|
86
|
+
messages,
|
|
87
|
+
editable = true,
|
|
88
|
+
excludeBlocks,
|
|
71
89
|
upload,
|
|
72
|
-
storage,
|
|
73
90
|
comments,
|
|
74
91
|
identity,
|
|
75
92
|
ai: runtime,
|
|
76
|
-
historyIdleMs,
|
|
77
93
|
docWidth,
|
|
94
|
+
extraSelectionItems,
|
|
78
95
|
onUpdate,
|
|
79
96
|
onCreate,
|
|
80
97
|
}: TesseraProps) {
|
|
81
|
-
const t = useMemo(() => createTesseraT(locale), [locale])
|
|
82
98
|
const [session, setSession] = useState<SuggestionSession | null>(null)
|
|
83
99
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
84
100
|
type DragNodeData = {
|
|
@@ -96,19 +112,35 @@ export function Tessera({
|
|
|
96
112
|
// destroys and recreates ALL plugin views. A recreated suggestion view
|
|
97
113
|
// starts from an already-active state with no "started" transition, so
|
|
98
114
|
// renderer.onStart never fires and the slash menu UI never mounts.
|
|
99
|
-
// Latest prop values are therefore read through refs instead
|
|
115
|
+
// Latest prop values are therefore read through refs instead — this also
|
|
116
|
+
// applies to `messages`/`placeholder`/`excludeBlocks`, which hosts may pass
|
|
117
|
+
// as inline literals. Config is read once at editor creation; later changes
|
|
118
|
+
// require remounting the component.
|
|
100
119
|
const onUpdateRef = useRef(onUpdate)
|
|
101
120
|
onUpdateRef.current = onUpdate
|
|
102
121
|
const onCreateRef = useRef(onCreate)
|
|
103
122
|
onCreateRef.current = onCreate
|
|
104
123
|
const runtimeRef = useRef(runtime)
|
|
105
124
|
runtimeRef.current = runtime
|
|
125
|
+
const placeholderRef = useRef(placeholder)
|
|
126
|
+
placeholderRef.current = placeholder
|
|
127
|
+
const messagesRef = useRef(messages)
|
|
128
|
+
messagesRef.current = messages
|
|
129
|
+
const excludeBlocksRef = useRef(excludeBlocks)
|
|
130
|
+
excludeBlocksRef.current = excludeBlocks
|
|
106
131
|
const initialContentRef = useRef(content)
|
|
107
132
|
const editorRef = useRef<Editor | null>(null)
|
|
108
133
|
|
|
134
|
+
const t = useMemo(() => createTesseraT(locale, messagesRef.current), [locale])
|
|
135
|
+
|
|
109
136
|
const extensions = useMemo(
|
|
110
137
|
() =>
|
|
111
|
-
createTesseraExtensions({
|
|
138
|
+
createTesseraExtensions({
|
|
139
|
+
locale,
|
|
140
|
+
placeholder: placeholderRef.current,
|
|
141
|
+
messages: messagesRef.current,
|
|
142
|
+
excludeBlocks: excludeBlocksRef.current,
|
|
143
|
+
}).map(ext => {
|
|
112
144
|
if (ext.name === 'tesseraSlashMenu') {
|
|
113
145
|
return ext.configure({
|
|
114
146
|
render: createSlashRenderer(t),
|
|
@@ -141,7 +173,7 @@ export function Tessera({
|
|
|
141
173
|
// runtime is read through runtimeRef on purpose: rebuilding the extension
|
|
142
174
|
// list after mount cannot apply anyway (extensions are only read when the
|
|
143
175
|
// editor is created) and would only trigger the setOptions churn above
|
|
144
|
-
[locale, t
|
|
176
|
+
[locale, t],
|
|
145
177
|
)
|
|
146
178
|
|
|
147
179
|
async function uploadAndInsert(file: File) {
|
|
@@ -195,11 +227,16 @@ export function Tessera({
|
|
|
195
227
|
)
|
|
196
228
|
|
|
197
229
|
const handleUpdate = useCallback(({ editor: e }: { editor: Editor }) => onUpdateRef.current?.(e), [])
|
|
198
|
-
const handleCreate = useCallback(({ editor: e }: { editor: Editor }) =>
|
|
230
|
+
const handleCreate = useCallback(({ editor: e }: { editor: Editor }) => {
|
|
231
|
+
// legacy docs may carry stray hidden text in typed cells
|
|
232
|
+
e.commands.normalizeTypedCells()
|
|
233
|
+
onCreateRef.current?.(e)
|
|
234
|
+
}, [])
|
|
199
235
|
|
|
200
236
|
const editor = useEditor({
|
|
201
237
|
extensions,
|
|
202
238
|
content: initialContentRef.current,
|
|
239
|
+
editable,
|
|
203
240
|
onUpdate: handleUpdate,
|
|
204
241
|
onCreate: handleCreate,
|
|
205
242
|
editorProps,
|
|
@@ -214,12 +251,18 @@ export function Tessera({
|
|
|
214
251
|
;(editor.storage as unknown as Record<string, Record<string, unknown>>).tesseraServices = {
|
|
215
252
|
...bag,
|
|
216
253
|
upload,
|
|
217
|
-
storage,
|
|
218
254
|
comments,
|
|
219
255
|
identity,
|
|
220
256
|
}
|
|
221
257
|
}
|
|
222
|
-
}, [editor, upload,
|
|
258
|
+
}, [editor, upload, comments, identity])
|
|
259
|
+
|
|
260
|
+
// read-only is a live toggle, not just an initial option
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (editor) {
|
|
263
|
+
editor.setEditable(editable)
|
|
264
|
+
}
|
|
265
|
+
}, [editor, editable])
|
|
223
266
|
|
|
224
267
|
// editor events → UI (image picker, sessions from slash AI items)
|
|
225
268
|
useEffect(() => {
|
|
@@ -246,9 +289,10 @@ export function Tessera({
|
|
|
246
289
|
}
|
|
247
290
|
|
|
248
291
|
return (
|
|
249
|
-
<TesseraContext.Provider value={{ editor, locale, t, ai }}>
|
|
292
|
+
<TesseraContext.Provider value={{ editor, locale, t, ai, extraSelectionItems }}>
|
|
250
293
|
<div
|
|
251
294
|
className="tessera-root"
|
|
295
|
+
data-readonly={editable ? undefined : 'true'}
|
|
252
296
|
style={{ '--te-doc-max-width': docWidth ? `${docWidth}px` : undefined } as CSSProperties}
|
|
253
297
|
>
|
|
254
298
|
<EditorContent editor={editor} />
|
|
@@ -290,9 +334,9 @@ export function Tessera({
|
|
|
290
334
|
</DragHandle>
|
|
291
335
|
<EmptyLineToolbar />
|
|
292
336
|
<SelectionToolbar onSession={setSession} />
|
|
337
|
+
<LinkEditor />
|
|
293
338
|
<FindReplacePanel />
|
|
294
339
|
<AskPanel />
|
|
295
|
-
<HistoryPanel />
|
|
296
340
|
<CommentPanel />
|
|
297
341
|
<BlockContextMenuUI />
|
|
298
342
|
<SuggestionBar session={session} onClear={() => setSession(null)} />
|