46 changed files with 885 additions and 176 deletions
@ -0,0 +1,131 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -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 |
||||
@ -0,0 +1,100 @@
@@ -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 |
||||
@ -1,12 +1,21 @@
@@ -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> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata' |
||||
import { parseEmojiPickerUnified } from '@/lib/utils' |
||||
import client from '@/services/client.service' |
||||
import { TEmoji } from '@/types' |
||||
import { sha256 } from '@noble/hashes/sha2' |
||||
import { SkinTones } from 'emoji-picker-react' |
||||
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested' |
||||
import FlexSearch from 'flexsearch' |
||||
import { Event } from 'nostr-tools' |
||||
|
||||
class CustomEmojiService { |
||||
static instance: CustomEmojiService |
||||
|
||||
private emojiMap = new Map<string, TEmoji>() |
||||
private emojiIndex = new FlexSearch.Index({ |
||||
tokenize: 'full' |
||||
}) |
||||
|
||||
constructor() { |
||||
if (!CustomEmojiService.instance) { |
||||
CustomEmojiService.instance = this |
||||
} |
||||
return CustomEmojiService.instance |
||||
} |
||||
|
||||
async init(userEmojiListEvent: Event | null) { |
||||
if (!userEmojiListEvent) return |
||||
|
||||
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent) |
||||
await this.addEmojisToIndex(emojis) |
||||
|
||||
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers) |
||||
await Promise.allSettled( |
||||
emojiSetEvents.map(async (event) => { |
||||
if (!event || event instanceof Error) return |
||||
|
||||
await this.addEmojisToIndex(getEmojisFromEvent(event)) |
||||
}) |
||||
) |
||||
} |
||||
|
||||
async searchEmojis(query: string = ''): Promise<string[]> { |
||||
if (!query) { |
||||
const idSet = new Set<string>() |
||||
getSuggested() |
||||
.sort((a, b) => b.count - a.count) |
||||
.map((item) => parseEmojiPickerUnified(item.unified)) |
||||
.forEach((item) => { |
||||
if (item && typeof item !== 'string') { |
||||
const id = this.getEmojiId(item) |
||||
if (!idSet.has(id)) { |
||||
idSet.add(id) |
||||
} |
||||
} |
||||
}) |
||||
for (const key of this.emojiMap.keys()) { |
||||
idSet.add(key) |
||||
} |
||||
return Array.from(idSet) |
||||
} |
||||
const results = await this.emojiIndex.searchAsync(query) |
||||
return results.filter((id) => typeof id === 'string') as string[] |
||||
} |
||||
|
||||
getEmojiById(id?: string): TEmoji | undefined { |
||||
if (!id) return undefined |
||||
|
||||
return this.emojiMap.get(id) |
||||
} |
||||
|
||||
getAllCustomEmojisForPicker() { |
||||
return Array.from(this.emojiMap.values()).map((emoji) => ({ |
||||
id: `:${emoji.shortcode}:${emoji.url}`, |
||||
imgUrl: emoji.url, |
||||
names: [emoji.shortcode] |
||||
})) |
||||
} |
||||
|
||||
isCustomEmojiId(shortcode: string) { |
||||
return this.emojiMap.has(shortcode) |
||||
} |
||||
|
||||
private async addEmojisToIndex(emojis: TEmoji[]) { |
||||
await Promise.allSettled( |
||||
emojis.map(async (emoji) => { |
||||
const id = this.getEmojiId(emoji) |
||||
this.emojiMap.set(id, emoji) |
||||
await this.emojiIndex.addAsync(id, emoji.shortcode) |
||||
}) |
||||
) |
||||
} |
||||
|
||||
getEmojiId(emoji: TEmoji) { |
||||
const encoder = new TextEncoder() |
||||
const data = encoder.encode(`${emoji.shortcode}:${emoji.url}`.toLowerCase()) |
||||
const hashBuffer = sha256(data) |
||||
const hashArray = Array.from(new Uint8Array(hashBuffer)) |
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') |
||||
} |
||||
|
||||
updateSuggested(id: string) { |
||||
const emoji = this.getEmojiById(id) |
||||
if (!emoji) return |
||||
|
||||
setSuggested( |
||||
{ |
||||
n: [emoji.shortcode.toLowerCase()], |
||||
u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(), |
||||
a: '0', |
||||
imgUrl: emoji.url |
||||
}, |
||||
SkinTones.NEUTRAL |
||||
) |
||||
} |
||||
} |
||||
|
||||
const instance = new CustomEmojiService() |
||||
export default instance |
||||
Loading…
Reference in new issue