@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/LICENSE +21 -0
- package/dist/index.d.ts +903 -0
- package/dist/index.js +2538 -0
- package/dist/index.js.map +1 -0
- package/dist/tessera.css +1491 -0
- package/package.json +72 -0
- package/src/__tests__/markdown.test.ts +138 -0
- package/src/__tests__/v11.test.ts +184 -0
- package/src/__tests__/v112.test.ts +184 -0
- package/src/__tests__/writeback.test.ts +123 -0
- package/src/blockmenu.ts +38 -0
- package/src/diff.ts +150 -0
- package/src/extensions/context-menu.ts +55 -0
- package/src/extensions/emoji.ts +148 -0
- package/src/extensions/find-replace.ts +229 -0
- package/src/extensions/gallery.ts +111 -0
- package/src/extensions/history.ts +133 -0
- package/src/extensions/input-rules.ts +34 -0
- package/src/extensions/metrics.ts +61 -0
- package/src/extensions/shortcuts.ts +118 -0
- package/src/extensions/slash.ts +272 -0
- package/src/extensions/word-paste.ts +25 -0
- package/src/i18n.ts +320 -0
- package/src/index.ts +80 -0
- package/src/markdown.ts +260 -0
- package/src/marks/ai.ts +55 -0
- package/src/marks/comment.ts +137 -0
- package/src/marks/placeholder.ts +63 -0
- package/src/nodes/collapsible.ts +171 -0
- package/src/nodes/embed.ts +55 -0
- package/src/nodes/hint.ts +70 -0
- package/src/nodes/image.ts +77 -0
- package/src/nodes/table.ts +286 -0
- package/src/nodes/toc.ts +38 -0
- package/src/preset.ts +117 -0
- package/src/services.ts +103 -0
- package/src/styles/tessera.css +1491 -0
- package/src/wordpaste.ts +56 -0
- package/src/writeback.ts +156 -0
|
@@ -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
|
+
})
|
package/src/blockmenu.ts
ADDED
|
@@ -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
|
+
})
|