diff --git a/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx b/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx index 86443cc9d40..706dbc685a2 100644 --- a/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx +++ b/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx @@ -1,5 +1,7 @@ -import { Editor } from "@tiptap/react"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { computePosition, flip, shift } from "@floating-ui/dom"; +import { Editor, posToDOMRect } from "@tiptap/react"; +import { SuggestionKeyDownProps } from "@tiptap/suggestion"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; // plane imports import { cn } from "@plane/utils"; @@ -18,14 +20,33 @@ export interface EmojiListProps { } export interface EmojiListRef { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; + onKeyDown: (props: SuggestionKeyDownProps) => boolean; } +const updatePosition = (editor: Editor, element: HTMLElement) => { + const virtualElement = { + getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to), + }; + + computePosition(virtualElement, element, { + placement: "bottom-start", + strategy: "absolute", + middleware: [shift(), flip()], + }).then(({ x, y, strategy }) => { + Object.assign(element.style, { + width: "max-content", + position: strategy, + left: `${x}px`, + top: `${y}px`, + }); + }); +}; + export const EmojiList = forwardRef((props, ref) => { - const { items, command } = props; + const { items, command, editor } = props; const [selectedIndex, setSelectedIndex] = useState(0); - // refs - const emojiListContainer = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const containerRef = useRef(null); const selectItem = useCallback( (index: number): void => { @@ -37,26 +58,68 @@ export const EmojiList = forwardRef((props, ref) = [command, items] ); - const upHandler = useCallback(() => { - setSelectedIndex((prevIndex) => (prevIndex + items.length - 1) % items.length); - }, [items.length]); + const handleKeyDown = useCallback( + (event: KeyboardEvent): boolean => { + if (event.key === "Escape") { + event.preventDefault(); + return true; + } - const downHandler = useCallback(() => { - setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length); - }, [items.length]); + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => (prev + items.length - 1) % items.length); + return true; + } - const enterHandler = useCallback(() => { - setSelectedIndex((prevIndex) => { - selectItem(prevIndex); - return prevIndex; - }); - }, [selectItem]); + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + selectItem(selectedIndex); + return true; + } + return false; + }, + [items.length, selectedIndex, selectItem] + ); + + // Update position when items change + useEffect(() => { + if (containerRef.current && editor) { + updatePosition(editor, containerRef.current); + } + }, [items, editor]); + + // Handle scroll events + useEffect(() => { + const handleScroll = () => { + if (containerRef.current && editor) { + updatePosition(editor, containerRef.current); + } + }; + + document.addEventListener("scroll", handleScroll, true); + return () => document.removeEventListener("scroll", handleScroll, true); + }, [editor]); + + // Show animation + useEffect(() => { + setIsVisible(false); + const timeout = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timeout); + }, []); + + // Reset selection when items change useEffect(() => setSelectedIndex(0), [items]); - // scroll to the dropdown item when navigating via keyboard - useLayoutEffect(() => { - const container = emojiListContainer?.current; + // Scroll selected item into view + useEffect(() => { + const container = containerRef.current; if (!container) return; const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement; @@ -64,9 +127,7 @@ export const EmojiList = forwardRef((props, ref) = const containerRect = container.getBoundingClientRect(); const itemRect = item.getBoundingClientRect(); - const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom; - - if (!isItemInView) { + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { item.scrollIntoView({ block: "nearest" }); } } @@ -75,75 +136,57 @@ export const EmojiList = forwardRef((props, ref) = useImperativeHandle( ref, () => ({ - onKeyDown: ({ event }: { event: KeyboardEvent }): boolean => { - if (event.key === "ArrowUp") { - upHandler(); - return true; - } - - if (event.key === "ArrowDown") { - downHandler(); - return true; - } - - if (event.key === "Enter") { - enterHandler(); - event.preventDefault(); - event.stopPropagation(); - - return true; - } - - return false; - }, + onKeyDown: ({ event }: SuggestionKeyDownProps): boolean => handleKeyDown(event), }), - [upHandler, downHandler, enterHandler] + [handleKeyDown] ); + return (
- {items.length ? ( - items.map((item, index) => { - const isSelected = index === selectedIndex; - const emojiKey = item.shortcodes.join(" - "); - - return ( - - ); - }) - ) : ( -
No emojis found
- )} + onClick={() => selectItem(index)} + onMouseEnter={() => setSelectedIndex(index)} + > + + {item.fallbackImage ? ( + {item.name} + ) : ( + item.emoji + )} + + + :{item.name}: + + + ); + }) + ) : ( +
No emojis found
+ )} +
); }); diff --git a/packages/editor/src/core/extensions/emoji/suggestion.ts b/packages/editor/src/core/extensions/emoji/suggestion.ts index 459d605a5de..a75e93fb031 100644 --- a/packages/editor/src/core/extensions/emoji/suggestion.ts +++ b/packages/editor/src/core/extensions/emoji/suggestion.ts @@ -1,13 +1,12 @@ import type { EmojiOptions } from "@tiptap/extension-emoji"; import { ReactRenderer, Editor } from "@tiptap/react"; import { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"; -import tippy, { Instance as TippyInstance } from "tippy.js"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { getExtensionStorage } from "@/helpers/get-extension-storage"; // local imports -import { EmojiItem, EmojiList, EmojiListRef, EmojiListProps } from "./components/emojis-list"; +import { EmojiItem, EmojiList, EmojiListRef } from "./components/emojis-list"; const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"]; @@ -36,85 +35,66 @@ const emojiSuggestion: EmojiOptions["suggestion"] = { allowSpaces: false, render: () => { - let component: ReactRenderer; - let popup: TippyInstance[] | null = null; + let component: ReactRenderer; + let editor: Editor; return { onStart: (props: SuggestionProps): void => { - const emojiListProps: EmojiListProps = { - items: props.items, - command: props.command, - editor: props.editor, - }; + if (!props.clientRect) return; - getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI); + editor = props.editor; + + // Track active dropdown + getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI); component = new ReactRenderer(EmojiList, { - props: emojiListProps, + props: { + items: props.items, + command: props.command, + editor: props.editor, + }, editor: props.editor, }); - if (!props.clientRect) return; - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => - document.querySelector(".active-editor") ?? - document.querySelector('[id^="editor-container"]') ?? - document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - hideOnClick: false, - sticky: "reference", - animation: false, - duration: 0, - offset: [0, 8], - }); + // Append to editor container + const targetElement = + (props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body; + targetElement.appendChild(component.element); }, onUpdate: (props: SuggestionProps): void => { - const emojiListProps: EmojiListProps = { + if (!component) return; + + component.updateProps({ items: props.items, command: props.command, editor: props.editor, - }; - - component.updateProps(emojiListProps); - - if (popup && props.clientRect) { - popup[0]?.setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - } + }); }, onKeyDown: (props: SuggestionKeyDownProps): boolean => { if (props.event.key === "Escape") { - if (popup) { - popup[0]?.hide(); - } if (component) { component.destroy(); } return true; } - return component.ref?.onKeyDown(props) || false; + // Delegate to EmojiList + return component?.ref?.onKeyDown(props) || false; }, - onExit: (props: SuggestionProps): void => { - const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY); - const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI); - if (index > -1) { - utilityStorage.activeDropbarExtensions.splice(index, 1); + onExit: (): void => { + // Remove from active dropdowns + if (editor) { + const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY); + const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI); + if (index > -1) { + utilityStorage.activeDropbarExtensions.splice(index, 1); + } } - if (popup) { - popup[0]?.destroy(); - } + // Cleanup if (component) { component.destroy(); }