Add custom emojis presentation

Signed-off-by: Anton Alexeyev <alexeyev.anton@gmail.com>
This commit is contained in:
Anton Alexeyev 2025-05-02 22:02:54 +07:00
parent c8f61385ec
commit 14f5252d7f
14 changed files with 121 additions and 45 deletions

View File

@ -80,7 +80,15 @@
{:else if node.type === MarkupNodeType.text} {:else if node.type === MarkupNodeType.text}
{node.text} {node.text}
{:else if node.type === MarkupNodeType.emoji} {:else if node.type === MarkupNodeType.emoji}
<span class="emoji" class:emojiOnly={single}>{node.attrs?.emoji}</span> <span class="emoji" class:emojiOnly={single}>
{#if node.attrs?.kind === 'custom'}
{@const src = toString(attrs.url)}
{@const alt = toString(attrs.emoji)}
<img {src} {alt}/>
{:else}
{node.attrs?.emoji}
{/if}
</span>
{:else if node.type === MarkupNodeType.paragraph} {:else if node.type === MarkupNodeType.paragraph}
<p class="p-inline contrast" class:overflow-label={preview} class:emojiOnly={checkEmoji(nodes)}> <p class="p-inline contrast" class:overflow-label={preview} class:emojiOnly={checkEmoji(nodes)}>
{#if nodes.length > 0} {#if nodes.length > 0}

View File

@ -18,13 +18,15 @@ import { Node, mergeAttributes } from '@tiptap/core'
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
emoji: { emoji: {
insertEmoji: (emoji: string) => ReturnType insertEmoji: (emoji: string, kind: 'unicode' | 'custom', url?: string) => ReturnType
} }
} }
} }
export interface EmojiNodeOptions { export interface EmojiNodeOptions {
emoji: string emoji: string
kind: 'unicode' | 'custom'
url?: string
} }
export const EmojiNode = Node.create<EmojiNodeOptions>({ export const EmojiNode = Node.create<EmojiNodeOptions>({
@ -38,6 +40,12 @@ export const EmojiNode = Node.create<EmojiNodeOptions>({
return { return {
emoji: { emoji: {
default: '' default: ''
},
kind: {
default: 'unicode'
},
url: {
default: null
} }
} }
}, },
@ -45,11 +53,11 @@ export const EmojiNode = Node.create<EmojiNodeOptions>({
addCommands () { addCommands () {
return { return {
insertEmoji: insertEmoji:
(emoji: string) => (emoji: string, kind: 'unicode' | 'custom', url?: string) =>
({ commands }) => { ({ commands }) => {
return commands.insertContent({ return commands.insertContent({
type: this.name, type: this.name,
attrs: { emoji } attrs: { emoji, kind, url }
}) })
} }
} }
@ -64,6 +72,28 @@ export const EmojiNode = Node.create<EmojiNodeOptions>({
}, },
renderHTML ({ node, HTMLAttributes }) { renderHTML ({ node, HTMLAttributes }) {
if (node.attrs.kind === 'custom') {
return [
'span',
mergeAttributes(
{
'data-type': this.name,
class: 'emoji'
},
HTMLAttributes
),
[
'img',
mergeAttributes(
{
'data-type': this.name,
src: node.attrs.url,
alt: node.attrs.emoji
}
)
]
]
}
return [ return [
'span', 'span',
mergeAttributes( mergeAttributes(

View File

@ -854,3 +854,13 @@
padding-top: 0; padding-top: 0;
.message.outcoming { border-radius: 0.75rem 0.125rem 0.125rem 0.75rem; } .message.outcoming { border-radius: 0.75rem 0.125rem 0.125rem 0.75rem; }
} }
.emoji > img {
display: inline-block;
vertical-align: sub;
height: 1.3em;
width: auto;
margin: 0 0.05em 0 0.1em;
padding: 0;
line-height: 1;
}

View File

@ -77,8 +77,8 @@
insertText: (text) => { insertText: (text) => {
editor?.insertText(text) editor?.insertText(text)
}, },
insertEmoji: (emoji) => { insertEmoji: (text: string, url?: string) => {
editor?.insertEmoji(emoji) editor?.insertEmoji(text, url)
}, },
insertMarkup: (markup) => { insertMarkup: (markup) => {
editor?.insertMarkup(markup) editor?.insertMarkup(markup)

View File

@ -41,7 +41,11 @@
dispatch('select', displayedEmoji) dispatch('select', displayedEmoji)
}} }}
> >
<span>{isCustomEmoji(displayedEmoji) ? displayedEmoji.shortcode : displayedEmoji.emoji}</span> {#if isCustomEmoji(displayedEmoji)}
<span><img src="{displayedEmoji.url}" alt="{displayedEmoji.shortcode}"></span>
{:else}
<span>{displayedEmoji.emoji}</span>
{/if}
</button> </button>
{/if} {/if}
@ -74,6 +78,10 @@
transform: translateY(1%); transform: translateY(1%);
pointer-events: none; pointer-events: none;
} }
span > img {
height: 1em;
width: auto;
}
&:enabled:hover { &:enabled:hover {
background-color: var(--theme-popup-hover); background-color: var(--theme-popup-hover);
} }

View File

@ -102,9 +102,8 @@
selected = isCustomEmoji(emoji) ? emoji.shortcode : emoji.emoji selected = isCustomEmoji(emoji) ? emoji.shortcode : emoji.emoji
addFrequentlyEmojis(emoji) addFrequentlyEmojis(emoji)
dispatch('close', { dispatch('close', {
// TODO: send ExtendedEmoji text: selected,
emoji: selected url: isCustomEmoji(emoji) ? emoji.url : undefined
// codes: emoji.hexcode.split('-').map((hc) => parseInt(hc, 16))
}) })
} }

View File

@ -69,6 +69,13 @@ export async function loadEmojis (lang?: string): Promise<EmojiWithGroup[]> {
export async function updateEmojis (lang?: string): Promise<void> { export async function updateEmojis (lang?: string): Promise<void> {
const emojis = await loadEmojis(lang) const emojis = await loadEmojis(lang)
emojis.push({
shortcode: 'huly',
label: 'huly',
url: 'https://fonts.gstatic.com/s/e/notoemoji/latest/1f979/512.gif',
tags: ['huly'],
key: 'flags'
})
emojiStore.set(emojis) emojiStore.set(emojis)
} }
@ -86,13 +93,15 @@ export function getEmojiByEmoticon (emoticon: string | undefined): string | unde
export function getUnicodeEmojiByShortCode (shortcode: string | undefined, skinTone?: number): Emoji | undefined { export function getUnicodeEmojiByShortCode (shortcode: string | undefined, skinTone?: number): Emoji | undefined {
const emoji = getEmojiByShortCode(shortcode, skinTone) const emoji = getEmojiByShortCode(shortcode, skinTone)
if (emoji === undefined || isCustomEmoji(emoji)) return undefined if (emoji === undefined || isCustomEmoji(emoji)) return undefined
return emoji
} }
export function getEmojiByShortCode (shortcode: string | undefined, skinTone?: number): ExtendedEmoji | undefined { export function getEmojiByShortCode (shortcode: string | undefined, skinTone?: number): ExtendedEmoji | undefined {
if (shortcode === undefined) return undefined if (shortcode === undefined) return undefined
const pureShortcode = shortcode.replaceAll(':', '')
return findEmoji((e) => { return findEmoji((e) => {
if (isCustomEmoji(e)) return e.shortcode === shortcode if (isCustomEmoji(e)) return e.shortcode === pureShortcode
return e.shortcodes?.includes(shortcode.replaceAll(':', '')) return e.shortcodes?.includes(pureShortcode)
}, skinTone) }, skinTone)
} }

View File

@ -178,8 +178,8 @@
insertText: (text) => { insertText: (text) => {
editor?.commands.insertContent(text) editor?.commands.insertContent(text)
}, },
insertEmoji: (emoji) => { insertEmoji: (text: string, url?: string) => {
editor?.commands.insertEmoji(emoji) editor?.commands.insertEmoji(text, url === undefined ? 'unicode' : 'custom')
}, },
insertMarkup: (markup) => { insertMarkup: (markup) => {
editor?.commands.insertContent(markupToJSON(markup)) editor?.commands.insertContent(markupToJSON(markup))

View File

@ -84,8 +84,8 @@
insertText: (text) => { insertText: (text) => {
editor?.insertText(text) editor?.insertText(text)
}, },
insertEmoji: (emoji) => { insertEmoji: (text: string, url?: string) => {
editor?.insertEmoji(emoji) editor?.insertEmoji(text, url)
}, },
insertMarkup: (markup) => { insertMarkup: (markup) => {
editor?.insertMarkup(markup) editor?.insertMarkup(markup)

View File

@ -79,8 +79,8 @@
insertText: (text) => { insertText: (text) => {
editor?.insertText(text) editor?.insertText(text)
}, },
insertEmoji: (emoji) => { insertEmoji: (text: string, url?: string) => {
editor?.insertEmoji(emoji) editor?.insertEmoji(text, url)
}, },
insertMarkup: (markup) => { insertMarkup: (markup) => {
editor?.insertMarkup(markup) editor?.insertMarkup(markup)

View File

@ -96,8 +96,8 @@
return editor return editor
} }
export function insertEmoji (emoji: string): void { export function insertEmoji (text: string, url?: string): void {
editor?.commands.insertEmoji(emoji) editor?.commands.insertEmoji(text, url === undefined ? 'unicode' : 'custom', url)
} }
export function insertText (text: string): void { export function insertText (text: string): void {

View File

@ -27,7 +27,7 @@ export const defaultRefActions: RefAction[] = [
if (emoji === null || emoji === undefined) { if (emoji === null || emoji === undefined) {
return return
} }
editorHandler.insertEmoji(emoji.emoji) editorHandler.insertEmoji(emoji.text, emoji.url)
editorHandler.focus() editorHandler.focus()
}, },
() => {} () => {}

View File

@ -37,16 +37,28 @@ function handleEmoji (
} }
const emoji = getEmojiFunction(match.pop()) const emoji = getEmojiFunction(match.pop())
if (emoji === undefined) return if (emoji === undefined) return
// TODO: add custom emoji if (typeof emoji === 'string' || !isCustomEmoji(emoji)) {
const unicodeEmoji = typeof emoji === 'string' ? emoji : (isCustomEmoji(emoji) ? emoji.shortcode : emoji.emoji)
commands.insertContentAt(range, [ commands.insertContentAt(range, [
{ {
type: 'emoji', type: 'emoji',
attrs: { attrs: {
emoji: unicodeEmoji emoji: typeof emoji === 'string' ? emoji : emoji.emoji,
kind: 'unicode'
} }
} }
]) ])
} else {
commands.insertContentAt(range, [
{
type: 'emoji',
attrs: {
emoji: emoji.shortcode,
kind: 'custom',
url: emoji.url
}
}
])
}
} }
export const EmojiExtension = EmojiNode.extend<EmojiNodeOptions>({ export const EmojiExtension = EmojiNode.extend<EmojiNodeOptions>({

View File

@ -16,7 +16,7 @@ export type CollaboratorType = 'local' | 'cloud'
*/ */
export interface TextEditorHandler { export interface TextEditorHandler {
insertText: (html: string) => void insertText: (html: string) => void
insertEmoji: (emoji: string) => void insertEmoji: (text: string, url?: string) => void
insertMarkup: (markup: Markup) => void insertMarkup: (markup: Markup) => void
insertTemplate: (name: string, markup: string) => void insertTemplate: (name: string, markup: string) => void
insertTable: (options: { rows?: number, cols?: number, withHeaderRow?: boolean }) => void insertTable: (options: { rows?: number, cols?: number, withHeaderRow?: boolean }) => void