@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tessera-editor/react",
3
- "version": "0.1.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.0",
34
- "@tessera-editor/core": "0.1.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",
@@ -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
- function buildItems(editor: Editor, t: TesseraTranslator, hasAi: boolean): SlashMenuItem[] {
21
- const base = defaultSlashItems(t)
22
- if (!hasAi) {
23
- return base
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, ai } = useContext(TesseraContext)!
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, !!ai)
147
+ const items = buildItems(editor, t)
151
148
 
152
149
  const runItem = (item: SlashMenuItem) => {
153
150
  const { $from } = editor.state.selection
@@ -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
- return () => window.removeEventListener('keydown', close)
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 src={preview} alt={alt ?? ''} />
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
+ }
@@ -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
- <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>
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">
@@ -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={() => setOpen(v => !v)}
120
+ onClick={toggle}
79
121
  >
80
122
 
81
123
  </button>
82
- {open ? (
83
- <div className="tessera-popover tessera-col-menu" contentEditable={false} onMouseDown={e => e.preventDefault()}>
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
- </div>
129
- ) : null}
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({ locale, historyIdleMs }).map(ext => {
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, historyIdleMs],
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 }) => onCreateRef.current?.(e), [])
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, storage, comments, identity])
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)} />