feat: custom emoji

This commit is contained in:
codytseng
2025-08-22 21:05:44 +08:00
parent 481d6a1447
commit 71d4420604
46 changed files with 885 additions and 176 deletions

View File

@@ -254,7 +254,12 @@ export default function PostContent({
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}
{!isTouchDevice() && (
<EmojiPickerDialog onEmojiClick={(emoji) => textareaRef.current?.insertText(emoji)}>
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (!emoji) return
textareaRef.current?.insertEmoji(emoji)
}}
>
<Button variant="ghost" size="icon">
<Smile />
</Button>

View File

@@ -0,0 +1,131 @@
import Emoji from '@/components/Emoji'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
export interface EmojiListProps {
items: string[]
command: (params: { name?: string }) => void
}
export interface EmojiListHandler {
onKeyDown: (params: { event: KeyboardEvent }) => boolean
}
export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = (index: number): void => {
const item = props.items[index]
if (item) {
props.command({ name: item })
}
customEmojiService.updateSuggested(item)
}
const upHandler = (): void => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = (): void => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = (): void => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => {
return {
onKeyDown: (x: { event: KeyboardEvent }): boolean => {
if (x.event.key === 'ArrowUp') {
upHandler()
return true
}
if (x.event.key === 'ArrowDown') {
downHandler()
return true
}
if (x.event.key === 'Enter') {
enterHandler()
return true
}
return false
}
}
}, [upHandler, downHandler, enterHandler])
if (!props.items?.length) {
return null
}
return (
<ScrollArea
className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<div className="p-1">
{props.items.map((item, index) => {
return (
<EmojiListItem
key={item}
id={item}
selectedIndex={selectedIndex}
index={index}
selectItem={selectItem}
setSelectedIndex={setSelectedIndex}
/>
)
})}
</div>
</ScrollArea>
)
})
function EmojiListItem({
id,
selectedIndex,
index,
selectItem,
setSelectedIndex
}: {
id: string
selectedIndex: number
index: number
selectItem: (index: number) => void
setSelectedIndex: (index: number) => void
}) {
const emoji = useMemo(() => customEmojiService.getEmojiById(id), [id])
if (!emoji) return null
return (
<button
className={cn(
'cursor-pointer w-full p-1 rounded-lg transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
selectedIndex === index && 'bg-accent text-accent-foreground'
)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex gap-2 items-center truncate pointer-events-none">
<Emoji
emoji={emoji}
classNames={{
img: 'size-8 shrink-0 rounded-md',
text: 'w-8 text-center shrink-0'
}}
/>
<span className="truncate">:{emoji.shortcode}:</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,33 @@
import Emoji from '@/components/Emoji'
import customEmojiService from '@/services/custom-emoji.service'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
import { useMemo } from 'react'
export default function EmojiNode(props: NodeViewRendererProps) {
const emoji = useMemo(() => {
const name = props.node.attrs.name
if (customEmojiService.isCustomEmojiId(name)) {
return customEmojiService.getEmojiById(name)
}
return shortcodeToEmoji(name, emojis)?.emoji
}, [props.node.attrs.name])
if (!emoji) {
return null
}
if (typeof emoji === 'string') {
return (
<NodeViewWrapper className="inline">
<span>{emoji}</span>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="inline">
<Emoji emoji={emoji} classNames={{ img: 'mb-1' }} />
</NodeViewWrapper>
)
}

View File

@@ -0,0 +1,12 @@
import TTEmoji from '@tiptap/extension-emoji'
import { ReactNodeViewRenderer } from '@tiptap/react'
import EmojiNode from './EmojiNode'
const Emoji = TTEmoji.extend({
selectable: true,
addNodeView() {
return ReactNodeViewRenderer(EmojiNode)
}
})
export default Emoji

View File

@@ -0,0 +1,100 @@
import customEmojiService from '@/services/custom-emoji.service'
import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import { EmojiList, EmojiListHandler, EmojiListProps } from './EmojiList'
const suggestion = {
items: async ({ query }: { query: string }) => {
return await customEmojiService.searchEmojis(query)
},
render: () => {
let component: ReactRenderer<EmojiListHandler, EmojiListProps> | undefined
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
return {
onBeforeStart: () => {
touchListener = (e: TouchEvent) => {
if (popup && popup[0] && postEditor.isSuggestionPopupOpen) {
const popupElement = popup[0].popper
if (popupElement && !popupElement.contains(e.target as Node)) {
popup[0].hide()
}
}
}
document.addEventListener('touchstart', touchListener)
closePopup = () => {
if (popup && popup[0]) {
popup[0].hide()
}
}
postEditor.addEventListener('closeSuggestionPopup', closePopup)
},
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
hideOnClick: true,
touch: true,
onShow() {
postEditor.isSuggestionPopupOpen = true
},
onHide() {
postEditor.isSuggestionPopupOpen = false
}
})
},
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup[0]?.setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
popup[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
postEditor.isSuggestionPopupOpen = false
popup[0]?.destroy()
component?.destroy()
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
}
}
}
}
export default suggestion

View File

@@ -3,9 +3,9 @@ import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import Nip05 from '../../Nip05'
import { SimpleUserAvatar } from '../../UserAvatar'
import { SimpleUsername } from '../../Username'
import Nip05 from '../../../Nip05'
import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../../Username'
export interface MentionListProps {
items: string[]
@@ -64,7 +64,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
}
}))
if (props.items.length === 0) {
if (!props.items?.length) {
return null
}

View File

@@ -1,5 +1,5 @@
import { formatNpub } from '@/lib/pubkey'
import Mention from '@tiptap/extension-mention'
import TTMention from '@tiptap/extension-mention'
import { ReactNodeViewRenderer } from '@tiptap/react'
import MentionNode from './MentionNode'
@@ -13,7 +13,7 @@ declare module '@tiptap/core' {
// const MENTION_REGEX = /(nostr:)?(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
const CustomMention = Mention.extend({
const Mention = TTMention.extend({
selectable: true,
addNodeView() {
@@ -67,7 +67,7 @@ const CustomMention = Mention.extend({
// ]
// }
})
export default CustomMention
export default Mention
// function handler({
// range,

View File

@@ -12,8 +12,8 @@ const suggestion = {
},
render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps>
let popup: Instance[]
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
@@ -30,7 +30,6 @@ const suggestion = {
document.addEventListener('touchstart', touchListener)
closePopup = () => {
console.log('closePopup')
if (popup && popup[0]) {
popup[0].hide()
}
@@ -67,29 +66,29 @@ const suggestion = {
},
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
component.updateProps(props)
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
popup[0]?.setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
popup[0].hide()
popup[0]?.hide()
return true
}
return component.ref?.onKeyDown(props) ?? false
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
postEditor.isSuggestionPopupOpen = false
popup[0].destroy()
component.destroy()
popup[0]?.destroy()
component?.destroy()
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)

View File

@@ -1,12 +1,21 @@
import { Card } from '@/components/ui/card'
import { transformCustomEmojisInContent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
import Content from '../../Content'
export default function Preview({ content, className }: { content: string; className?: string }) {
const { content: processedContent, emojiTags } = useMemo(
() => transformCustomEmojisInContent(content),
[content]
)
return (
<Card className={cn('p-3', className)}>
<Content event={createFakeEvent({ content })} className="pointer-events-none h-full" />
<Content
event={createFakeEvent({ content: processedContent, tags: emojiTags })}
className="pointer-events-none h-full"
/>
</Card>
)
}

View File

@@ -1,7 +1,9 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText } from '@/lib/tiptap'
import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service'
import { TEmoji } from '@/types'
import Document from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
@@ -14,13 +16,16 @@ import { Event } from 'nostr-tools'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
import CustomMention from './CustomMention'
import Emoji from './Emoji'
import emojiSuggestion from './Emoji/suggestion'
import Mention from './Mention'
import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview'
import suggestion from './suggestion'
export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void
insertText: (text: string) => void
insertEmoji: (emoji: string | TEmoji) => void
}
const PostTextarea = forwardRef<
@@ -63,8 +68,11 @@ const PostTextarea = forwardRef<
placeholder:
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}),
CustomMention.configure({
suggestion
Emoji.configure({
suggestion: emojiSuggestion
}),
Mention.configure({
suggestion: mentionSuggestion
}),
ClipboardAndDropHandler.configure({
onUploadStart: (file, cancel) => {
@@ -130,6 +138,18 @@ const PostTextarea = forwardRef<
if (editor) {
editor.chain().focus().insertContent(text).run()
}
},
insertEmoji: (emoji: string | TEmoji) => {
if (editor) {
if (typeof emoji === 'string') {
editor.chain().insertContent(emoji).run()
} else {
const emojiNode = editor.schema.nodes.emoji.create({
name: customEmojiService.getEmojiId(emoji)
})
editor.chain().insertContent(emojiNode).insertContent(' ').run()
}
}
}
}))