@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.
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { createTesseraExtensions } from '../preset'
5
+ import {
6
+ getTopLevelBlocks,
7
+ getBlockJson,
8
+ appendBlocks,
9
+ modifyRange,
10
+ removeBlocks,
11
+ } from '../writeback'
12
+
13
+ function makeEditor() {
14
+ return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
15
+ }
16
+
17
+ const twoParas = {
18
+ type: 'doc',
19
+ content: [
20
+ { type: 'paragraph', content: [{ type: 'text', text: 'Alpha' }] },
21
+ { type: 'paragraph', content: [{ type: 'text', text: 'Beta' }] },
22
+ { type: 'paragraph', content: [{ type: 'text', text: 'Gamma' }] },
23
+ ],
24
+ }
25
+
26
+ describe('write-back protocol', () => {
27
+ it('assigns stable ids to top-level blocks', () => {
28
+ const editor = makeEditor()
29
+ editor.commands.setContent(twoParas)
30
+ const blocks = getTopLevelBlocks(editor)
31
+ expect(blocks).toHaveLength(3)
32
+ expect(blocks.every(b => typeof b.id === 'string' && b.id!.length > 0)).toBe(true)
33
+ editor.destroy()
34
+ })
35
+
36
+ it('ids survive unrelated edits', () => {
37
+ const editor = makeEditor()
38
+ editor.commands.setContent(twoParas)
39
+ const before = getTopLevelBlocks(editor).map(b => b.id)
40
+ editor.commands.focus('end')
41
+ editor.commands.insertContent(' tail')
42
+ const after = getTopLevelBlocks(editor).map(b => b.id)
43
+ expect(after).toEqual(before)
44
+ editor.destroy()
45
+ })
46
+
47
+ it('getBlockJson returns the block subtree', () => {
48
+ const editor = makeEditor()
49
+ editor.commands.setContent(twoParas)
50
+ const first = getTopLevelBlocks(editor)[0]!
51
+ const json = getBlockJson(editor, first.id as string)
52
+ expect(json?.type).toBe('paragraph')
53
+ expect(json?.content?.[0]?.text).toBe('Alpha')
54
+ editor.destroy()
55
+ })
56
+
57
+ it('appendBlocks appends at end and after a given id', () => {
58
+ const editor = makeEditor()
59
+ editor.commands.setContent(twoParas)
60
+ const blocks = getTopLevelBlocks(editor)
61
+
62
+ expect(appendBlocks(editor, { content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Delta' }] }] })).toBe(true)
63
+ expect(editor.state.doc.childCount).toBe(4)
64
+
65
+ const second = blocks[1]!
66
+ expect(appendBlocks(editor, { afterId: second.id as string, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inserted' }] }] })).toBe(true)
67
+ const texts = getTopLevelBlocks(editor).map(b => b.node.textContent)
68
+ expect(texts).toEqual(['Alpha', 'Beta', 'Inserted', 'Gamma', 'Delta'])
69
+ editor.destroy()
70
+ })
71
+
72
+ it('modifyRange replaces an inclusive id range with new content', () => {
73
+ const editor = makeEditor()
74
+ editor.commands.setContent(twoParas)
75
+ const blocks = getTopLevelBlocks(editor)
76
+
77
+ expect(
78
+ modifyRange(editor, {
79
+ fromId: blocks[0]!.id as string,
80
+ toId: blocks[1]!.id as string,
81
+ content: [{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Replaced' }] }],
82
+ }),
83
+ ).toBe(true)
84
+
85
+ const after = getTopLevelBlocks(editor)
86
+ expect(after).toHaveLength(2)
87
+ expect(after[0]!.type).toBe('heading')
88
+ expect(after[0]!.node.textContent).toBe('Replaced')
89
+ expect(after[1]!.node.textContent).toBe('Gamma')
90
+ editor.destroy()
91
+ })
92
+
93
+ it('removeBlocks deletes blocks by ids in one step', () => {
94
+ const editor = makeEditor()
95
+ editor.commands.setContent(twoParas)
96
+ const ids = getTopLevelBlocks(editor)
97
+ .slice(0, 2)
98
+ .map(b => b.id as string)
99
+ expect(removeBlocks(editor, ids)).toBe(true)
100
+ const after = getTopLevelBlocks(editor)
101
+ expect(after).toHaveLength(1)
102
+ expect(after[0]!.node.textContent).toBe('Gamma')
103
+ editor.destroy()
104
+ })
105
+
106
+ it('modifyRange as one transaction = one undo step (with realistic spacing)', async () => {
107
+ const editor = makeEditor()
108
+ editor.commands.setContent(twoParas)
109
+ // prosemirror-history merges adjacent edits within newGroupDelay (500ms);
110
+ // space the operations like real usage so the write-back owns its undo step.
111
+ await new Promise(resolve => setTimeout(resolve, 550))
112
+ const blocks = getTopLevelBlocks(editor)
113
+ modifyRange(editor, {
114
+ fromId: blocks[0]!.id as string,
115
+ toId: blocks[2]!.id as string,
116
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Solo' }] }],
117
+ })
118
+ expect(editor.state.doc.childCount).toBe(1)
119
+ editor.commands.undo()
120
+ expect(editor.state.doc.childCount).toBe(3)
121
+ editor.destroy()
122
+ })
123
+ })
@@ -0,0 +1,38 @@
1
+ import type { Editor } from '@tiptap/core'
2
+
3
+ /**
4
+ * Block menu helpers shared by the right-click context menu and the drag
5
+ * handle menu (acceptance ยง5). Pure functions so both bindings โ€” and the
6
+ * tests โ€” operate on the same logic.
7
+ */
8
+
9
+ /** Resolves the document position of a top-level block by its stable id. */
10
+ export function findBlockPosById(editor: Editor, id: string): number | null {
11
+ let found: number | null = null
12
+ editor.state.doc.forEach((node, offset) => {
13
+ if (found === null && node.attrs.id === id) {
14
+ found = offset
15
+ }
16
+ })
17
+ return found
18
+ }
19
+
20
+ /** Removes a top-level block by its stable id (single transaction). */
21
+ export function deleteBlockById(editor: Editor, id: string): boolean {
22
+ const pos = findBlockPosById(editor, id)
23
+ if (pos === null) {
24
+ return false
25
+ }
26
+ const node = editor.state.doc.nodeAt(pos)
27
+ if (!node) {
28
+ return false
29
+ }
30
+ const tr = editor.state.tr.delete(pos, pos + node.nodeSize)
31
+ editor.view.dispatch(tr)
32
+ return true
33
+ }
34
+
35
+ /** Anchor URL for a block id (`#block-<id>` on the current page). */
36
+ export function blockAnchorUrl(blockId: string): string {
37
+ return `${location.origin}${location.pathname}#block-${blockId}`
38
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { JSONContent } from '@tiptap/core'
2
+
3
+ /**
4
+ * Document diff (v1.1 history panel): block-level via stable ids (LCS on the
5
+ * id sequence) + word-level inside changed text blocks. Pure utility โ€” the
6
+ * panel renders it.
7
+ */
8
+
9
+ export interface WordDiffPart {
10
+ text: string
11
+ type: 'same' | 'add' | 'del'
12
+ }
13
+
14
+ export interface BlockDiffEntry {
15
+ kind: 'added' | 'removed' | 'changed' | 'unchanged'
16
+ id?: string
17
+ before?: JSONContent
18
+ after?: JSONContent
19
+ wordDiff?: WordDiffPart[]
20
+ }
21
+
22
+ function topLevel(doc: JSONContent): JSONContent[] {
23
+ return doc.content ?? []
24
+ }
25
+
26
+ function blockId(block: JSONContent): string | null {
27
+ const id = block.attrs?.id
28
+ return typeof id === 'string' ? id : null
29
+ }
30
+
31
+ function collectText(node: JSONContent): string {
32
+ let text = ''
33
+ if (node.text) {
34
+ text += node.text
35
+ }
36
+ for (const child of node.content ?? []) {
37
+ text += collectText(child)
38
+ }
39
+ return text
40
+ }
41
+
42
+ function tokenize(text: string): string[] {
43
+ return text.match(/[\u4e00-\u9fa5]|[a-zA-Z0-9]+|\s+|[^\sa-zA-Z0-9\u4e00-\u9fa5]/g) ?? []
44
+ }
45
+
46
+ /** Classic LCS word diff with a size guard. */
47
+ export function wordDiff(beforeText: string, afterText: string): WordDiffPart[] {
48
+ const a = tokenize(beforeText)
49
+ const b = tokenize(afterText)
50
+ if (a.length * b.length > 4_000_000) {
51
+ return [
52
+ { text: beforeText, type: 'del' },
53
+ { text: afterText, type: 'add' },
54
+ ]
55
+ }
56
+ // dp[i][j] = LCS length of a[i:], b[j:]
57
+ const dp: Uint32Array[] = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1))
58
+ for (let i = a.length - 1; i >= 0; i--) {
59
+ for (let j = b.length - 1; j >= 0; j--) {
60
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
61
+ }
62
+ }
63
+ const parts: WordDiffPart[] = []
64
+ const push = (text: string, type: WordDiffPart['type']) => {
65
+ const last = parts[parts.length - 1]
66
+ if (last && last.type === type) {
67
+ last.text += text
68
+ } else {
69
+ parts.push({ text, type })
70
+ }
71
+ }
72
+ let i = 0
73
+ let j = 0
74
+ while (i < a.length && j < b.length) {
75
+ if (a[i] === b[j]) {
76
+ push(a[i]!, 'same')
77
+ i++
78
+ j++
79
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
80
+ push(a[i]!, 'del')
81
+ i++
82
+ } else {
83
+ push(b[j]!, 'add')
84
+ j++
85
+ }
86
+ }
87
+ while (i < a.length) {
88
+ push(a[i++]!, 'del')
89
+ }
90
+ while (j < b.length) {
91
+ push(b[j++]!, 'add')
92
+ }
93
+ return parts
94
+ }
95
+
96
+ function sameBlock(a: JSONContent, b: JSONContent): boolean {
97
+ return JSON.stringify(a) === JSON.stringify(b)
98
+ }
99
+
100
+ export function diffDocs(before: JSONContent, after: JSONContent): BlockDiffEntry[] {
101
+ const beforeBlocks = topLevel(before)
102
+ const afterBlocks = topLevel(after)
103
+ const afterById = new Map<string, JSONContent>()
104
+ for (const block of afterBlocks) {
105
+ const id = blockId(block)
106
+ if (id) {
107
+ afterById.set(id, block)
108
+ }
109
+ }
110
+ const seen = new Set<string>()
111
+ const entries: BlockDiffEntry[] = []
112
+
113
+ for (const block of beforeBlocks) {
114
+ const id = blockId(block)
115
+ if (id && afterById.has(id)) {
116
+ seen.add(id)
117
+ const next = afterById.get(id)!
118
+ if (sameBlock(block, next)) {
119
+ entries.push({ kind: 'unchanged', id, before: block, after: next })
120
+ } else {
121
+ entries.push({ kind: 'changed', id, before: block, after: next, wordDiff: wordDiff(collectText(block), collectText(next)) })
122
+ }
123
+ } else {
124
+ entries.push({ kind: 'removed', id: id ?? undefined, before: block })
125
+ }
126
+ }
127
+ for (const block of afterBlocks) {
128
+ const id = blockId(block)
129
+ if (id && seen.has(id)) {
130
+ continue
131
+ }
132
+ if (!id || !beforeBlocks.some(b => blockId(b) === id)) {
133
+ entries.push({ kind: 'added', id: id ?? undefined, after: block })
134
+ }
135
+ }
136
+ return entries
137
+ }
138
+
139
+ /** Compact summary for the panel header. */
140
+ export function diffSummary(entries: BlockDiffEntry[]): { added: number; removed: number; changed: number } {
141
+ let added = 0
142
+ let removed = 0
143
+ let changed = 0
144
+ for (const entry of entries) {
145
+ if (entry.kind === 'added') added++
146
+ else if (entry.kind === 'removed') removed++
147
+ else if (entry.kind === 'changed') changed++
148
+ }
149
+ return { added, removed, changed }
150
+ }
@@ -0,0 +1,55 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Plugin } from '@tiptap/pm/state'
3
+
4
+ /**
5
+ * Block context menu (v1.1): right-click a block โ†’ `tessera:blockMenu` event.
6
+ * The binding renders the menu (copy anchor link / copy block id / delete;
7
+ * row & column ops when the target is a table).
8
+ */
9
+ export const BlockContextMenu = Extension.create({
10
+ name: 'tesseraBlockMenu',
11
+
12
+ addProseMirrorPlugins() {
13
+ const editor = this.editor
14
+ return [
15
+ new Plugin({
16
+ props: {
17
+ handleDOMEvents: {
18
+ contextmenu: (view, event) => {
19
+ const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
20
+ if (!coords) {
21
+ return false
22
+ }
23
+ const $pos = view.state.doc.resolve(coords.pos)
24
+ for (let depth = $pos.depth; depth >= 1; depth--) {
25
+ const node = $pos.node(depth)
26
+ if (typeof node.attrs.id === 'string') {
27
+ editor.emit('tessera:blockMenu', {
28
+ blockId: node.attrs.id as string,
29
+ blockType: node.type.name,
30
+ clientX: event.clientX,
31
+ clientY: event.clientY,
32
+ })
33
+ event.preventDefault()
34
+ return true
35
+ }
36
+ }
37
+ return false
38
+ },
39
+ },
40
+ },
41
+ }),
42
+ ]
43
+ },
44
+ })
45
+
46
+ declare module '@tiptap/core' {
47
+ interface EditorEvents {
48
+ 'tessera:blockMenu': {
49
+ blockId: string
50
+ blockType: string
51
+ clientX: number
52
+ clientY: number
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,148 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { PluginKey } from '@tiptap/pm/state'
3
+ import Suggestion from '@tiptap/suggestion'
4
+ import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'
5
+
6
+ /**
7
+ * Emoji picker (acceptance ยง2, v1.1): typing `:` anywhere opens a picker
8
+ * filtered by name/keywords; Return / click inserts the emoji and closes.
9
+ * Runs on @tiptap/suggestion like the slash menu โ€” IME composition guard
10
+ * comes for free (suggestion ignores composing transactions).
11
+ */
12
+
13
+ export interface EmojiItem {
14
+ char: string
15
+ name: string
16
+ keywords: string[]
17
+ }
18
+
19
+ /** Curated common set; hosts may append via `extraItems`. */
20
+ export const EMOJI_ITEMS: EmojiItem[] = [
21
+ { char: '๐Ÿ˜€', name: 'grinning', keywords: ['face', 'happy', 'smile'] },
22
+ { char: '๐Ÿ˜„', name: 'smile', keywords: ['face', 'happy', 'joy'] },
23
+ { char: '๐Ÿ˜', name: 'beaming', keywords: ['face', 'grin'] },
24
+ { char: '๐Ÿ˜‚', name: 'joy', keywords: ['face', 'tears', 'lol'] },
25
+ { char: '๐Ÿคฃ', name: 'rofl', keywords: ['face', 'laugh', 'floor'] },
26
+ { char: '๐Ÿ˜Š', name: 'blush', keywords: ['face', 'happy', 'warm'] },
27
+ { char: '๐Ÿ™‚', name: 'slight_smile', keywords: ['face', 'smile'] },
28
+ { char: '๐Ÿ˜‰', name: 'wink', keywords: ['face'] },
29
+ { char: '๐Ÿ˜', name: 'heart_eyes', keywords: ['face', 'love'] },
30
+ { char: '๐Ÿ˜˜', name: 'kiss', keywords: ['face', 'love'] },
31
+ { char: '๐Ÿ˜œ', name: 'zany', keywords: ['face', 'crazy', 'tongue'] },
32
+ { char: '๐Ÿค”', name: 'thinking', keywords: ['face', 'hmm'] },
33
+ { char: '๐Ÿค—', name: 'hug', keywords: ['face'] },
34
+ { char: '๐Ÿคจ', name: 'eyebrow', keywords: ['face', 'suspicious'] },
35
+ { char: '๐Ÿ˜', name: 'neutral', keywords: ['face', 'meh'] },
36
+ { char: '๐Ÿ˜ด', name: 'sleeping', keywords: ['face', 'zzz'] },
37
+ { char: '๐Ÿ˜ช', name: 'sleepy', keywords: ['face', 'tired'] },
38
+ { char: '๐Ÿ˜ซ', name: 'tired', keywords: ['face', 'exhausted'] },
39
+ { char: '๐Ÿฅณ', name: 'partying', keywords: ['face', 'celebrate'] },
40
+ { char: '๐Ÿ˜Ž', name: 'cool', keywords: ['face', 'sunglasses'] },
41
+ { char: '๐Ÿค“', name: 'nerd', keywords: ['face', 'glasses'] },
42
+ { char: '๐Ÿ˜ญ', name: 'sob', keywords: ['face', 'cry', 'tears'] },
43
+ { char: '๐Ÿ˜ก', name: 'angry', keywords: ['face', 'rage'] },
44
+ { char: '๐Ÿ˜ฑ', name: 'scream', keywords: ['face', 'fear'] },
45
+ { char: '๐Ÿคฏ', name: 'exploding_head', keywords: ['face', 'mind', 'blown'] },
46
+ { char: '๐Ÿฅบ', name: 'pleading', keywords: ['face', 'puppy'] },
47
+ { char: '๐Ÿ˜‡', name: 'innocent', keywords: ['face', 'angel', 'halo'] },
48
+ { char: '๐Ÿค', name: 'handshake', keywords: ['hands', 'deal'] },
49
+ { char: '๐Ÿ‘', name: 'thumbsup', keywords: ['hand', 'ok', 'like', 'yes'] },
50
+ { char: '๐Ÿ‘Ž', name: 'thumbsdown', keywords: ['hand', 'dislike', 'no'] },
51
+ { char: '๐Ÿ‘Œ', name: 'ok_hand', keywords: ['hand'] },
52
+ { char: 'โœŒ๏ธ', name: 'victory', keywords: ['hand', 'peace'] },
53
+ { char: '๐Ÿคž', name: 'crossed_fingers', keywords: ['hand', 'luck'] },
54
+ { char: '๐Ÿ‘', name: 'clap', keywords: ['hands', 'praise'] },
55
+ { char: '๐Ÿ™', name: 'pray', keywords: ['hands', 'thanks'] },
56
+ { char: '๐Ÿ’ช', name: 'muscle', keywords: ['arm', 'strong'] },
57
+ { char: 'โค๏ธ', name: 'heart', keywords: ['love', 'red'] },
58
+ { char: '๐Ÿงก', name: 'orange_heart', keywords: ['love'] },
59
+ { char: '๐Ÿ’›', name: 'yellow_heart', keywords: ['love'] },
60
+ { char: '๐Ÿ’š', name: 'green_heart', keywords: ['love'] },
61
+ { char: '๐Ÿ’™', name: 'blue_heart', keywords: ['love'] },
62
+ { char: '๐Ÿ’œ', name: 'purple_heart', keywords: ['love'] },
63
+ { char: '๐Ÿ–ค', name: 'black_heart', keywords: ['love'] },
64
+ { char: '๐Ÿ’”', name: 'broken_heart', keywords: ['love', 'sad'] },
65
+ { char: 'โญ', name: 'star', keywords: ['favorite'] },
66
+ { char: '๐ŸŒŸ', name: 'glowing_star', keywords: ['star', 'shine'] },
67
+ { char: 'โœจ', name: 'sparkles', keywords: ['magic', 'shine', 'ai'] },
68
+ { char: '๐Ÿ”ฅ', name: 'fire', keywords: ['hot', 'flame'] },
69
+ { char: 'โšก', name: 'zap', keywords: ['lightning', 'fast'] },
70
+ { char: '๐Ÿ’ก', name: 'bulb', keywords: ['idea', 'light'] },
71
+ { char: 'โœ…', name: 'check', keywords: ['done', 'ok', 'complete'] },
72
+ { char: 'โŒ', name: 'x', keywords: ['wrong', 'no', 'cancel'] },
73
+ { char: 'โš ๏ธ', name: 'warning', keywords: ['caution', 'alert'] },
74
+ { char: 'โ“', name: 'question', keywords: ['ask', 'help'] },
75
+ { char: 'โ—', name: 'exclamation', keywords: ['important'] },
76
+ { char: '๐Ÿš€', name: 'rocket', keywords: ['ship', 'launch', 'fast'] },
77
+ { char: '๐ŸŽ‰', name: 'tada', keywords: ['party', 'celebrate', 'congrats'] },
78
+ { char: '๐ŸŽŠ', name: 'confetti', keywords: ['party'] },
79
+ { char: '๐ŸŽฏ', name: 'dart', keywords: ['target', 'goal'] },
80
+ { char: '๐Ÿ“Œ', name: 'pushpin', keywords: ['pin'] },
81
+ { char: '๐Ÿ“Ž', name: 'paperclip', keywords: ['attach'] },
82
+ { char: '๐Ÿ“', name: 'memo', keywords: ['note', 'doc', 'write'] },
83
+ { char: '๐Ÿ“…', name: 'calendar', keywords: ['date', 'schedule'] },
84
+ { char: 'โฐ', name: 'alarm', keywords: ['clock', 'time'] },
85
+ { char: '๐Ÿ’ฐ', name: 'moneybag', keywords: ['money', 'cash'] },
86
+ { char: '๐ŸŽ', name: 'gift', keywords: ['present'] },
87
+ { char: 'โ˜•', name: 'coffee', keywords: ['drink', 'tea'] },
88
+ { char: '๐Ÿ•', name: 'pizza', keywords: ['food'] },
89
+ { char: '๐ŸŒˆ', name: 'rainbow', keywords: ['color'] },
90
+ { char: 'โ˜€๏ธ', name: 'sunny', keywords: ['weather', 'sun'] },
91
+ { char: '๐ŸŒ™', name: 'crescent', keywords: ['weather', 'moon', 'night'] },
92
+ { char: 'โ˜๏ธ', name: 'cloud', keywords: ['weather'] },
93
+ { char: '๐Ÿ‘€', name: 'eyes', keywords: ['look', 'watch'] },
94
+ { char: '๐Ÿค–', name: 'robot', keywords: ['ai', 'bot'] },
95
+ { char: '๐Ÿž', name: 'bug', keywords: ['insect', 'error'] },
96
+ ]
97
+
98
+ export function filterEmojiItems(query: string, items: EmojiItem[] = EMOJI_ITEMS): EmojiItem[] {
99
+ const q = query.toLowerCase()
100
+ if (!q) {
101
+ return items
102
+ }
103
+ return items.filter(
104
+ item => item.name.toLowerCase().includes(q) || item.keywords.some(k => k.toLowerCase().includes(q)),
105
+ )
106
+ }
107
+
108
+ export interface EmojiMenuOptions {
109
+ /** UI renderer provided by a binding (same contract as the slash menu). */
110
+ render?: () => {
111
+ onStart: (props: SuggestionProps<EmojiItem>) => void
112
+ onUpdate: (props: SuggestionProps<EmojiItem>) => void
113
+ onExit: (props: SuggestionProps<EmojiItem>) => void
114
+ onKeyDown?: (props: SuggestionKeyDownProps) => boolean
115
+ }
116
+ /** Hosts may append their own emoji entries. */
117
+ extraItems?: () => EmojiItem[]
118
+ }
119
+
120
+ export const EmojiMenu = Extension.create<EmojiMenuOptions>({
121
+ name: 'tesseraEmojiMenu',
122
+
123
+ addOptions() {
124
+ return {
125
+ render: undefined,
126
+ extraItems: undefined,
127
+ }
128
+ },
129
+
130
+ addProseMirrorPlugins() {
131
+ const options = this.options
132
+ const items = [...EMOJI_ITEMS, ...(options.extraItems?.() ?? [])]
133
+
134
+ return [
135
+ Suggestion({
136
+ editor: this.editor,
137
+ pluginKey: new PluginKey('tesseraEmojiMenu'),
138
+ char: ':',
139
+ startOfLine: false,
140
+ items: ({ query }) => filterEmojiItems(query, items),
141
+ command: ({ editor: e, range, props }) => {
142
+ e.chain().focus().insertContentAt(range, `${(props as EmojiItem).char} `).run()
143
+ },
144
+ render: options.render as SuggestionOptions<EmojiItem>['render'],
145
+ }),
146
+ ]
147
+ },
148
+ })