@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.
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeAll } from 'vitest'
3
+ import { render, waitFor, act } from '@testing-library/react'
4
+ import { Tessera } from '../Tessera'
5
+ import type { Editor } from '@tiptap/core'
6
+
7
+ /**
8
+ * Host configuration surface on the React binding: read-only mode, custom
9
+ * placeholder, block exclusion, and host buttons in the selection toolbar.
10
+ */
11
+
12
+ const doc = {
13
+ type: 'doc',
14
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
15
+ }
16
+
17
+ beforeAll(() => {
18
+ // jsdom implements no layout: prosemirror-view's coordsAtPos (used by
19
+ // scrollIntoView and the selection toolbar positioning) needs Range rects.
20
+ const rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 }
21
+ ;(Range.prototype as unknown as { getClientRects: () => unknown }).getClientRects = () => [rect]
22
+ ;(Range.prototype as unknown as { getBoundingClientRect: () => unknown }).getBoundingClientRect = () => rect
23
+ })
24
+
25
+ /** Select "Hello" and focus the view so the selection toolbar repositions in. */
26
+ async function selectText(editor: Editor | null): Promise<void> {
27
+ await act(async () => {
28
+ editor!.commands.setTextSelection({ from: 1, to: 6 })
29
+ editor!.commands.focus()
30
+ })
31
+ }
32
+
33
+ describe('host configuration surface', () => {
34
+ it('editable=false renders a read-only editor', async () => {
35
+ let editor: Editor | null = null
36
+ const { container } = render(<Tessera content={doc} editable={false} onCreate={ed => (editor = ed)} />)
37
+ await waitFor(() => expect(editor).not.toBeNull())
38
+ expect(container.querySelector('.ProseMirror')?.getAttribute('contenteditable')).toBe('false')
39
+ })
40
+
41
+ it('editable defaults to true', async () => {
42
+ let editor: Editor | null = null
43
+ const { container } = render(<Tessera content={doc} onCreate={ed => (editor = ed)} />)
44
+ await waitFor(() => expect(editor).not.toBeNull())
45
+ expect(container.querySelector('.ProseMirror')?.getAttribute('contenteditable')).toBe('true')
46
+ })
47
+
48
+ it('placeholder overrides the empty-paragraph hint', async () => {
49
+ let editor: Editor | null = null
50
+ const { container } = render(<Tessera placeholder="从需求背景写起" onCreate={ed => (editor = ed)} />)
51
+ await waitFor(() => expect(editor).not.toBeNull())
52
+ await waitFor(() => {
53
+ expect(container.querySelector('[data-placeholder="从需求背景写起"]')).not.toBeNull()
54
+ })
55
+ })
56
+
57
+ it('excludeBlocks removes the node types from the mounted schema', async () => {
58
+ let editor: Editor | null = null
59
+ render(
60
+ <Tessera
61
+ excludeBlocks={['hint', 'collapsible', 'embedBlock', 'tocBlock', 'horizontalRule']}
62
+ onCreate={ed => (editor = ed)}
63
+ />,
64
+ )
65
+ await waitFor(() => expect(editor).not.toBeNull())
66
+ expect(editor!.state.schema.nodes.hint).toBeUndefined()
67
+ expect(editor!.state.schema.nodes.collapsible).toBeUndefined()
68
+ expect(editor!.state.schema.nodes.horizontalRule).toBeUndefined()
69
+ expect(editor!.state.schema.nodes.table).toBeDefined()
70
+ expect(editor!.state.schema.nodes.imageBlock).toBeDefined()
71
+ })
72
+
73
+ it('extraSelectionItems renders in the selection toolbar on text selection', async () => {
74
+ let editor: Editor | null = null
75
+ const { container } = render(
76
+ <Tessera
77
+ content={doc}
78
+ onCreate={ed => (editor = ed)}
79
+ extraSelectionItems={<button type="button">让 AI 改写此段</button>}
80
+ />,
81
+ )
82
+ await waitFor(() => expect(editor).not.toBeNull())
83
+ await selectText(editor)
84
+ await waitFor(() => {
85
+ const toolbar = container.querySelector('[data-testid="selection-toolbar"]')
86
+ expect(toolbar, '选区工具栏应出现').not.toBeNull()
87
+ expect(toolbar!.textContent).toContain('让 AI 改写此段')
88
+ })
89
+ })
90
+
91
+ it('comment button is hidden without an injected CommentStore', async () => {
92
+ let editor: Editor | null = null
93
+ const { container } = render(<Tessera content={doc} onCreate={ed => (editor = ed)} />)
94
+ await waitFor(() => expect(editor).not.toBeNull())
95
+ await selectText(editor)
96
+ await waitFor(() => {
97
+ expect(container.querySelector('[data-testid="selection-toolbar"]')).not.toBeNull()
98
+ })
99
+ const toolbar = container.querySelector('[data-testid="selection-toolbar"]')!
100
+ expect(toolbar.textContent).not.toContain('💬')
101
+ // collapsible node is present by default → its button stays
102
+ expect(toolbar.textContent).toContain('▸')
103
+ })
104
+
105
+ it('collapsible button is hidden when collapsible is excluded', async () => {
106
+ let editor: Editor | null = null
107
+ const { container } = render(
108
+ <Tessera content={doc} excludeBlocks={['collapsible']} onCreate={ed => (editor = ed)} />,
109
+ )
110
+ await waitFor(() => expect(editor).not.toBeNull())
111
+ await selectText(editor)
112
+ await waitFor(() => {
113
+ expect(container.querySelector('[data-testid="selection-toolbar"]')).not.toBeNull()
114
+ })
115
+ expect(container.querySelector('[data-testid="selection-toolbar"]')!.textContent).not.toContain('▸')
116
+ })
117
+ })
@@ -0,0 +1,4 @@
1
+ // jsdom implements no layout, so it lacks Element.scrollIntoView entirely —
2
+ // any component whose effects call it would crash under test. Components
3
+ // under test here (slash/emoji menus) call it to follow keyboard highlight.
4
+ Element.prototype.scrollIntoView = () => {}
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, act } from '@testing-library/react'
3
+ import { createRef } from 'react'
4
+ import { SlashMenuView, type SlashMenuViewProps } from '../SlashMenu'
5
+ import type { SlashMenuItem } from '@tessera-editor/core'
6
+
7
+ /**
8
+ * Regression: ArrowDown past the menu's visible fold must scroll the
9
+ * highlighted item back into view. The follow logic lives in an effect keyed
10
+ * on selectedIndex that queries [data-selected="true"] inside the menu
11
+ * container — which only works when the container ref is actually attached.
12
+ */
13
+
14
+ const items: SlashMenuItem[] = Array.from({ length: 40 }, (_, i) => ({
15
+ id: `item-${i}`,
16
+ group: 'basic',
17
+ title: `Item ${i}`,
18
+ command: () => {},
19
+ }))
20
+
21
+ const scrollSpy = vi.fn()
22
+
23
+ beforeEach(() => {
24
+ scrollSpy.mockClear()
25
+ ;(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollSpy
26
+ })
27
+
28
+ describe('SlashMenuView keyboard scroll follow', () => {
29
+ it('keeps the highlighted item in view when navigating past the fold', () => {
30
+ const ref = createRef<{ onKeyDown: (props: { event: KeyboardEvent }) => boolean }>()
31
+ const props = {
32
+ items,
33
+ command: vi.fn(),
34
+ clientRect: () => new DOMRect(100, 200, 8, 18),
35
+ t: (key: string) => key,
36
+ } as unknown as SlashMenuViewProps
37
+ const { container } = render(<SlashMenuView ref={ref} {...props} />)
38
+
39
+ const menu = container.querySelector('[data-testid="slash-menu"]')
40
+ expect(menu).toBeTruthy()
41
+
42
+ act(() => {
43
+ for (let i = 0; i < 25; i++) {
44
+ ref.current!.onKeyDown({ event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) })
45
+ }
46
+ })
47
+
48
+ const selected = menu!.querySelector('[data-selected="true"]')
49
+ expect(selected?.textContent).toContain('Item 25')
50
+ expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest' })
51
+ expect(scrollSpy.mock.contexts.some(el => el === selected)).toBe(true)
52
+ })
53
+ })
package/src/context.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from 'react'
2
+ import type { ReactNode } from 'react'
2
3
  import type { Editor } from '@tiptap/react'
