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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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:*"
}
}

View 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"')
})
})

View 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
},
}),
]
},
})

View 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)

View 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]
}

View 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)
})
}

View File

@@ -0,0 +1,3 @@
export function removeVariationSelector(value: string): string {
return value.replace('\u{FE0E}', '').replace('\u{FE0F}', '')
}

View 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))
}

View 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

View 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(),
},
})