@tiptap/extension-emoji 2.22.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/src/emoji.ts ADDED
@@ -0,0 +1,460 @@
1
+ import {
2
+ combineTransactionSteps,
3
+ escapeForRegEx,
4
+ findChildrenInRange,
5
+ getChangedRanges,
6
+ InputRule,
7
+ mergeAttributes,
8
+ Node,
9
+ nodeInputRule,
10
+ PasteRule,
11
+ } from '@tiptap/core'
12
+ import { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
13
+ import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
14
+ import emojiRegex from 'emoji-regex'
15
+ import { isEmojiSupported } from 'is-emoji-supported'
16
+
17
+ import { emojis as defaultEmojis } from './data.js'
18
+ import { emojiToShortcode } from './helpers/emojiToShortcode.js'
19
+ import { removeDuplicates } from './helpers/removeDuplicates.js'
20
+ import { shortcodeToEmoji } from './helpers/shortcodeToEmoji.js'
21
+
22
+ declare module '@tiptap/core' {
23
+ interface Commands<ReturnType> {
24
+ emoji: {
25
+ /**
26
+ * Add an emoji
27
+ */
28
+ setEmoji: (shortcode: string) => ReturnType,
29
+ }
30
+ }
31
+ }
32
+
33
+ export type EmojiItem = {
34
+ /**
35
+ * A unique name of the emoji which will be stored as attribute
36
+ */
37
+ name: string,
38
+ /**
39
+ * The emoji unicode character
40
+ */
41
+ emoji?: string,
42
+ /**
43
+ * A list of unique shortcodes that are used by input rules to find the emoji
44
+ */
45
+ shortcodes: string[],
46
+ /**
47
+ * A list of tags that can help for searching emojis
48
+ */
49
+ tags: string[],
50
+ /**
51
+ * A name that can help to group emojis
52
+ */
53
+ group?: string,
54
+ /**
55
+ * A list of unique emoticons
56
+ */
57
+ emoticons?: string[],
58
+ /**
59
+ * The unicode version the emoji was introduced
60
+ */
61
+ version?: number,
62
+ /**
63
+ * A fallback image if the current system doesn't support the emoji or for custom emojis
64
+ */
65
+ fallbackImage?: string,
66
+ /**
67
+ * Store some custom data
68
+ */
69
+ [key: string]: any,
70
+ }
71
+
72
+ export type EmojiOptions = {
73
+ HTMLAttributes: Record<string, any>,
74
+ emojis: EmojiItem[],
75
+ enableEmoticons: boolean,
76
+ forceFallbackImages: boolean,
77
+ suggestion: Omit<SuggestionOptions, 'editor'>,
78
+ }
79
+
80
+ export type EmojiStorage = {
81
+ emojis: EmojiItem[],
82
+ isSupported: (item: EmojiItem) => boolean,
83
+ }
84
+
85
+ export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
86
+
87
+ export const inputRegex = /:([a-zA-Z0-9_+-]+):$/
88
+
89
+ export const pasteRegex = /:([a-zA-Z0-9_+-]+):/g
90
+
91
+ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
92
+ name: 'emoji',
93
+
94
+ inline: true,
95
+
96
+ group: 'inline',
97
+
98
+ selectable: false,
99
+
100
+ addOptions() {
101
+ return {
102
+ HTMLAttributes: {},
103
+ emojis: defaultEmojis,
104
+ enableEmoticons: false,
105
+ forceFallbackImages: false,
106
+ suggestion: {
107
+ char: ':',
108
+ pluginKey: EmojiSuggestionPluginKey,
109
+ command: ({ editor, range, props }) => {
110
+ // increase range.to by one when the next node is of type "text"
111
+ // and starts with a space character
112
+ const nodeAfter = editor.view.state.selection.$to.nodeAfter
113
+ const overrideSpace = nodeAfter?.text?.startsWith(' ')
114
+
115
+ if (overrideSpace) {
116
+ range.to += 1
117
+ }
118
+
119
+ editor
120
+ .chain()
121
+ .focus()
122
+ .insertContentAt(range, [
123
+ {
124
+ type: this.name,
125
+ attrs: props,
126
+ },
127
+ {
128
+ type: 'text',
129
+ text: ' ',
130
+ },
131
+ ])
132
+ .command(({ tr, state }) => {
133
+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 2).marks())
134
+ return true
135
+ })
136
+ .run()
137
+ },
138
+ allow: ({ state, range }) => {
139
+ const $from = state.doc.resolve(range.from)
140
+ const type = state.schema.nodes[this.name]
141
+ const allow = !!$from.parent.type.contentMatch.matchType(type)
142
+
143
+ return allow
144
+ },
145
+ },
146
+ }
147
+ },
148
+
149
+ addStorage() {
150
+ const { emojis } = this.options
151
+ const supportMap: Record<number, boolean> = removeDuplicates(emojis.map(item => item.version))
152
+ .filter(version => typeof version === 'number')
153
+ .reduce((versions, version) => {
154
+ const emoji = emojis.find(item => item.version === version && item.emoji)
155
+
156
+ return {
157
+ ...versions,
158
+ [version as number]: emoji
159
+ ? isEmojiSupported(emoji.emoji as string)
160
+ : false,
161
+ }
162
+ }, {})
163
+
164
+ return {
165
+ emojis: this.options.emojis,
166
+ isSupported: emojiItem => {
167
+ return emojiItem.version
168
+ ? supportMap[emojiItem.version]
169
+ : false
170
+ },
171
+ }
172
+ },
173
+
174
+ addAttributes() {
175
+ return {
176
+ name: {
177
+ default: null,
178
+ parseHTML: element => element.dataset.name,
179
+ renderHTML: attributes => ({
180
+ 'data-name': attributes.name,
181
+ }),
182
+ },
183
+ }
184
+ },
185
+
186
+ parseHTML() {
187
+ return [
188
+ {
189
+ tag: `span[data-type="${this.name}"]`,
190
+ },
191
+ ]
192
+ },
193
+
194
+ renderHTML({ HTMLAttributes, node }) {
195
+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)
196
+ const attributes = mergeAttributes(
197
+ HTMLAttributes,
198
+ this.options.HTMLAttributes,
199
+ { 'data-type': this.name },
200
+ )
201
+
202
+ if (!emojiItem) {
203
+ return [
204
+ 'span',
205
+ attributes,
206
+ `:${node.attrs.name}:`,
207
+ ]
208
+ }
209
+
210
+ const isSupported = this.storage.isSupported(emojiItem)
211
+ const hasEmoji = !!emojiItem?.emoji
212
+ const hasFallbackImage = !!emojiItem?.fallbackImage
213
+
214
+ const renderFallbackImage = (this.options.forceFallbackImages && !hasEmoji)
215
+ || (this.options.forceFallbackImages && hasFallbackImage)
216
+ || (this.options.forceFallbackImages && !isSupported && hasFallbackImage)
217
+ || ((!isSupported || !hasEmoji) && hasFallbackImage)
218
+
219
+ return [
220
+ 'span',
221
+ attributes,
222
+ renderFallbackImage
223
+ ? [
224
+ 'img',
225
+ {
226
+ src: emojiItem.fallbackImage,
227
+ draggable: 'false',
228
+ loading: 'lazy',
229
+ align: 'absmiddle',
230
+ },
231
+ ]
232
+ : emojiItem.emoji || `:${emojiItem.shortcodes[0]}:`,
233
+ ]
234
+ },
235
+
236
+ renderText({ node }) {
237
+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)
238
+
239
+ return emojiItem?.emoji || `:${node.attrs.name}:`
240
+ },
241
+
242
+ addCommands() {
243
+ return {
244
+ setEmoji: shortcode => ({ chain }) => {
245
+ const emojiItem = shortcodeToEmoji(shortcode, this.options.emojis)
246
+
247
+ if (!emojiItem) {
248
+ return false
249
+ }
250
+
251
+ chain()
252
+ .insertContent({
253
+ type: this.name,
254
+ attrs: {
255
+ name: emojiItem.name,
256
+ },
257
+ })
258
+ .command(({ tr, state }) => {
259
+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
260
+ return true
261
+ }).run()
262
+
263
+ return true
264
+ },
265
+ }
266
+ },
267
+
268
+ addInputRules() {
269
+ const inputRules: InputRule[] = []
270
+
271
+ inputRules.push(
272
+ new InputRule({
273
+ find: inputRegex,
274
+ handler: ({ range, match, chain }) => {
275
+ const name = match[1]
276
+
277
+ if (!shortcodeToEmoji(name, this.options.emojis)) {
278
+ return
279
+ }
280
+
281
+ chain()
282
+ .insertContentAt(range, {
283
+ type: this.name,
284
+ attrs: {
285
+ name,
286
+ },
287
+ })
288
+ .command(({ tr, state }) => {
289
+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
290
+ return true
291
+ }).run()
292
+ },
293
+ }),
294
+ )
295
+
296
+ if (this.options.enableEmoticons) {
297
+ // get the list of supported emoticons
298
+ const emoticons = this.options.emojis
299
+ .map(item => item.emoticons)
300
+ .flat()
301
+ .filter(item => item) as string[]
302
+
303
+ const emoticonRegex = new RegExp(`(?:^|\\s)(${emoticons.map(item => escapeForRegEx(item)).join('|')}) $`)
304
+
305
+ inputRules.push(
306
+ nodeInputRule({
307
+ find: emoticonRegex,
308
+ type: this.type,
309
+ getAttributes: match => {
310
+ const emoji = this.options.emojis.find(item => item.emoticons?.includes(match[1]))
311
+
312
+ if (!emoji) {
313
+ return
314
+ }
315
+
316
+ return {
317
+ name: emoji.name,
318
+ }
319
+ },
320
+ }),
321
+ )
322
+ }
323
+
324
+ return inputRules
325
+ },
326
+
327
+ addPasteRules() {
328
+ return [
329
+ new PasteRule({
330
+ find: pasteRegex,
331
+ handler: ({ range, match, chain }) => {
332
+ const name = match[1]
333
+
334
+ if (!shortcodeToEmoji(name, this.options.emojis)) {
335
+ return
336
+ }
337
+
338
+ chain()
339
+ .insertContentAt(range, {
340
+ type: this.name,
341
+ attrs: {
342
+ name,
343
+ },
344
+ }, {
345
+ updateSelection: false,
346
+ })
347
+ .command(({ tr, state }) => {
348
+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
349
+ return true
350
+ })
351
+ .run()
352
+ },
353
+ }),
354
+ ]
355
+ },
356
+
357
+ addProseMirrorPlugins() {
358
+ return [
359
+ Suggestion({
360
+ editor: this.editor,
361
+ ...this.options.suggestion,
362
+ }),
363
+
364
+ new Plugin({
365
+ key: new PluginKey('emoji'),
366
+ props: {
367
+ // double click to select emoji doesn’t work by default
368
+ // that’s why we simulate this behavior
369
+ handleDoubleClickOn: (view, pos, node) => {
370
+ if (node.type !== this.type) {
371
+ return false
372
+ }
373
+
374
+ const from = pos
375
+ const to = from + node.nodeSize
376
+
377
+ this.editor.commands.setTextSelection({
378
+ from,
379
+ to,
380
+ })
381
+
382
+ return true
383
+ },
384
+ },
385
+
386
+ // replace text emojis with emoji node on any change
387
+ appendTransaction: (transactions, oldState, newState) => {
388
+ const docChanges = transactions.some(transaction => transaction.docChanged)
389
+ && !oldState.doc.eq(newState.doc)
390
+
391
+ if (!docChanges) {
392
+ return
393
+ }
394
+
395
+ const { tr } = newState
396
+ const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[])
397
+ const changes = getChangedRanges(transform)
398
+
399
+ changes.forEach(({ newRange }) => {
400
+ // We don’t want to add emoji inline nodes within code blocks.
401
+ // Because this would split the code block.
402
+
403
+ // This only works if the range of changes is within a code node.
404
+ // For all other cases (e.g. the whole document is set/pasted and the parent of the range is `doc`)
405
+ // it doesn't and we have to double check later.
406
+ if (newState.doc.resolve(newRange.from).parent.type.spec.code) {
407
+ return
408
+ }
409
+
410
+ const textNodes = findChildrenInRange(newState.doc, newRange, node => node.type.isText)
411
+
412
+ textNodes.forEach(({ node, pos }) => {
413
+ if (!node.text) {
414
+ return
415
+ }
416
+
417
+ const matches = [...node.text.matchAll(emojiRegex())]
418
+
419
+ matches.forEach(match => {
420
+ if (match.index === undefined) {
421
+ return
422
+ }
423
+
424
+ const emoji = match[0]
425
+ const name = emojiToShortcode(emoji, this.options.emojis)
426
+
427
+ if (!name) {
428
+ return
429
+ }
430
+
431
+ const from = tr.mapping.map(pos + match.index)
432
+
433
+ // Double check parent node is not a code block.
434
+ if (newState.doc.resolve(from).parent.type.spec.code) {
435
+ return
436
+ }
437
+
438
+ const to = from + emoji.length
439
+ const emojiNode = this.type.create({
440
+ name,
441
+ })
442
+
443
+ tr.replaceRangeWith(from, to, emojiNode)
444
+
445
+ tr.setStoredMarks(newState.doc.resolve(from).marks())
446
+
447
+ })
448
+ })
449
+ })
450
+
451
+ if (!tr.steps.length) {
452
+ return
453
+ }
454
+
455
+ return tr
456
+ },
457
+ }),
458
+ ]
459
+ },
460
+ })
@@ -0,0 +1,85 @@
1
+ import dataSource from 'emoji-datasource/emoji.json'
2
+ import data from 'emojibase-data/en/data.json'
3
+ import messages from 'emojibase-data/en/messages.json'
4
+ import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json'
5
+ import gitHubShortcodes from 'emojibase-data/en/shortcodes/github.json'
6
+ import fs from 'fs'
7
+ import json5 from 'json5'
8
+
9
+ import { EmojiItem } from './emoji.js'
10
+ import { removeVariationSelector } from './helpers/removeVariationSelector.js'
11
+
12
+ const emojis: EmojiItem[] = data
13
+ // .filter(emoji => emoji.version > 0 && emoji.version < 14)
14
+ .map(emoji => {
15
+ const dataSourceEmoji = dataSource.find(item => {
16
+ return item.unified === emoji.hexcode || item.non_qualified === emoji.hexcode
17
+ })
18
+ const hasFallbackImage = dataSourceEmoji?.has_img_apple
19
+ const name = [gitHubShortcodes[emoji.hexcode]].flat()[0]
20
+ || [emojibaseShortcodes[emoji.hexcode]].flat()[0]
21
+ const shortcodes = emojibaseShortcodes[emoji.hexcode]
22
+ ? [emojibaseShortcodes[emoji.hexcode]].flat()
23
+ : []
24
+ const emoticons = emoji.emoticon
25
+ ? [emoji.emoticon].flat()
26
+ : []
27
+
28
+ return {
29
+ emoji: removeVariationSelector(emoji.emoji),
30
+ name,
31
+ shortcodes,
32
+ tags: emoji.tags || [],
33
+ group: emoji.group ? messages.groups[emoji.group].message : '',
34
+ emoticons,
35
+ version: emoji.version,
36
+ fallbackImage: hasFallbackImage
37
+ ? `https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${dataSourceEmoji.image}`
38
+ : undefined,
39
+ }
40
+ })
41
+
42
+ const gitHubCustomEmojiNames = [
43
+ 'atom',
44
+ 'basecamp',
45
+ 'basecampy',
46
+ 'bowtie',
47
+ 'electron',
48
+ 'feelsgood',
49
+ 'finnadie',
50
+ 'goberserk',
51
+ 'godmode',
52
+ 'hurtrealbad',
53
+ 'neckbeard',
54
+ 'octocat',
55
+ 'rage1',
56
+ 'rage2',
57
+ 'rage3',
58
+ 'rage4',
59
+ 'shipit',
60
+ 'suspect',
61
+ 'trollface',
62
+ ]
63
+
64
+ const gitHubCustomEmojis: EmojiItem[] = gitHubCustomEmojiNames.map(name => {
65
+ return {
66
+ name,
67
+ shortcodes: [name],
68
+ tags: [],
69
+ group: 'GitHub',
70
+ fallbackImage: `https://github.githubassets.com/images/icons/emoji/${name}.png`,
71
+ }
72
+ })
73
+
74
+ const content = `// This is a generated file
75
+
76
+ import { EmojiItem } from './emoji'
77
+
78
+ export const emojis: EmojiItem[] = ${json5.stringify(emojis, { space: 2 })}
79
+
80
+ export const gitHubCustomEmojis: EmojiItem[] = ${json5.stringify(gitHubCustomEmojis, { space: 2 })}
81
+
82
+ export const gitHubEmojis: EmojiItem[] = [...emojis, ...gitHubCustomEmojis]
83
+ `
84
+
85
+ fs.writeFileSync('./src/data.ts', content)
@@ -0,0 +1,6 @@
1
+ import { EmojiItem } from '../emoji.js'
2
+ import { removeVariationSelector } from './removeVariationSelector.js'
3
+
4
+ export function emojiToShortcode(emoji: string, emojis: EmojiItem[]): string | undefined {
5
+ return emojis.find(item => item.emoji === removeVariationSelector(emoji))?.shortcodes[0]
6
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Removes duplicated values within an array.
3
+ * Supports numbers, strings and objects.
4
+ */
5
+ export function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
6
+ const seen: Record<any, any> = {}
7
+
8
+ return array.filter(item => {
9
+ const key = by(item)
10
+
11
+ return Object.prototype.hasOwnProperty.call(seen, key)
12
+ ? false
13
+ : (seen[key] = true)
14
+ })
15
+ }
@@ -0,0 +1,3 @@
1
+ export function removeVariationSelector(value: string): string {
2
+ return value.replace('\u{FE0E}', '').replace('\u{FE0F}', '')
3
+ }
@@ -0,0 +1,5 @@
1
+ import { EmojiItem } from '../emoji.js'
2
+
3
+ export function shortcodeToEmoji(shortcode: string, emojis: EmojiItem[]): EmojiItem | undefined {
4
+ return emojis.find(item => shortcode === item.name || item.shortcodes.includes(shortcode))
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Emoji } from './emoji.js'
2
+
3
+ export * from './data.js'
4
+ export * from './emoji.js'
5
+ export * from './helpers/emojiToShortcode.js'
6
+ export * from './helpers/shortcodeToEmoji.js'
7
+
8
+ export default Emoji