3
4
  import type { TesseraLocale, TesseraTranslator } from '@tessera-editor/core'
4
5
  import type { AiController } from '@tessera-editor/ai'
@@ -8,6 +9,12 @@ export interface TesseraContextValue {
8
9
  locale: TesseraLocale
9
10
  t: TesseraTranslator
10
11
  ai: AiController | null
12
+ /**
13
+ * Host buttons appended to the selection toolbar (e.g. custom AI actions).
14
+ * A render function receives the live editor + translator, so a host button
15
+ * like 「让 AI 改写此段」 can read the current selection on click.
16
+ */
17
+ extraSelectionItems?: ReactNode | ((ctx: { editor: Editor; t: TesseraTranslator }) => ReactNode)
11
18
  }
12
19
 
13
20
  export const TesseraContext = createContext<TesseraContextValue | null>(null)
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { Tessera } from './Tessera'
2
2
  export type { TesseraProps } from './Tessera'
3
+ export type { Editor } from '@tiptap/react'
4
+ export type { Content, JSONContent } from '@tiptap/core'
3
5
  export { EmptyLineToolbar } from './EmptyLineToolbar'
4
6
  export { SelectionToolbar } from './SelectionToolbar'
5
7
  export { SlashMenuView, createSlashRenderer } from './SlashMenu'
6
8
  export { FindReplacePanel, AskPanel, SuggestionBar } from './Panels'
7
- export { HistoryPanel } from './HistoryPanel'
8
9
  export { CommentPanel, CommentComposer } from './CommentPanel'
9
10
  export { BlockContextMenuUI } from './BlockMenu'
10
11
  export { ImageBlockView } from './ImageNodeView'
@@ -1,166 +0,0 @@
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
- }