diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index b0eca500..c7e0f2af 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -44,6 +44,7 @@ "@tailwindcss/typography": "^0.5.19", "@tiptap/extension-blockquote": "^3.17.1", "@tiptap/extension-code-block-lowlight": "^3.17.1", + "@tiptap/extension-emoji": "^3.19.0", "@tiptap/extension-highlight": "^3.17.1", "@tiptap/extension-horizontal-rule": "^3.17.1", "@tiptap/extension-list": "^3.17.1", @@ -56,6 +57,7 @@ "@tiptap/pm": "^3.17.1", "@tiptap/react": "^3.17.1", "@tiptap/starter-kit": "^3.17.1", + "@tiptap/suggestion": "^3.19.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", diff --git a/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx b/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx new file mode 100644 index 00000000..03df1df0 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies. + */ +import type { + SuggestionKeyDownProps, + SuggestionProps, +} from "@tiptap/suggestion"; +import clsx from "clsx"; +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; + +export type EmojiListRef = { + onKeyDown: (props: SuggestionKeyDownProps) => boolean; +}; + +export const EmojiList = forwardRef( + ({ items, command }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + + if (item) { + command({ name: item.name }); + } + }, + [items, command] + ); + + useImperativeHandle(ref, () => { + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + return { + onKeyDown: (x: SuggestionKeyDownProps) => { + 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; + }, + }; + }, [items, selectedIndex, selectItem]); + + if (items.length === 0) { + return null; + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); + } +); diff --git a/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx b/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx new file mode 100644 index 00000000..8982d9e7 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx @@ -0,0 +1,121 @@ +/** + * External dependencies. + */ +import type { EmojiItem } from "@tiptap/extension-emoji"; +import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; +import { computePosition } from "@floating-ui/react"; +import { ReactRenderer } from "@tiptap/react"; + +/** + * Internal dependencies. + */ +import { EmojiList, type EmojiListRef } from "./emojiList"; + +type EmojiStorage = { + emojis: EmojiItem[]; +}; + +const EmojiSuggestions: Omit = { + items: ({ editor, query }) => { + const storage = editor.storage.emoji as EmojiStorage; + + return storage.emojis + .filter(({ shortcodes, tags }) => { + const lowerQuery = query.toLowerCase(); + + return ( + shortcodes.some((shortcode) => shortcode.startsWith(lowerQuery)) || + tags.some((tag) => tag.startsWith(lowerQuery)) + ); + }) + .slice(0, 5); + }, + + allowSpaces: false, + + render: () => { + let component: ReactRenderer | null = null; + + const repositionComponent = (clientRect: DOMRect | null) => { + if (!clientRect || !component) { + return; + } + + const virtualElement = { + getBoundingClientRect() { + return clientRect; + }, + }; + + computePosition(virtualElement, component.element, { + placement: "bottom-start", + }).then((pos) => { + if (!component) { + return; + } + + Object.assign(component.element.style, { + left: `${pos.x}px`, + top: `${pos.y}px`, + position: pos.strategy === "fixed" ? "fixed" : "absolute", + }); + }); + }; + + return { + onStart: (props) => { + component = new ReactRenderer(EmojiList, { + props, + editor: props.editor, + }); + + document.body.appendChild(component.element); + if (props.clientRect) { + repositionComponent(props.clientRect()); + } + }, + + onUpdate(props) { + if (!component) { + return; + } + + component.updateProps(props); + repositionComponent(props.clientRect?.() ?? null); + }, + + onKeyDown(props) { + if (!component) { + return false; + } + + if (!component.ref) { + return false; + } + + if (props.event.key === "Escape") { + if (document.body.contains(component.element)) { + document.body.removeChild(component.element); + } + component.destroy(); + component = null; + return true; + } + + return component.ref.onKeyDown(props) ?? false; + }, + + onExit() { + if (component) { + if (document.body.contains(component.element)) { + document.body.removeChild(component.element); + } + component.destroy(); + component = null; + } + }, + }; + }, +}; + +export default EmojiSuggestions; diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx b/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx index 3efd0b77..b333c0e2 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx @@ -164,7 +164,7 @@ const Menu = ({ className }: MenuProps) => { if (command.component) { return ( - + {({ isActive, onClick }) => (