snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
1201
packages/extension-emoji/CHANGELOG.md
Normal file
1201
packages/extension-emoji/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
62
packages/extension-emoji/package.json
Normal file
62
packages/extension-emoji/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@tiptap/extension-emoji",
|
||||
"version": "3.30.3",
|
||||
"description": "emoji extension for tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev/api/nodes/emoji",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-emoji"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.6.0",
|
||||
"emojibase-data": "^17",
|
||||
"is-emoji-supported": "^0.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^",
|
||||
"@tiptap/suggestion": "workspace:^",
|
||||
"emoji-datasource": "^16.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"tsm": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*",
|
||||
"@tiptap/suggestion": "workspace:*"
|
||||
}
|
||||
}
|
||||
55
packages/extension-emoji/src/emoji.test.ts
Normal file
55
packages/extension-emoji/src/emoji.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { gitHubEmojis } from './data.js'
|
||||
import { Emoji, inputRegex, pasteRegex } from './emoji.js'
|
||||
|
||||
describe('emoji extension', () => {
|
||||
it('inputRegex matches typical shortcode', () => {
|
||||
expect(':smile:').toMatch(inputRegex)
|
||||
})
|
||||
|
||||
it('pasteRegex matches standalone shortcode', () => {
|
||||
expect(':smile:').toMatch(pasteRegex)
|
||||
})
|
||||
|
||||
it('pasteRegex does NOT match shortcode inside url-like text', () => {
|
||||
// This should NOT match because the shortcode is part of a URL path
|
||||
expect('https://example.com/:x:/sub/').not.toMatch(pasteRegex)
|
||||
})
|
||||
|
||||
it('pasteRegex matches when preceded by space', () => {
|
||||
expect(' test :smile: ').toMatch(pasteRegex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Emoji.setEmoji command', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit, Emoji.configure({ emojis: gitHubEmojis, enableEmoticons: true })],
|
||||
content: '<p></p>',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('inserts an emoji node by name', () => {
|
||||
editor.commands.setEmoji('zap')
|
||||
const emoji = editor.view.dom.querySelector('[data-type="emoji"][data-name="zap"]')
|
||||
expect(emoji).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders the emoji as an inline node in the document', () => {
|
||||
editor.commands.setEmoji('smile')
|
||||
expect(editor.getHTML()).toContain('data-type="emoji"')
|
||||
expect(editor.getHTML()).toContain('data-name="smile"')
|
||||
})
|
||||
})
|
||||
553
packages/extension-emoji/src/emoji.ts
Normal file
553
packages/extension-emoji/src/emoji.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
import {
|
||||
combineTransactionSteps,
|
||||
escapeForRegEx,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
InputRule,
|
||||
isFirefox,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
nodeInputRule,
|
||||
PasteRule,
|
||||
} from '@tiptap/core'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||
import { Suggestion } from '@tiptap/suggestion'
|
||||
import emojiRegex from 'emoji-regex'
|
||||
import { isEmojiSupported } from 'is-emoji-supported'
|
||||
|
||||
import { emojis as defaultEmojis } from './data.js'
|
||||
import { emojiToShortcode } from './helpers/emojiToShortcode.js'
|
||||
import { removeDuplicates } from './helpers/removeDuplicates.js'
|
||||
import { shortcodeToEmoji } from './helpers/shortcodeToEmoji.js'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
emoji: {
|
||||
/**
|
||||
* Add an emoji
|
||||
*/
|
||||
setEmoji: (shortcode: string) => ReturnType
|
||||
}
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
emoji: EmojiStorage
|
||||
}
|
||||
}
|
||||
|
||||
export type EmojiItem = {
|
||||
/**
|
||||
* A unique name of the emoji which will be stored as attribute
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* The emoji unicode character
|
||||
*/
|
||||
emoji?: string
|
||||
/**
|
||||
* A list of unique shortcodes that are used by input rules to find the emoji
|
||||
*/
|
||||
shortcodes: string[]
|
||||
/**
|
||||
* A list of tags that can help for searching emojis
|
||||
*/
|
||||
tags: string[]
|
||||
/**
|
||||
* A name that can help to group emojis
|
||||
*/
|
||||
group?: string
|
||||
/**
|
||||
* A list of unique emoticons
|
||||
*/
|
||||
emoticons?: string[]
|
||||
/**
|
||||
* The unicode version the emoji was introduced
|
||||
*/
|
||||
version?: number
|
||||
/**
|
||||
* A fallback image if the current system doesn't support the emoji or for custom emojis
|
||||
*/
|
||||
fallbackImage?: string
|
||||
/**
|
||||
* Store some custom data
|
||||
*/
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type EmojiOptions = {
|
||||
HTMLAttributes: Record<string, any>
|
||||
emojis: EmojiItem[]
|
||||
enableEmoticons: boolean
|
||||
forceFallbackImages: boolean
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>
|
||||
}
|
||||
|
||||
export type EmojiStorage = {
|
||||
emojis: EmojiItem[]
|
||||
isSupported: (item: EmojiItem) => boolean
|
||||
}
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||
|
||||
export const inputRegex = /:([a-zA-Z0-9_+-]+):$/
|
||||
|
||||
export const pasteRegex = /(^|\s):([a-zA-Z0-9_+-]+):/g
|
||||
|
||||
export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
|
||||
name: 'emoji',
|
||||
|
||||
inline: true,
|
||||
|
||||
group: 'inline',
|
||||
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
emojis: defaultEmojis,
|
||||
enableEmoticons: false,
|
||||
forceFallbackImages: false,
|
||||
suggestion: {
|
||||
char: ':',
|
||||
pluginKey: EmojiSuggestionPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ')
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
},
|
||||
])
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 2).marks())
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from)
|
||||
const type = state.schema.nodes[this.name]
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type)
|
||||
|
||||
return allow
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const { emojis } = this.options
|
||||
const supportMap: Record<number, boolean> = removeDuplicates(emojis.map(item => item.version))
|
||||
.filter(version => typeof version === 'number')
|
||||
.reduce((versions, version) => {
|
||||
const emoji = emojis.find(item => item.version === version && item.emoji)
|
||||
|
||||
return {
|
||||
...versions,
|
||||
[version as number]: emoji ? isEmojiSupported(emoji.emoji as string) : false,
|
||||
}
|
||||
}, {})
|
||||
|
||||
return {
|
||||
emojis: this.options.emojis,
|
||||
isSupported: emojiItem => {
|
||||
return emojiItem.version ? supportMap[emojiItem.version] : false
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: element => element.dataset.name,
|
||||
renderHTML: attributes => ({
|
||||
'data-name': attributes.name,
|
||||
}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)
|
||||
const attributes = mergeAttributes(HTMLAttributes, this.options.HTMLAttributes, {
|
||||
'data-type': this.name,
|
||||
})
|
||||
|
||||
if (!emojiItem) {
|
||||
return ['span', attributes, `:${node.attrs.name}:`]
|
||||
}
|
||||
|
||||
const isSupported = this.storage.isSupported(emojiItem)
|
||||
const hasEmoji = !!emojiItem?.emoji
|
||||
const hasFallbackImage = !!emojiItem?.fallbackImage
|
||||
|
||||
const renderFallbackImage =
|
||||
(this.options.forceFallbackImages && !hasEmoji) ||
|
||||
(this.options.forceFallbackImages && hasFallbackImage) ||
|
||||
(this.options.forceFallbackImages && !isSupported && hasFallbackImage) ||
|
||||
((!isSupported || !hasEmoji) && hasFallbackImage)
|
||||
|
||||
return [
|
||||
'span',
|
||||
attributes,
|
||||
renderFallbackImage
|
||||
? [
|
||||
'img',
|
||||
{
|
||||
src: emojiItem.fallbackImage,
|
||||
draggable: 'false',
|
||||
loading: 'lazy',
|
||||
align: 'absmiddle',
|
||||
alt: `${emojiItem.name} emoji`,
|
||||
},
|
||||
]
|
||||
: emojiItem.emoji || `:${emojiItem.shortcodes[0]}:`,
|
||||
]
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)
|
||||
|
||||
return emojiItem?.emoji || `:${node.attrs.name}:`
|
||||
},
|
||||
|
||||
renderMarkdown: node => {
|
||||
if (!node.attrs?.name) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `:${node.attrs.name}:`
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setEmoji:
|
||||
shortcode =>
|
||||
({ chain }) => {
|
||||
const emojiItem = shortcodeToEmoji(shortcode, this.options.emojis)
|
||||
|
||||
if (!emojiItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name: emojiItem.name,
|
||||
},
|
||||
})
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
const inputRules: InputRule[] = []
|
||||
|
||||
inputRules.push(
|
||||
new InputRule({
|
||||
find: inputRegex,
|
||||
handler: ({ range, match, chain }) => {
|
||||
const name = match[1]
|
||||
|
||||
if (!shortcodeToEmoji(name, this.options.emojis)) {
|
||||
return
|
||||
}
|
||||
|
||||
chain()
|
||||
.insertContentAt(range, {
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
if (this.options.enableEmoticons) {
|
||||
// get the list of supported emoticons
|
||||
const emoticons = this.options.emojis
|
||||
.flatMap(item => item.emoticons)
|
||||
.filter(item => item) as string[]
|
||||
|
||||
const emoticonRegex = new RegExp(
|
||||
`(?:^|\\s)(${emoticons.map(item => escapeForRegEx(item)).join('|')}) $`,
|
||||
)
|
||||
|
||||
inputRules.push(
|
||||
nodeInputRule({
|
||||
find: emoticonRegex,
|
||||
type: this.type,
|
||||
getAttributes: match => {
|
||||
const emoji = this.options.emojis.find(item => item.emoticons?.includes(match[1]))
|
||||
|
||||
if (!emoji) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
name: emoji.name,
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return inputRules
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
new PasteRule({
|
||||
find: pasteRegex,
|
||||
handler: ({ range, match, chain }) => {
|
||||
// match[1] is the optional prefix (start or whitespace), match[2] is the shortcode name
|
||||
const prefix = match[1] || ''
|
||||
const name = match[2]
|
||||
|
||||
if (!shortcodeToEmoji(name, this.options.emojis)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace only the shortcode portion (preserve the prefix)
|
||||
const shortcodeFrom = range.from + prefix.length
|
||||
const shortcodeTo = range.to
|
||||
|
||||
chain()
|
||||
.insertContentAt(
|
||||
{ from: shortcodeFrom, to: shortcodeTo },
|
||||
{
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
{
|
||||
updateSelection: false,
|
||||
},
|
||||
)
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks())
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
|
||||
new Plugin({
|
||||
key: new PluginKey('emoji'),
|
||||
props: {
|
||||
// Firefox cannot move the cursor past emoji nodes at paragraph
|
||||
// boundaries using arrow keys. We intercept plain (unmodified)
|
||||
// ArrowLeft/ArrowRight and manually resolve the correct position.
|
||||
// Only handles emoji nodes — other inline non-selectable nodes
|
||||
// (mentions, hard breaks) are out of scope for this extension.
|
||||
// See: https://github.com/ProseMirror/prosemirror/issues/1220
|
||||
handleKeyDown: (view, event) => {
|
||||
if (!isFirefox()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isLeft = event.key === 'ArrowLeft'
|
||||
const isRight = event.key === 'ArrowRight'
|
||||
|
||||
if (!isLeft && !isRight) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't interfere with selection extension (Shift), word
|
||||
// navigation (Ctrl/Alt), or OS shortcuts (Meta).
|
||||
if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
|
||||
if (!selection.empty || !(selection instanceof TextSelection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const $pos = selection.$from
|
||||
const emojiType = this.type
|
||||
|
||||
if (isLeft) {
|
||||
const nodeBefore = $pos.nodeBefore
|
||||
|
||||
if (!nodeBefore || nodeBefore.type !== emojiType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Cursor is right after an emoji node. Move past it to the left.
|
||||
const newPos = $pos.pos - nodeBefore.nodeSize
|
||||
|
||||
if (newPos >= $pos.start()) {
|
||||
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, newPos)))
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const nodeAfter = $pos.nodeAfter
|
||||
|
||||
if (!nodeAfter || nodeAfter.type !== emojiType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Cursor is right before an emoji node. Skip over it to the right.
|
||||
const newPos = $pos.pos + nodeAfter.nodeSize
|
||||
|
||||
if (newPos <= $pos.end()) {
|
||||
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, newPos)))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
// double click to select emoji doesn't work by default
|
||||
// that's why we simulate this behavior
|
||||
handleDoubleClickOn: (view, pos, node) => {
|
||||
if (node.type !== this.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
const from = pos
|
||||
const to = from + node.nodeSize
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from,
|
||||
to,
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
// replace text emojis with emoji node on any change
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
// Skip processing during IME composition
|
||||
if (this.editor.view.composing) {
|
||||
return
|
||||
}
|
||||
const docChanges =
|
||||
transactions.some(transaction => transaction.docChanged) &&
|
||||
!oldState.doc.eq(newState.doc)
|
||||
|
||||
if (!docChanges) {
|
||||
return
|
||||
}
|
||||
|
||||
const { tr } = newState
|
||||
const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[])
|
||||
const changes = getChangedRanges(transform)
|
||||
|
||||
changes.forEach(({ newRange }) => {
|
||||
// We don’t want to add emoji inline nodes within code blocks.
|
||||
// Because this would split the code block.
|
||||
|
||||
// This only works if the range of changes is within a code node.
|
||||
// For all other cases (e.g. the whole document is set/pasted and the parent of the range is `doc`)
|
||||
// it doesn't and we have to double check later.
|
||||
if (newState.doc.resolve(newRange.from).parent.type.spec.code) {
|
||||
return
|
||||
}
|
||||
|
||||
const textNodes = findChildrenInRange(newState.doc, newRange, node => node.type.isText)
|
||||
|
||||
textNodes.forEach(({ node, pos }) => {
|
||||
if (!node.text) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = [...node.text.matchAll(emojiRegex())]
|
||||
|
||||
matches.forEach(match => {
|
||||
if (match.index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const emoji = match[0]
|
||||
const name = emojiToShortcode(emoji, this.options.emojis)
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = tr.mapping.map(pos + match.index)
|
||||
|
||||
// Double check parent node is not a code block.
|
||||
if (newState.doc.resolve(from).parent.type.spec.code) {
|
||||
return
|
||||
}
|
||||
|
||||
const to = from + emoji.length
|
||||
const emojiNode = this.type.create({
|
||||
name,
|
||||
})
|
||||
|
||||
tr.replaceRangeWith(from, to, emojiNode)
|
||||
|
||||
tr.setStoredMarks(newState.doc.resolve(from).marks())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return
|
||||
}
|
||||
|
||||
return tr
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
88
packages/extension-emoji/src/generate.ts
Normal file
88
packages/extension-emoji/src/generate.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import dataSource from 'emoji-datasource/emoji.json' with { type: 'json' }
|
||||
import data from 'emojibase-data/en/data.json' with { type: 'json' }
|
||||
import messages from 'emojibase-data/en/messages.json' with { type: 'json' }
|
||||
import emojibaseShortcodesJson from 'emojibase-data/en/shortcodes/emojibase.json' with { type: 'json' }
|
||||
import gitHubShortcodesJson from 'emojibase-data/en/shortcodes/github.json' with { type: 'json' }
|
||||
import fs from 'fs'
|
||||
import json5 from 'json5'
|
||||
|
||||
import type { EmojiItem } from './emoji.js'
|
||||
import { removeVariationSelector } from './helpers/removeVariationSelector.js'
|
||||
|
||||
type Shortcodes = Record<string, string | string[]>
|
||||
|
||||
const emojibaseShortcodes = emojibaseShortcodesJson as Shortcodes
|
||||
const gitHubShortcodes = gitHubShortcodesJson as Shortcodes
|
||||
|
||||
const emojis: EmojiItem[] = data
|
||||
// .filter(emoji => emoji.version > 0 && emoji.version < 14)
|
||||
.map(emoji => {
|
||||
const dataSourceEmoji = dataSource.find(item => {
|
||||
return item.unified === emoji.hexcode || item.non_qualified === emoji.hexcode
|
||||
})
|
||||
const hasFallbackImage = dataSourceEmoji?.has_img_apple
|
||||
const name =
|
||||
[gitHubShortcodes[emoji.hexcode]].flat()[0] || [emojibaseShortcodes[emoji.hexcode]].flat()[0]
|
||||
const shortcodes = emojibaseShortcodes[emoji.hexcode]
|
||||
? [emojibaseShortcodes[emoji.hexcode]].flat()
|
||||
: []
|
||||
const emoticons = emoji.emoticon ? [emoji.emoticon].flat() : []
|
||||
|
||||
return {
|
||||
emoji: removeVariationSelector(emoji.emoji),
|
||||
name,
|
||||
shortcodes,
|
||||
tags: emoji.tags || [],
|
||||
group: emoji.group ? messages.groups[emoji.group].message : '',
|
||||
emoticons,
|
||||
version: emoji.version,
|
||||
fallbackImage: hasFallbackImage
|
||||
? `https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${dataSourceEmoji.image}`
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const gitHubCustomEmojiNames = [
|
||||
'atom',
|
||||
'basecamp',
|
||||
'basecampy',
|
||||
'bowtie',
|
||||
'electron',
|
||||
'feelsgood',
|
||||
'finnadie',
|
||||
'goberserk',
|
||||
'godmode',
|
||||
'hurtrealbad',
|
||||
'neckbeard',
|
||||
'octocat',
|
||||
'rage1',
|
||||
'rage2',
|
||||
'rage3',
|
||||
'rage4',
|
||||
'shipit',
|
||||
'suspect',
|
||||
'trollface',
|
||||
]
|
||||
|
||||
const gitHubCustomEmojis: EmojiItem[] = gitHubCustomEmojiNames.map(name => {
|
||||
return {
|
||||
name,
|
||||
shortcodes: [name],
|
||||
tags: [],
|
||||
group: 'GitHub',
|
||||
fallbackImage: `https://github.githubassets.com/images/icons/emoji/${name}.png`,
|
||||
}
|
||||
})
|
||||
|
||||
const content = `// This is a generated file
|
||||
|
||||
import { EmojiItem } from './emoji'
|
||||
|
||||
export const emojis: EmojiItem[] = ${json5.stringify(emojis, { space: 2 })}
|
||||
|
||||
export const gitHubCustomEmojis: EmojiItem[] = ${json5.stringify(gitHubCustomEmojis, { space: 2 })}
|
||||
|
||||
export const gitHubEmojis: EmojiItem[] = [...emojis, ...gitHubCustomEmojis]
|
||||
`
|
||||
|
||||
fs.writeFileSync('./src/data.ts', content)
|
||||
6
packages/extension-emoji/src/helpers/emojiToShortcode.ts
Normal file
6
packages/extension-emoji/src/helpers/emojiToShortcode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { EmojiItem } from '../emoji.js'
|
||||
import { removeVariationSelector } from './removeVariationSelector.js'
|
||||
|
||||
export function emojiToShortcode(emoji: string, emojis: EmojiItem[]): string | undefined {
|
||||
return emojis.find(item => item.emoji === removeVariationSelector(emoji))?.shortcodes[0]
|
||||
}
|
||||
13
packages/extension-emoji/src/helpers/removeDuplicates.ts
Normal file
13
packages/extension-emoji/src/helpers/removeDuplicates.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Removes duplicated values within an array.
|
||||
* Supports numbers, strings and objects.
|
||||
*/
|
||||
export function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
|
||||
const seen: Record<any, any> = {}
|
||||
|
||||
return array.filter(item => {
|
||||
const key = by(item)
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(seen, key) ? false : (seen[key] = true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function removeVariationSelector(value: string): string {
|
||||
return value.replace('\u{FE0E}', '').replace('\u{FE0F}', '')
|
||||
}
|
||||
5
packages/extension-emoji/src/helpers/shortcodeToEmoji.ts
Normal file
5
packages/extension-emoji/src/helpers/shortcodeToEmoji.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { EmojiItem } from '../emoji.js'
|
||||
|
||||
export function shortcodeToEmoji(shortcode: string, emojis: EmojiItem[]): EmojiItem | undefined {
|
||||
return emojis.find(item => shortcode === item.name || item.shortcodes.includes(shortcode))
|
||||
}
|
||||
8
packages/extension-emoji/src/index.ts
Normal file
8
packages/extension-emoji/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Emoji } from './emoji.js'
|
||||
|
||||
export * from './data.js'
|
||||
export * from './emoji.js'
|
||||
export * from './helpers/emojiToShortcode.js'
|
||||
export * from './helpers/shortcodeToEmoji.js'
|
||||
|
||||
export default Emoji
|
||||
9
packages/extension-emoji/vite.config.ts
Normal file
9
packages/extension-emoji/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user