@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
|
@@ -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'
|
package/src/HistoryPanel.tsx
DELETED
|
@@ -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
|
-
}
|