feat: custom emoji
This commit is contained in:
@@ -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>
|
||||
|
||||
131
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
Normal file
131
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx
Normal file
33
src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
src/components/PostEditor/PostTextarea/Emoji/index.tsx
Normal file
12
src/components/PostEditor/PostTextarea/Emoji/index.tsx
Normal 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
|
||||
100
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
Normal file
100
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user