@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,166 @@
1
+ import { useContext, useEffect, useState } from 'react'
2
+ import {
3
+ diffDocs,
4
+ diffSummary,
5
+ getStorageService,
6
+ } from '@tessera-editor/core'
7
+ import type { DocSnapshot, BlockDiffEntry } from '@tessera-editor/core'
8
+ import { TesseraContext } from './context'
9
+
10
+ /**
11
+ * Version history panel (v1.1): snapshots from the injected StorageService,
12
+ * block+word level diff against the current doc, one-click restore.
13
+ */
14
+ export function HistoryPanel() {
15
+ const { editor, t } = useContext(TesseraContext)!
16
+ const [visible, setVisible] = useState(false)
17
+ const [snapshots, setSnapshots] = useState<DocSnapshot[]>([])
18
+ const [selected, setSelected] = useState<DocSnapshot | null>(null)
19
+ const [entries, setEntries] = useState<BlockDiffEntry[] | null>(null)
20
+ const [summary, setSummary] = useState({ added: 0, removed: 0, changed: 0 })
21
+
22
+ const refresh = async () => {
23
+ const storage = getStorageService(editor)
24
+ if (!storage) {
25
+ return
26
+ }
27
+ const list = await storage.listSnapshots()
28
+ setSnapshots([...list].sort((a, b) => b.ts - a.ts))
29
+ }
30
+
31
+ useEffect(() => {
32
+ const open = () => {
33
+ setVisible(true)
34
+ void refresh()
35
+ }
36
+ editor.on('tessera:historyPanel', open)
37
+ const saved = () => void refresh()
38
+ editor.on('tessera:snapshotSaved', saved as never)
39
+ return () => {
40
+ editor.off('tessera:historyPanel', open)
41
+ editor.off('tessera:snapshotSaved', saved as never)
42
+ }
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [editor])
45
+
46
+ if (!visible) {
47
+ return null
48
+ }
49
+
50
+ const hasStorage = !!getStorageService(editor)
51
+
52
+ const select = (snap: DocSnapshot) => {
53
+ setSelected(snap)
54
+ const result = diffDocs(snap.doc as never, editor.getJSON() as never)
55
+ setEntries(result)
56
+ setSummary(diffSummary(result.filter(e => e.kind !== 'unchanged')))
57
+ }
58
+
59
+ const restore = (snap: DocSnapshot) => {
60
+ if (!window.confirm(t('historyConfirmRestore'))) {
61
+ return
62
+ }
63
+ editor.commands.setContent(snap.doc as never)
64
+ setVisible(false)
65
+ }
66
+
67
+ return (
68
+ <div className="tessera-history-panel" data-testid="history-panel">
69
+ <div className="tessera-ask-header">
70
+ <span>{t('historyTitle')}</span>
71
+ <div className="tessera-history-actions">
72
+ {hasStorage ? (
73
+ <button type="button" onClick={() => editor.commands.captureSnapshot()}>
74
+ {t('historyCapture')}
75
+ </button>
76
+ ) : null}
77
+ <button type="button" onClick={() => setVisible(false)}>
78
+ ×
79
+ </button>
80
+ </div>
81
+ </div>
82
+ {!hasStorage ? (
83
+ <div className="tessera-ask-error">{t('aiRuntimeMissing').replace('AI Runtime', 'StorageService')}</div>
84
+ ) : null}
85
+ <div className="tessera-history-body">
86
+ <div className="tessera-history-list">
87
+ {snapshots.length === 0 ? <div className="tessera-toc-empty">{t('historyEmpty')}</div> : null}
88
+ {snapshots.map(snap => (
89
+ <button
90
+ key={snap.id}
91
+ type="button"
92
+ className="tessera-history-item"
93
+ data-selected={selected?.id === snap.id}
94
+ onClick={() => select(snap)}
95
+ >
96
+ <span className="tessera-history-time">{formatTime(snap.ts)}</span>
97
+ {snap.label ? <span className="tessera-history-label">{snap.label}</span> : null}
98
+ <span className="tessera-history-action" role="button" tabIndex={0}
99
+ onClick={e => {
100
+ e.stopPropagation()
101
+ restore(snap)
102
+ }}
103
+ onKeyDown={e => {
104
+ if (e.key === 'Enter') restore(snap)
105
+ }}
106
+ >
107
+ {t('historyRestore')}
108
+ </span>
109
+ </button>
110
+ ))}
111
+ </div>
112
+ {selected ? (
113
+ <div className="tessera-history-diff">
114
+ <div className="tessera-diff-summary">
115
+ +{summary.added} {t('historyDiffAdded')} · −{summary.removed} {t('historyDiffRemoved')} · ~{summary.changed}{' '}
116
+ {t('historyDiffChanged')}
117
+ </div>
118
+ {(entries ?? [])
119
+ .filter(e => e.kind !== 'unchanged')
120
+ .slice(0, 80)
121
+ .map((entry, i) => (
122
+ <div key={entry.id ?? i} className={`tessera-diff-block tessera-diff-block--${entry.kind}`}>
123
+ <span className="tessera-diff-badge">
124
+ {entry.kind === 'added'
125
+ ? t('historyDiffAdded')
126
+ : entry.kind === 'removed'
127
+ ? t('historyDiffRemoved')
128
+ : t('historyDiffChanged')}
129
+ </span>
130
+ {entry.kind === 'changed' && entry.wordDiff ? (
131
+ <span className="tessera-diff-text">
132
+ {entry.wordDiff.map((part, j) => (
133
+ <span key={j} className={`tessera-diff-part tessera-diff-part--${part.type}`}>
134
+ {part.text}
135
+ </span>
136
+ ))}
137
+ </span>
138
+ ) : (
139
+ <span className="tessera-diff-text">{blockText(entry)}</span>
140
+ )}
141
+ </div>
142
+ ))}
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ function blockText(entry: BlockDiffEntry): string {
151
+ const json = entry.after ?? entry.before
152
+ const walk = (node: unknown): string => {
153
+ if (!node || typeof node !== 'object') {
154
+ return ''
155
+ }
156
+ const n = node as { text?: string; content?: unknown[] }
157
+ return (n.text ?? '') + (n.content ?? []).map(walk).join('')
158
+ }
159
+ return walk(json).trim().slice(0, 120)
160
+ }
161
+
162
+ function formatTime(ts: number): string {
163
+ const d = new Date(ts)
164
+ const pad = (n: number) => String(n).padStart(2, '0')
165
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
166
+ }
@@ -0,0 +1,120 @@
1
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
2
+ import type { NodeViewProps } from '@tiptap/react'
3
+ import { ImageBlock } from '@tessera-editor/core'
4
+ import { useContext, useEffect, useRef, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { TesseraContext } from './context'
7
+
8
+ /**
9
+ * Image NodeView (acceptance §6): 悬停边缘箭头调宽、左/中/全宽对齐。
10
+ * Uploads flow through the injected UploadService (Tessera.tsx handles
11
+ * paste/drop/picker); this view only mutates node attributes.
12
+ */
13
+
14
+ function AiImageNodeView({ node, updateAttributes, selected, decorations }: NodeViewProps) {
15
+ console.log('[imgview] decorations:', Array.isArray(decorations), decorations?.length)
16
+ const galleryAttrs = (() => {
17
+ for (const deco of decorations ?? []) {
18
+ const attrs = (deco as unknown as { type?: { attrs?: Record<string, string> } }).type?.attrs
19
+ if (attrs && 'data-gallery' in attrs) {
20
+ return attrs
21
+ }
22
+ }
23
+ return null
24
+ })()
25
+ const { t } = useContext(TesseraContext)!
26
+ const imgRef = useRef<HTMLImageElement>(null)
27
+ const [preview, setPreview] = useState<string | null>(null)
28
+
29
+ useEffect(() => {
30
+ if (!preview) return
31
+ const close = (e: KeyboardEvent) => {
32
+ if (e.key === 'Escape') setPreview(null)
33
+ }
34
+ window.addEventListener('keydown', close)
35
+ return () => window.removeEventListener('keydown', close)
36
+ }, [preview])
37
+ const { src, alt, width, align } = node.attrs as {
38
+ src: string
39
+ alt: string | null
40
+ width: number | null
41
+ align: 'left' | 'center' | 'full'
42
+ }
43
+
44
+ const startResize = (event: React.PointerEvent) => {
45
+ event.preventDefault()
46
+ event.stopPropagation()
47
+ const startX = event.clientX
48
+ const startWidth = imgRef.current?.getBoundingClientRect().width ?? width ?? 400
49
+ const onMove = (e: PointerEvent) => {
50
+ const next = Math.round(Math.max(80, Math.min(startWidth + (e.clientX - startX), 1200)))
51
+ updateAttributes({ width: next })
52
+ }
53
+ const onUp = () => {
54
+ window.removeEventListener('pointermove', onMove)
55
+ window.removeEventListener('pointerup', onUp)
56
+ }
57
+ window.addEventListener('pointermove', onMove)
58
+ window.addEventListener('pointerup', onUp)
59
+ }
60
+
61
+ if (!src) {
62
+ return (
63
+ <NodeViewWrapper className="tessera-image tessera-image-empty" data-selected={selected}>
64
+ <span>{t('imageUploadFailed')}</span>
65
+ </NodeViewWrapper>
66
+ )
67
+ }
68
+
69
+ return (
70
+ <NodeViewWrapper
71
+ className="tessera-image"
72
+ data-align={align}
73
+ data-selected={selected}
74
+ data-gallery={galleryAttrs ? 'true' : undefined}
75
+ data-gallery-index={galleryAttrs?.['data-gallery-index']}
76
+ data-gallery-size={galleryAttrs?.['data-gallery-size']}
77
+ >
78
+ <div className="tessera-image-frame" style={width ? { width: `${width}px` } : undefined}>
79
+ <img
80
+ ref={imgRef}
81
+ src={src}
82
+ alt={alt ?? ''}
83
+ draggable={false}
84
+ style={{ cursor: 'zoom-in' }}
85
+ onClick={e => {
86
+ e.stopPropagation()
87
+ setPreview(src)
88
+ }}
89
+ />
90
+ <div className="tessera-image-resize" onPointerDown={startResize} title="↔" />
91
+ </div>
92
+ {preview
93
+ ? createPortal(
94
+ <div className="tessera-lightbox" onClick={() => setPreview(null)}>
95
+ <img src={preview} alt={alt ?? ''} />
96
+ </div>,
97
+ document.body,
98
+ )
99
+ : null}
100
+ <div className="tessera-image-align">
101
+ <button type="button" title={t('imageAlignLeft')} data-on={align === 'left'} onClick={() => updateAttributes({ align: 'left' })}>
102
+
103
+ </button>
104
+ <button type="button" title={t('imageAlignCenter')} data-on={align === 'center'} onClick={() => updateAttributes({ align: 'center' })}>
105
+
106
+ </button>
107
+ <button type="button" title={t('imageAlignFull')} data-on={align === 'full'} onClick={() => updateAttributes({ align: 'full' })}>
108
+
109
+ </button>
110
+ </div>
111
+ </NodeViewWrapper>
112
+ )
113
+ }
114
+
115
+ /** Binding-level image block: core schema + React NodeView. */
116
+ export const ImageBlockView = ImageBlock.extend({
117
+ addNodeView() {
118
+ return ReactNodeViewRenderer(AiImageNodeView)
119
+ },
120
+ })
package/src/Panels.tsx ADDED
@@ -0,0 +1,205 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react'
2
+ import { findReplaceKey } from '@tessera-editor/core'
3
+ import type { FindReplaceState } from '@tessera-editor/core'
4
+ import type { SuggestionSession } from '@tessera-editor/ai'
5
+ import { TesseraContext } from './context'
6
+
7
+ /** ⌘F find & replace panel — drives the core findReplace commands. */
8
+ export function FindReplacePanel() {
9
+ const { editor, t } = useContext(TesseraContext)!
10
+ const [visible, setVisible] = useState(false)
11
+ const [query, setQuery] = useState('')
12
+ const [replacement, setReplacement] = useState('')
13
+ const [count, setCount] = useState(0)
14
+ const [active, setActive] = useState(0)
15
+ const queryRef = useRef(query)
16
+ queryRef.current = query
17
+
18
+ useEffect(() => {
19
+ const open = () => {
20
+ setVisible(true)
21
+ requestAnimationFrame(() => {
22
+ const input = document.querySelector<HTMLInputElement>('.tessera-find-input')
23
+ input?.focus()
24
+ })
25
+ }
26
+ editor.on('tessera:findPanel', open)
27
+
28
+ const sync = () => {
29
+ const s = findReplaceKey.getState(editor.state) as FindReplaceState | undefined
30
+ if (!s) {
31
+ return
32
+ }
33
+ setVisible(s.visible)
34
+ setCount(s.matches.length)
35
+ setActive(s.active)
36
+ if (s.query !== queryRef.current && !s.visible) {
37
+ setQuery(s.query)
38
+ }
39
+ }
40
+ editor.on('transaction', sync)
41
+ return () => {
42
+ editor.off('tessera:findPanel', open)
43
+ editor.off('transaction', sync)
44
+ }
45
+ }, [editor])
46
+
47
+ if (!visible) {
48
+ return null
49
+ }
50
+
51
+ const updateQuery = (value: string) => {
52
+ setQuery(value)
53
+ editor.commands.setFindQuery(value)
54
+ }
55
+
56
+ return (
57
+ <div className="tessera-find-panel" data-testid="find-panel">
58
+ <input
59
+ className="tessera-find-input"
60
+ autoFocus
61
+ value={query}
62
+ placeholder={t('findPlaceholder')}
63
+ onChange={e => updateQuery(e.target.value)}
64
+ onKeyDown={e => {
65
+ if (e.key === 'Enter') {
66
+ editor.commands.findNext()
67
+ }
68
+ if (e.key === 'Escape') {
69
+ editor.commands.closeFindPanel()
70
+ }
71
+ }}
72
+ />
73
+ <span className="tessera-find-count">{count > 0 ? `${active + 1}/${count}` : '0'}</span>
74
+ <button type="button" title={t('findPrev')} onClick={() => editor.commands.findPrev()}>
75
+
76
+ </button>
77
+ <button type="button" title={t('findNext')} onClick={() => editor.commands.findNext()}>
78
+
79
+ </button>
80
+ <input
81
+ value={replacement}
82
+ placeholder={t('replacePlaceholder')}
83
+ onChange={e => setReplacement(e.target.value)}
84
+ />
85
+ <button type="button" onClick={() => editor.commands.replaceCurrent(replacement)}>
86
+ {t('replaceOne')}
87
+ </button>
88
+ <button type="button" onClick={() => editor.commands.replaceAll(replacement)}>
89
+ {t('replaceAll')}
90
+ </button>
91
+ <button type="button" className="tessera-find-close" title={t('findClose')} onClick={() => editor.commands.closeFindPanel()}>
92
+ ×
93
+ </button>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ /** AI 问答面板(/ask 唤起):流式回答,不写文档。 */
99
+ export function AskPanel() {
100
+ const { editor, t, ai } = useContext(TesseraContext)!
101
+ const [visible, setVisible] = useState(false)
102
+ const [question, setQuestion] = useState('')
103
+ const [answer, setAnswer] = useState('')
104
+ const [thinking, setThinking] = useState(false)
105
+ const [error, setError] = useState<string | null>(null)
106
+
107
+ useEffect(() => {
108
+ const open = () => setVisible(true)
109
+ editor.on('tessera:askPanel', open)
110
+ return () => {
111
+ editor.off('tessera:askPanel', open)
112
+ }
113
+ }, [editor])
114
+
115
+ if (!visible) {
116
+ return null
117
+ }
118
+
119
+ const ask = async () => {
120
+ if (!ai || !question.trim()) {
121
+ return
122
+ }
123
+ setThinking(true)
124
+ setAnswer('')
125
+ setError(null)
126
+ try {
127
+ await ai.ask(question, { onToken: token => setAnswer(prev => prev + token) })
128
+ } catch (err) {
129
+ setError(String(err))
130
+ } finally {
131
+ setThinking(false)
132
+ }
133
+ }
134
+
135
+ return (
136
+ <div className="tessera-ask-panel" data-testid="ask-panel">
137
+ <div className="tessera-ask-header">
138
+ <span>{t('aiTitle')}</span>
139
+ <button type="button" onClick={() => setVisible(false)}>
140
+ ×
141
+ </button>
142
+ </div>
143
+ {ai ? null : <div className="tessera-ask-error">{t('aiRuntimeMissing')}</div>}
144
+ <div className="tessera-ask-body">
145
+ {answer ? <div className="tessera-ask-answer">{answer}</div> : null}
146
+ {thinking ? <div className="tessera-ask-thinking">{t('aiThinking')}</div> : null}
147
+ {error ? <div className="tessera-ask-error">{error}</div> : null}
148
+ </div>
149
+ <div className="tessera-ask-input">
150
+ <input
151
+ value={question}
152
+ placeholder={t('aiAskPlaceholder')}
153
+ onChange={e => setQuestion(e.target.value)}
154
+ onKeyDown={e => {
155
+ if (e.key === 'Enter') {
156
+ ask()
157
+ }
158
+ }}
159
+ />
160
+ <button type="button" disabled={!ai || thinking || !question.trim()} onClick={ask}>
161
+ {t('aiSend')}
162
+ </button>
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ /** "Agent 起草、人批准":pending 建议存在时显示审阅条。 */
169
+ export function SuggestionBar({
170
+ session,
171
+ onClear,
172
+ }: {
173
+ session: SuggestionSession | null
174
+ onClear: () => void
175
+ }) {
176
+ const { t } = useContext(TesseraContext)!
177
+ if (!session) {
178
+ return null
179
+ }
180
+ return (
181
+ <div className="tessera-suggestion-bar" data-testid="suggestion-bar">
182
+ <span className="tessera-suggestion-label">✨ {t('aiStreaming')}</span>
183
+ <button
184
+ type="button"
185
+ className="tessera-suggestion-accept"
186
+ onClick={() => {
187
+ session.accept()
188
+ onClear()
189
+ }}
190
+ >
191
+ {t('aiAccept')}
192
+ </button>
193
+ <button
194
+ type="button"
195
+ className="tessera-suggestion-reject"
196
+ onClick={() => {
197
+ session.reject()
198
+ onClear()
199
+ }}
200
+ >
201
+ {t('aiReject')}
202
+ </button>
203
+ </div>
204
+ )
205
+ }