@tessera-editor/core 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.
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@tessera-editor/core",
3
+ "version": "0.1.0",
4
+ "description": "Tessera framework-agnostic editor core",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "development": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./styles.css": {
16
+ "development": "./src/styles/tessera.css",
17
+ "default": "./dist/tessera.css"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "dependencies": {
25
+ "@tiptap/core": "^3.31.3",
26
+ "@tiptap/extension-color": "^3.31.3",
27
+ "@tiptap/extension-highlight": "^3.31.3",
28
+ "@tiptap/extension-list": "^3.31.3",
29
+ "@tiptap/extension-table": "^3.31.3",
30
+ "@tiptap/extension-text-style": "^3.31.3",
31
+ "@tiptap/extension-unique-id": "^3.31.3",
32
+ "@tiptap/extensions": "^3.31.3",
33
+ "@tiptap/pm": "^3.31.3",
34
+ "@tiptap/starter-kit": "^3.31.3",
35
+ "@tiptap/suggestion": "^3.31.3",
36
+ "markdown-it": "^15.0.1",
37
+ "prosemirror-markdown": "^1.13.7",
38
+ "prosemirror-tables": "^1.8.5"
39
+ },
40
+ "devDependencies": {
41
+ "@types/markdown-it": "^14.2.0",
42
+ "jsdom": "^30.0.1",
43
+ "vitest": "^5.0.0"
44
+ },
45
+ "keywords": [
46
+ "rich-text",
47
+ "editor",
48
+ "tiptap",
49
+ "prosemirror",
50
+ "wysiwyg"
51
+ ],
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/mofishless/tessera-editor.git",
55
+ "directory": "packages/core"
56
+ },
57
+ "homepage": "https://github.com/mofishless/tessera-editor/tree/main/packages/core#readme",
58
+ "bugs": "https://github.com/mofishless/tessera-editor/issues",
59
+ "sideEffects": [
60
+ "*.css"
61
+ ],
62
+ "module": "./dist/index.js",
63
+ "publishConfig": {
64
+ "access": "public",
65
+ "registry": "https://registry.npmjs.org/"
66
+ },
67
+ "scripts": {
68
+ "typecheck": "tsc --noEmit",
69
+ "test": "vitest run",
70
+ "build": "tsup"
71
+ }
72
+ }
@@ -0,0 +1,138 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions } from '../preset'
5
+ import { docToMarkdown, markdownToDoc, stableJson } from '../markdown'
6
+ import type { JSONContent } from '@tiptap/core'
7
+
8
+ function makeEditor() {
9
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
10
+ }
11
+
12
+ const richDoc: JSONContent = {
13
+ type: 'doc',
14
+ content: [
15
+ { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
16
+ {
17
+ type: 'paragraph',
18
+ content: [
19
+ { type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
20
+ { type: 'text', text: ' and ' },
21
+ { type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
22
+ { type: 'text', text: ' and ' },
23
+ { type: 'text', marks: [{ type: 'underline' }], text: 'underline' },
24
+ { type: 'text', text: ' and ' },
25
+ { type: 'text', marks: [{ type: 'strike' }], text: 'strike' },
26
+ { type: 'text', text: ' and ' },
27
+ { type: 'text', marks: [{ type: 'code' }], text: 'inline' },
28
+ { type: 'text', text: ' and ' },
29
+ { type: 'text', marks: [{ type: 'highlight' }], text: 'highlight' },
30
+ { type: 'text', text: ' and a ' },
31
+ { type: 'text', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }], text: 'link' },
32
+ ],
33
+ },
34
+ { type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Quoted' }] }] },
35
+ {
36
+ type: 'bulletList',
37
+ content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet' }] }] }],
38
+ },
39
+ {
40
+ type: 'orderedList',
41
+ content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered' }] }] }],
42
+ },
43
+ {
44
+ type: 'taskList',
45
+ content: [
46
+ { type: 'taskItem', attrs: { checked: true }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done' }] }] },
47
+ { type: 'taskItem', attrs: { checked: false }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo' }] }] },
48
+ ],
49
+ },
50
+ { type: 'codeBlock', attrs: { language: 'ts' }, content: [{ type: 'text', text: 'const x = 1' }] },
51
+ {
52
+ type: 'hint',
53
+ attrs: { variant: 'warning' },
54
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Careful' }] }],
55
+ },
56
+ {
57
+ type: 'collapsible',
58
+ attrs: { open: true },
59
+ content: [
60
+ { type: 'collapsibleSummary', content: [{ type: 'text', text: 'Summary line' }] },
61
+ { type: 'collapsibleContent', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }] },
62
+ ],
63
+ },
64
+ { type: 'imageBlock', attrs: { src: 'https://example.com/a.png', alt: 'pic', width: 320, align: 'center' } },
65
+ { type: 'horizontalRule' },
66
+ ],
67
+ }
68
+
69
+ /** Trailing empty paragraphs (TrailingNode) are whitespace: not preserved in Markdown. */
70
+ function dropTrailingEmptyParagraph(json: JSONContent): JSONContent {
71
+ const content = json.content ? [...json.content] : []
72
+ while (content.length > 0) {
73
+ const last = content[content.length - 1]!
74
+ if (last.type === 'paragraph' && !last.content) {
75
+ content.pop()
76
+ } else {
77
+ break
78
+ }
79
+ }
80
+ return { ...json, content }
81
+ }
82
+
83
+ describe('markdown interchange', () => {
84
+ it('round-trips a representative document (ids stripped)', () => {
85
+ const editor = makeEditor()
86
+ editor.commands.setContent(richDoc)
87
+ const canonical = dropTrailingEmptyParagraph(stableJson(editor.getJSON()))
88
+ const mdText = docToMarkdown(editor.state.doc, editor.state.schema)
89
+ expect(mdText).toContain('#'.repeat(2) + ' Title')
90
+ expect(mdText).toContain('**bold**')
91
+ expect(mdText).toContain('- [x] Done')
92
+ expect(mdText).toContain('data-type="hint"')
93
+ expect(mdText).toContain('data-type="collapsible"')
94
+
95
+ const back = dropTrailingEmptyParagraph(stableJson(markdownToDoc(mdText, editor.state.schema)))
96
+ expect(back).toEqual(canonical)
97
+ editor.destroy()
98
+ })
99
+
100
+ it('imports plain markdown with GFM task lists', () => {
101
+ const editor = makeEditor()
102
+ const json = markdownToDoc('# Hello\n\n- [x] done\n- [ ] todo\n\n> quote', editor.state.schema)
103
+ const types = json.content?.map(n => n.type)
104
+ expect(types).toEqual(['heading', 'taskList', 'blockquote'])
105
+ const task = json.content?.[1]
106
+ expect(task?.content?.map(i => i.attrs?.checked)).toEqual([true, false])
107
+ editor.destroy()
108
+ })
109
+
110
+ it('exports Markdown from canonical JSON without an editor instance doc', () => {
111
+ const editor = makeEditor()
112
+ const mdText = docToMarkdown(richDoc, editor.state.schema)
113
+ expect(mdText).toContain('```ts')
114
+ editor.destroy()
115
+ })
116
+
117
+ it('does not throw on JSON-only marks (textStyle/color, aiAttribution) and keeps the text', () => {
118
+ const editor = makeEditor()
119
+ const doc: JSONContent = {
120
+ type: 'doc',
121
+ content: [
122
+ {
123
+ type: 'paragraph',
124
+ content: [
125
+ { type: 'text', marks: [{ type: 'textStyle', attrs: { color: '#d9414f' } }], text: 'red ' },
126
+ { type: 'text', marks: [{ type: 'aiAttribution', attrs: { pending: true } }], text: 'ai-written' },
127
+ ],
128
+ },
129
+ ],
130
+ }
131
+ // regression: an uncovered mark type made the serializer THROW and crash
132
+ // the host app on export
133
+ const mdText = docToMarkdown(doc, editor.state.schema)
134
+ expect(mdText).toContain('red')
135
+ expect(mdText).toContain('ai-written')
136
+ editor.destroy()
137
+ })
138
+ })
@@ -0,0 +1,184 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions, tableToCsvAt, diffDocs, docToMarkdown, markdownToDoc, stableJson, listCommentRanges } from '../index'
5
+ import type { StorageService, DocSnapshot } from '../index'
6
+
7
+ function makeEditor() {
8
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
9
+ }
10
+
11
+ function buildTable(editor: Editor, rows: { header: string[]; body: string[][] }) {
12
+ const content = [
13
+ {
14
+ type: 'table',
15
+ attrs: { types: ['text', 'number', 'checkbox'] },
16
+ content: [
17
+ {
18
+ type: 'tableRow',
19
+ content: rows.header.map(h => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: h }] }] })),
20
+ },
21
+ ...rows.body.map(cells => ({
22
+ type: 'tableRow',
23
+ content: cells.map((c, i) => ({
24
+ type: 'tableCell',
25
+ attrs: i === 1 ? { value: Number(c) } : i === 2 ? { value: c === 'true' } : undefined,
26
+ content: [{ type: 'paragraph', ...(i === 0 ? { content: [{ type: 'text', text: c }] } : {}) }],
27
+ })),
28
+ })),
29
+ ],
30
+ },
31
+ ]
32
+ editor.commands.setContent({ type: 'doc', content })
33
+ }
34
+
35
+ describe('typed tables', () => {
36
+ it('creates via command and round-trips typed attrs', () => {
37
+ const editor = makeEditor()
38
+ editor.commands.setContent({ type: 'doc', content: [{ type: 'paragraph' }] })
39
+ editor.commands.setTextSelection(1)
40
+ expect(editor.commands.insertTableTyped({ rows: 2, cols: 3, withHeaderRow: true })).toBe(true)
41
+ const table = editor.state.doc.child(0)
42
+ expect(table.type.name).toBe('table')
43
+ // types attr defaults to text per column
44
+ editor.commands.setTextSelection(2)
45
+ expect(editor.commands.setColumnType(1, 'number')).toBe(true)
46
+ const updated = editor.state.doc.child(0)
47
+ expect(updated.attrs.types).toEqual(['text', 'number', 'text'])
48
+ editor.destroy()
49
+ })
50
+
51
+ it('sorts body rows by a number column and keeps header', () => {
52
+ const editor = makeEditor()
53
+ buildTable(editor, {
54
+ header: ['名称', '数量', '完成'],
55
+ body: [
56
+ ['b', '5', 'false'],
57
+ ['a', '9', 'true'],
58
+ ['c', '1', 'false'],
59
+ ],
60
+ })
61
+ editor.commands.setTextSelection(3)
62
+ expect(editor.commands.sortTableByColumn(1, 'asc')).toBe(true)
63
+ const table = editor.state.doc.child(0)
64
+ const firstRow = table.content.child(1)! // skip header
65
+ expect(firstRow.child(0).textContent).toBe('c')
66
+ const lastRow = table.content.child(3)!
67
+ expect(lastRow.child(0).textContent).toBe('a')
68
+ editor.destroy()
69
+ })
70
+
71
+ it('exports CSV with typed formatting', () => {
72
+ const editor = makeEditor()
73
+ buildTable(editor, {
74
+ header: ['名称', '数量', '完成'],
75
+ body: [
76
+ ['a', '9', 'true'],
77
+ ['b', '5', 'false'],
78
+ ],
79
+ })
80
+ editor.commands.setTextSelection(3)
81
+ const csv = tableToCsvAt(editor)
82
+ expect(csv).toContain('名称,数量,完成')
83
+ expect(csv).toContain('a,9,✔')
84
+ expect(csv).toContain('b,5,')
85
+ editor.destroy()
86
+ })
87
+
88
+ it('round-trips through markdown as semantic HTML', () => {
89
+ const editor = makeEditor()
90
+ buildTable(editor, {
91
+ header: ['名称', '数量', '完成'],
92
+ body: [['a', '9', 'true']],
93
+ })
94
+ const dropTrailing = (json: import("@tiptap/core").JSONContent) => {
95
+ const content = [...(json.content ?? [])]
96
+ while (content.length && content[content.length - 1]!.type === 'paragraph' && !content[content.length - 1]!.content) {
97
+ content.pop()
98
+ }
99
+ return { ...json, content }
100
+ }
101
+ const canonical = stableJson(dropTrailing(editor.getJSON()))
102
+ const mdText = docToMarkdown(editor.state.doc, editor.state.schema)
103
+ expect(mdText).toContain('<table')
104
+ expect(mdText).toContain('data-types')
105
+ const back = stableJson(dropTrailing(markdownToDoc(mdText, editor.state.schema)))
106
+ expect(back).toEqual(canonical)
107
+ editor.destroy()
108
+ })
109
+ })
110
+
111
+ describe('diff', () => {
112
+ it('classifies added / removed / changed blocks by id', () => {
113
+ const before = {
114
+ type: 'doc',
115
+ content: [
116
+ { type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'hello world' }] },
117
+ { type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'gone' }] },
118
+ ],
119
+ }
120
+ const after = {
121
+ type: 'doc',
122
+ content: [
123
+ { type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'hello there' }] },
124
+ { type: 'paragraph', attrs: { id: 'p3' }, content: [{ type: 'text', text: 'new block' }] },
125
+ ],
126
+ }
127
+ const entries = diffDocs(before, after)
128
+ const byId = Object.fromEntries(entries.filter(e => e.id).map(e => [e.id, e.kind]))
129
+ expect(byId.p1).toBe('changed')
130
+ expect(byId.p2).toBe('removed')
131
+ expect(byId.p3).toBe('added')
132
+ const changed = entries.find(e => e.id === 'p1')!
133
+ expect(changed.wordDiff?.some(p => p.type === 'del' && p.text.includes('world'))).toBe(true)
134
+ expect(changed.wordDiff?.some(p => p.type === 'add' && p.text.includes('there'))).toBe(true)
135
+ })
136
+ })
137
+
138
+ describe('history snapshots', () => {
139
+ it('captures into the injected storage on command', async () => {
140
+ const saved: DocSnapshot[] = []
141
+ const storage: StorageService = {
142
+ saveSnapshot: async snap => {
143
+ saved.push(snap)
144
+ },
145
+ listSnapshots: async () => saved,
146
+ }
147
+ const editor = makeEditor()
148
+ ;(editor.storage as unknown as Record<string, unknown>).tesseraServices = { storage }
149
+ editor.commands.setContent({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'v1' }] }] })
150
+ expect(editor.commands.captureSnapshot('manual')).toBe(true)
151
+ await new Promise(r => setTimeout(r, 20))
152
+ expect(saved).toHaveLength(1)
153
+ expect(saved[0]!.label).toBe('manual')
154
+ expect(JSON.stringify(saved[0]!.doc)).toContain('v1')
155
+ editor.destroy()
156
+ })
157
+ })
158
+
159
+ describe('comments', () => {
160
+ it('adds, resolves and lists comment threads in doc order', () => {
161
+ const editor = makeEditor()
162
+ editor.commands.setContent({
163
+ type: 'doc',
164
+ content: [
165
+ { type: 'paragraph', content: [{ type: 'text', text: 'first second' }] },
166
+ ],
167
+ })
168
+ editor.commands.setTextSelection({ from: 1, to: 6 })
169
+ expect(editor.commands.addCommentThread('t1')).toBe(true)
170
+ editor.commands.setTextSelection({ from: 7, to: 13 })
171
+ expect(editor.commands.addCommentThread('t2')).toBe(true)
172
+
173
+ const ranges = listCommentRanges(editor.state)
174
+ expect(ranges.map(r => r.threadId)).toEqual(['t1', 't2'])
175
+
176
+ expect(editor.commands.setCommentResolved('t1', true)).toBe(true)
177
+ const after = listCommentRanges(editor.state)
178
+ expect(after.find(r => r.threadId === 't1')?.resolved).toBe(true)
179
+
180
+ expect(editor.commands.removeCommentThread('t2')).toBe(true)
181
+ expect(listCommentRanges(editor.state).map(r => r.threadId)).toEqual(['t1'])
182
+ editor.destroy()
183
+ })
184
+ })
@@ -0,0 +1,184 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions } from '../preset'
5
+ import { EMOJI_ITEMS, filterEmojiItems } from '../extensions/emoji'
6
+ import { findGalleryRuns } from '../extensions/gallery'
7
+ import { getTesseraMetrics } from '../extensions/metrics'
8
+ import { isWordHtml, cleanWordHtml } from '../wordpaste'
9
+ import { findBlockPosById, deleteBlockById, blockAnchorUrl } from '../blockmenu'
10
+
11
+ function makeEditor() {
12
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
13
+ }
14
+
15
+ describe('emoji picker data + filtering', () => {
16
+ it('every item has a char and a name', () => {
17
+ expect(EMOJI_ITEMS.length).toBeGreaterThan(50)
18
+ for (const item of EMOJI_ITEMS) {
19
+ expect(item.char.length).toBeGreaterThan(0)
20
+ expect(item.name.length).toBeGreaterThan(0)
21
+ expect(Array.isArray(item.keywords)).toBe(true)
22
+ }
23
+ })
24
+
25
+ it('empty query returns everything; keyword query filters; junk query empties', () => {
26
+ expect(filterEmojiItems('')).toHaveLength(EMOJI_ITEMS.length)
27
+ const smiles = filterEmojiItems('smile')
28
+ expect(smiles.length).toBeGreaterThan(0)
29
+ expect(smiles.every(i => i.name.includes('smile') || i.keywords.some(k => k.includes('smile')))).toBe(true)
30
+ expect(filterEmojiItems('zzzznotanemoji')).toHaveLength(0)
31
+ })
32
+
33
+ it('keywords search covers chinese-adjacent semantics via name (rocket etc.)', () => {
34
+ expect(filterEmojiItems('rocket').map(i => i.char)).toContain('🚀')
35
+ expect(filterEmojiItems('done').map(i => i.char)).toContain('✅')
36
+ })
37
+ })
38
+
39
+ describe('image gallery runs', () => {
40
+ function docWith(...blocks: ('p' | 'img')[]) {
41
+ return {
42
+ type: 'doc',
43
+ content: blocks.map(b =>
44
+ b === 'img'
45
+ ? { type: 'imageBlock', attrs: { src: 'https://example.com/x.png' } }
46
+ : { type: 'paragraph', content: [{ type: 'text', text: 'sep' }] },
47
+ ),
48
+ }
49
+ }
50
+
51
+ it('flags runs of >= 2 consecutive images only', () => {
52
+ const editor = makeEditor()
53
+ editor.commands.setContent(docWith('p', 'img', 'img', 'p', 'img', 'img', 'img', 'p'))
54
+ const runs = findGalleryRuns(editor.state.doc)
55
+ expect(runs.map(r => r.size)).toEqual([2, 3])
56
+ editor.destroy()
57
+ })
58
+
59
+ it('single images do not form a gallery', () => {
60
+ const editor = makeEditor()
61
+ editor.commands.setContent(docWith('img', 'p', 'img'))
62
+ expect(findGalleryRuns(editor.state.doc)).toHaveLength(0)
63
+ editor.destroy()
64
+ })
65
+ })
66
+
67
+ describe('word paste cleaning rules', () => {
68
+ it('detects Word sources', () => {
69
+ expect(isWordHtml('<p xmlns:w="urn:schemas-microsoft-com:office:word">x</p>')).toBe(true)
70
+ expect(isWordHtml('<p class="MsoNormal">x</p>')).toBe(true)
71
+ expect(isWordHtml('<p style="mso-list:l0">x</p>')).toBe(true)
72
+ expect(isWordHtml('<p>plain text</p>')).toBe(false)
73
+ })
74
+
75
+ const rules: Array<{ name: string; input: string; expected: string }> = [
76
+ {
77
+ name: 'R1 drops conditional comments',
78
+ input: '<!--[if gte mso 9]><xml><w:WordDocument></w:WordDocument></xml><![endif]--><p>Hi</p>',
79
+ expected: '<p>Hi</p>',
80
+ },
81
+ {
82
+ name: 'R2 drops style blocks and meta tags',
83
+ input: '<style>.MsoNormal{margin:0}</style><meta charset="utf-8"><p>Hi</p>',
84
+ expected: '<p>Hi</p>',
85
+ },
86
+ {
87
+ name: 'R3 strips office namespace tags',
88
+ input: '<o:p><w:sdt>Hi</w:sdt></o:p>',
89
+ expected: 'Hi',
90
+ },
91
+ {
92
+ name: 'R4 drops class/style/lang attributes, keeps semantic tags',
93
+ input: '<p class="MsoNormal" style="margin:0in"><b>Bold</b><i>Italic</i></p>',
94
+ expected: '<p><b>Bold</b><i>Italic</i></p>',
95
+ },
96
+ {
97
+ name: 'R6 collapses non-breaking spaces',
98
+ input: '<p>a&nbsp;b</p>',
99
+ expected: '<p>a b</p>',
100
+ },
101
+ {
102
+ name: 'R7 removes empty paragraphs',
103
+ input: '<p>a</p><p></p><p><br></p><p> </p><p>b</p>',
104
+ expected: '<p>a</p><p>b</p>',
105
+ },
106
+ ]
107
+
108
+ for (const rule of rules) {
109
+ it(rule.name, () => {
110
+ expect(cleanWordHtml(rule.input)).toBe(rule.expected)
111
+ })
112
+ }
113
+
114
+ it('keeps bold/italic semantics for PM to parse', () => {
115
+ const editor = makeEditor()
116
+ const cleaned = cleanWordHtml(
117
+ '<p class="MsoNormal"><span style="font-weight:bold">Word</span> paste <b>test</b></p>',
118
+ )
119
+ editor.commands.setContent(`<div>${cleaned}</div>`)
120
+ const text = editor.state.doc.textContent
121
+ expect(text).toContain('Word')
122
+ expect(text).toContain('paste')
123
+ editor.destroy()
124
+ })
125
+ })
126
+
127
+ describe('performance metrics', () => {
128
+ it('counts blocks/words/images/tables and serializes within budget', () => {
129
+ const editor = makeEditor()
130
+ editor.commands.setContent({
131
+ type: 'doc',
132
+ content: [
133
+ { type: 'paragraph', content: [{ type: 'text', text: 'one two three' }] },
134
+ { type: 'imageBlock', attrs: { src: 'https://example.com/a.png' } },
135
+ { type: 'paragraph', content: [{ type: 'text', text: 'four five' }] },
136
+ ],
137
+ })
138
+ const before = getTesseraMetrics(editor)
139
+ expect(before.blocks).toBe(3)
140
+ expect(before.words).toBe(5)
141
+ expect(before.images).toBe(1)
142
+ expect(before.tables).toBe(0)
143
+ expect(before.serializeMs).toBeGreaterThanOrEqual(0)
144
+
145
+ editor.commands.setTextSelection(editor.state.doc.content.size - 2)
146
+ editor.commands.insertTableTyped({ rows: 2, cols: 2, withHeaderRow: true })
147
+ const after = getTesseraMetrics(editor)
148
+ expect(after.tables).toBe(1)
149
+ editor.destroy()
150
+ })
151
+ })
152
+
153
+ describe('block menu helpers', () => {
154
+ it('finds blocks by stable id and computes anchor urls', () => {
155
+ const editor = makeEditor()
156
+ editor.commands.setContent({
157
+ type: 'doc',
158
+ content: [
159
+ { type: 'paragraph', attrs: { id: 'block-a' }, content: [{ type: 'text', text: 'A' }] },
160
+ { type: 'paragraph', attrs: { id: 'block-b' }, content: [{ type: 'text', text: 'B' }] },
161
+ ],
162
+ })
163
+ expect(findBlockPosById(editor, 'block-b')).toBeGreaterThan(0)
164
+ expect(findBlockPosById(editor, 'missing')).toBeNull()
165
+ expect(blockAnchorUrl('block-a')).toMatch(/#block-block-a$/)
166
+ editor.destroy()
167
+ })
168
+
169
+ it('deletes a block by id and reports misses', () => {
170
+ const editor = makeEditor()
171
+ editor.commands.setContent({
172
+ type: 'doc',
173
+ content: [
174
+ { type: 'paragraph', attrs: { id: 'keep' }, content: [{ type: 'text', text: 'keep' }] },
175
+ { type: 'paragraph', attrs: { id: 'drop' }, content: [{ type: 'text', text: 'drop' }] },
176
+ ],
177
+ })
178
+ expect(deleteBlockById(editor, 'missing')).toBe(false)
179
+ expect(deleteBlockById(editor, 'drop')).toBe(true)
180
+ expect(editor.state.doc.childCount).toBe(1)
181
+ expect(editor.state.doc.textContent).toBe('keep')
182
+ editor.destroy()
183
+ })
184
+ })