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 ce71380b2..87a505ba4 100644 --- a/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx +++ b/packages/editor/src/core/extensions/emoji/components/emojis-list.tsx @@ -1,8 +1,8 @@ -import { computePosition, flip, shift } from "@floating-ui/dom"; -import { type Editor, posToDOMRect } from "@tiptap/react"; -import { SuggestionKeyDownProps } from "@tiptap/suggestion"; +import { FloatingOverlay } from "@floating-ui/react"; +import { SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; // plane imports +import { useOutsideClickDetector } from "@plane/hooks"; import { cn } from "@plane/utils"; export type EmojiItem = { @@ -13,41 +13,21 @@ export type EmojiItem = { fallbackImage?: string; }; -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 type EmojiListRef = { onKeyDown: (props: SuggestionKeyDownProps) => boolean; }; -type Props = { - items: EmojiItem[]; - command: (item: { name: string }) => void; - editor: Editor; - query: string; +export type EmojisListDropdownProps = SuggestionProps & { + onClose: () => void; }; -export const EmojiList = forwardRef((props, ref) => { - const { items, command, editor, query } = props; +export const EmojisListDropdown = forwardRef((props, ref) => { + const { items, command, query, onClose } = props; + // states const [selectedIndex, setSelectedIndex] = useState(0); const [isVisible, setIsVisible] = useState(false); - const containerRef = useRef(null); + // refs + const dropdownContainerRef = useRef(null); const selectItem = useCallback( (index: number): void => { @@ -92,25 +72,6 @@ export const EmojiList = forwardRef((props, ref) => { [query.length, items.length, selectItem, selectedIndex] ); - // 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); @@ -123,7 +84,7 @@ export const EmojiList = forwardRef((props, ref) => { // Scroll selected item into view useEffect(() => { - const container = containerRef.current; + const container = dropdownContainerRef.current; if (!container) return; const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement; @@ -145,20 +106,31 @@ export const EmojiList = forwardRef((props, ref) => { [handleKeyDown] ); - if (query.length <= 0) { - return null; - } + useOutsideClickDetector(dropdownContainerRef, onClose); + + if (query.length <= 0) return null; return ( -
-
+ <> + {/* Backdrop */} + +
{items.length ? ( items.map((item, index) => { const isSelected = index === selectedIndex; @@ -195,8 +167,8 @@ export const EmojiList = forwardRef((props, ref) => {
No emojis found
)}
-
+ ); }); -EmojiList.displayName = "EmojiList"; +EmojisListDropdown.displayName = "EmojisListDropdown"; diff --git a/packages/editor/src/core/extensions/emoji/suggestion.ts b/packages/editor/src/core/extensions/emoji/suggestion.ts index 909f44d01..0ad7f558a 100644 --- a/packages/editor/src/core/extensions/emoji/suggestion.ts +++ b/packages/editor/src/core/extensions/emoji/suggestion.ts @@ -1,10 +1,12 @@ import type { EmojiOptions } from "@tiptap/extension-emoji"; import { ReactRenderer, type Editor } from "@tiptap/react"; -import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui"; +import { CommandListInstance } from "@/helpers/tippy"; // local imports -import { type EmojiItem, EmojiList, type EmojiListRef } from "./components/emojis-list"; +import { type EmojiItem, EmojisListDropdown, EmojisListDropdownProps } from "./components/emojis-list"; const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"]; @@ -44,71 +46,52 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = { allowSpaces: false, render: () => { - let component: ReactRenderer; - let editor: Editor; + let component: ReactRenderer | null = null; + let cleanup: () => void = () => {}; + let editorRef: Editor | null = null; + + const handleClose = (editor?: Editor) => { + component?.destroy(); + component = null; + (editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.EMOJI); + cleanup(); + }; return { - onStart: (props: SuggestionProps): void => { - if (!props.clientRect) return; - - editor = props.editor; - - // Track active dropdown - editor.storage.utility.activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI); - - component = new ReactRenderer(EmojiList, { + onStart: (props) => { + editorRef = props.editor; + component = new ReactRenderer(EmojisListDropdown, { props: { - items: props.items, - command: props.command, - editor: props.editor, - query: props.query, - }, + ...props, + onClose: () => handleClose(props.editor), + } satisfies EmojisListDropdownProps, editor: props.editor, + className: "fixed z-[100]", }); - - // Append to editor container - const targetElement = - (props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body; - targetElement.appendChild(component.element); + if (!props.clientRect) return; + props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.EMOJI); + const element = component.element as HTMLElement; + cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup; }, - onUpdate: (props: SuggestionProps): void => { - if (!component) return; - - component.updateProps({ - items: props.items, - command: props.command, - editor: props.editor, - query: props.query, - }); + onUpdate: (props) => { + if (!component || !component.element) return; + component.updateProps(props); + if (!props.clientRect) return; + cleanup(); + cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup; }, - - onKeyDown: (props: SuggestionKeyDownProps): boolean => { - if (props.event.key === "Escape") { - if (component) { - component.destroy(); - } + onKeyDown: ({ event }) => { + if (event.key === "Escape") { + handleClose(); return true; } - - // Delegate to EmojiList - return component?.ref?.onKeyDown(props) || false; + return component?.ref?.onKeyDown({ event }) || false; }, - onExit: (): void => { - // Remove from active dropdowns - if (editor) { - const { activeDropbarExtensions } = editor.storage.utility; - const index = activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI); - if (index > -1) { - activeDropbarExtensions.splice(index, 1); - } - } - - // Cleanup - if (component) { - component.destroy(); - } + onExit: ({ editor }) => { + component?.element.remove(); + handleClose(editor); }, }; }, diff --git a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx index 04257b15d..b670b94ab 100644 --- a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx @@ -1,23 +1,24 @@ "use client"; -import { Editor } from "@tiptap/react"; +import { FloatingOverlay } from "@floating-ui/react"; +import type { SuggestionProps } from "@tiptap/suggestion"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; // plane utils +import { useOutsideClickDetector } from "@plane/hooks"; import { cn } from "@plane/utils"; // helpers import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; // types import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types"; -export type MentionsListDropdownProps = { - command: (item: TMentionSuggestion) => void; - query: string; - editor: Editor; -} & Pick; +export type MentionsListDropdownProps = SuggestionProps & + Pick & { + onClose: () => void; + }; export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => { - const { command, query, searchCallback } = props; + const { command, query, searchCallback, onClose } = props; // states const [sections, setSections] = useState([]); const [selectedIndex, setSelectedIndex] = useState({ @@ -26,7 +27,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps }); const [isLoading, setIsLoading] = useState(false); // refs - const commandListContainer = useRef(null); + const dropdownContainer = useRef(null); const selectItem = useCallback( (sectionIndex: number, itemIndex: number) => { @@ -97,7 +98,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps // scroll to the dropdown item when navigating via keyboard useLayoutEffect(() => { - const container = commandListContainer?.current; + const container = dropdownContainer?.current; if (!container) return; const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; @@ -113,63 +114,77 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps } }, [selectedIndex]); - return ( -
{ - e.stopPropagation(); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - > - {isLoading ? ( -
Loading...
- ) : sections.length ? ( - sections.map((section, sectionIndex) => ( -
- {section.title &&
{section.title}
} - {section.items.map((item, itemIndex) => { - const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item; + useOutsideClickDetector(dropdownContainer, onClose); - return ( - - ); - })} -
- )) - ) : ( -
No results
- )} -
+ > + {item.icon} + {item.subTitle && ( +
{item.subTitle}
+ )} +

{item.title}

+ + ); + })} +
+ )) + ) : ( +
No results
+ )} + + ); }); diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts index ceb51427d..73ea3d829 100644 --- a/packages/editor/src/core/extensions/mentions/utils.ts +++ b/packages/editor/src/core/extensions/mentions/utils.ts @@ -1,4 +1,4 @@ -import { ReactRenderer } from "@tiptap/react"; +import { type Editor, ReactRenderer } from "@tiptap/react"; import type { SuggestionOptions } from "@tiptap/suggestion"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; @@ -15,43 +15,52 @@ export const renderMentionsDropdown = () => { const { searchCallback } = args; let component: ReactRenderer | null = null; + let cleanup: () => void = () => {}; + let editorRef: Editor | null = null; + + const handleClose = (editor?: Editor) => { + component?.destroy(); + component = null; + (editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION); + cleanup(); + }; return { onStart: (props) => { if (!searchCallback) return; + editorRef = props.editor; component = new ReactRenderer(MentionsListDropdown, { props: { ...props, searchCallback, - }, + onClose: () => handleClose(props.editor), + } satisfies MentionsListDropdownProps, editor: props.editor, + className: "fixed z-[100]", }); if (!props.clientRect) return; props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.MENTION); const element = component.element as HTMLElement; - element.style.position = "absolute"; - element.style.zIndex = "100"; - updateFloatingUIFloaterPosition(props.editor, element); + cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup; }, onUpdate: (props) => { if (!component || !component.element) return; component.updateProps(props); if (!props.clientRect) return; - updateFloatingUIFloaterPosition(props.editor, component.element); + cleanup(); + cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup; }, onKeyDown: ({ event }) => { if (event.key === "Escape") { - component?.destroy(); - component = null; + handleClose(); return true; } return component?.ref?.onKeyDown({ event }) ?? false; }, onExit: ({ editor }) => { - editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION); component?.element.remove(); - component?.destroy(); + handleClose(editor); }, }; }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx index a50902439..437756c79 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -10,10 +10,38 @@ type Props = { onClick: (e: React.MouseEvent) => void; onMouseEnter: () => void; sectionIndex: number; + query?: string; +}; + +// Utility to highlight matched text in a string +const highlightMatch = (text: string, query: string): React.ReactNode => { + if (!query || query.trim() === "") return text; + + const queryLower = query.toLowerCase().trim(); + const textLower = text.toLowerCase(); + + // Check for direct substring match + const index = textLower.indexOf(queryLower); + if (index >= 0) { + const before = text.substring(0, index); + const match = text.substring(index, index + queryLower.length); + const after = text.substring(index + queryLower.length); + + return ( + <> + {before} + {match} + {after} + + ); + } + + // Otherwise just return the text + return text; }; export const CommandMenuItem: React.FC = (props) => { - const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; + const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex, query } = props; return ( ); diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index 640fb8078..757d1f2e0 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -1,20 +1,22 @@ -import { Editor } from "@tiptap/core"; +import { FloatingOverlay } from "@floating-ui/react"; +import type { SuggestionProps } from "@tiptap/suggestion"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +// plane imports +import { useOutsideClickDetector } from "@plane/hooks"; // helpers import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; +// types +import type { ISlashCommandItem } from "@/types"; // components -import { ISlashCommandItem } from "@/types"; import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; -export type SlashCommandsMenuProps = { - editor: Editor; - items: TSlashCommandSection[]; - command: (item: ISlashCommandItem) => void; +export type SlashCommandsMenuProps = SuggestionProps & { + onClose: () => void; }; export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { - const { items: sections, command } = props; + const { items: sections, command, query, onClose } = props; // states const [selectedIndex, setSelectedIndex] = useState({ section: 0, @@ -112,43 +114,58 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) }, })); + useOutsideClickDetector(commandListContainer, onClose); + const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0; if (areSearchResultsEmpty) return null; return ( -
- {sections.map((section, sectionIndex) => ( -
- {section.title &&
{section.title}
} -
- {section.items?.map((item, itemIndex) => ( - { - e.stopPropagation(); - selectItem(sectionIndex, itemIndex); - }} - onMouseEnter={() => - setSelectedIndex({ - section: sectionIndex, - item: itemIndex, - }) - } - sectionIndex={sectionIndex} - /> - ))} + <> + {/* Backdrop */} + +
+ {sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} +
+ {section.items?.map((item, itemIndex) => ( + { + e.stopPropagation(); + selectItem(sectionIndex, itemIndex); + }} + onMouseEnter={() => + setSelectedIndex({ + section: sectionIndex, + item: itemIndex, + }) + } + sectionIndex={sectionIndex} + query={query} + /> + ))} +
-
- ))} -
+ ))} +
+ ); }); diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index 0ec5e4c8f..3f0375ae5 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -1,4 +1,4 @@ -import { type Editor, type Range, Extension } from "@tiptap/core"; +import { type Editor, Extension } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; // constants @@ -27,7 +27,7 @@ const Command = Extension.create({ return { suggestion: { char: "/", - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + command: ({ editor, range, props }) => { props.command({ editor, range }); }, allow({ editor }: { editor: Editor }) { @@ -50,20 +50,32 @@ const Command = Extension.create({ editor: this.editor, render: () => { let component: ReactRenderer | null = null; + let cleanup: () => void = () => {}; + let editorRef: Editor | null = null; + + const handleClose = (editor?: Editor) => { + component?.destroy(); + component = null; + (editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); + cleanup(); + }; return { onStart: (props) => { - // Track active dropdown + editorRef = props.editor; + // React renderer component, which wraps the actual dropdown component component = new ReactRenderer(SlashCommandsMenu, { - props, + props: { + ...props, + onClose: () => handleClose(props.editor), + } satisfies SlashCommandsMenuProps, editor: props.editor, + className: "fixed z-[100]", }); if (!props.clientRect) return; props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); const element = component.element as HTMLElement; - element.style.position = "absolute"; - element.style.zIndex = "100"; - updateFloatingUIFloaterPosition(props.editor, element); + cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup; }, onUpdate: (props) => { @@ -71,24 +83,22 @@ const Command = Extension.create({ component.updateProps(props); if (!props.clientRect) return; const element = component.element as HTMLElement; - updateFloatingUIFloaterPosition(props.editor, element); + cleanup(); + cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup; }, - onKeyDown: (props) => { - if (props.event.key === "Escape") { - component?.destroy(); - component = null; + onKeyDown: ({ event }) => { + if (event.key === "Escape") { + handleClose(this.editor); return true; } - return component?.ref?.onKeyDown(props) ?? false; + return component?.ref?.onKeyDown({ event }) ?? false; }, onExit: ({ editor }) => { - // Remove from active dropdowns - editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); - component?.destroy(); - component = null; + component?.element.remove(); + handleClose(editor); }, }; }, diff --git a/packages/editor/src/core/helpers/floating-ui.ts b/packages/editor/src/core/helpers/floating-ui.ts index d537b9ea3..5fde3b919 100644 --- a/packages/editor/src/core/helpers/floating-ui.ts +++ b/packages/editor/src/core/helpers/floating-ui.ts @@ -1,46 +1,52 @@ -import { computePosition, flip, type Middleware, type Strategy, type Placement, shift } from "@floating-ui/dom"; +import { + computePosition, + flip, + type Strategy, + type Placement, + shift, + ReferenceElement, + autoUpdate, +} from "@floating-ui/dom"; import { type Editor, posToDOMRect } from "@tiptap/core"; -export const updateFloatingUIFloaterPosition = ( +export type UpdateFloatingUIFloaterPosition = ( editor: Editor, element: HTMLElement, options?: { elementStyle?: Partial; - middleware?: Middleware[]; placement?: Placement; strategy?: Strategy; } ) => { - const editorElement = editor.options.element; - let container: Element | HTMLElement = document.body; + cleanup: () => void; +}; - if (editorElement instanceof Element) { - container = editorElement; - } else if (editorElement && typeof editorElement === "object" && "mount" in editorElement) { - container = editorElement.mount; - } else if (typeof editorElement === "function") { - container = document.body; - } +export const updateFloatingUIFloaterPosition: UpdateFloatingUIFloaterPosition = (editor, element, options) => { + document.body.appendChild(element); - container.appendChild(element); - - const virtualElement = { + const virtualElement: ReferenceElement = { getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to), }; - computePosition(virtualElement, element, { - placement: options?.placement ?? "bottom-start", - strategy: options?.strategy ?? "absolute", - middleware: options?.middleware ?? [shift(), flip()], - }) - .then(({ x, y, strategy }) => { - Object.assign(element.style, { - width: "max-content", - position: strategy, - left: `${x}px`, - top: `${y}px`, - ...options?.elementStyle, - }); + const cleanup = autoUpdate(virtualElement, element, () => { + computePosition(virtualElement, element, { + placement: options?.placement ?? "bottom-start", + strategy: options?.strategy ?? "fixed", + middleware: [shift(), flip()], }) - .catch((error) => console.error("An error occurred while updating floating UI floter position:", error)); + .then(({ x, y, strategy }) => { + Object.assign(element.style, { + width: "max-content", + position: strategy, + left: `${x}px`, + top: `${y}px`, + ...options?.elementStyle, + }); + }) + .catch((error) => console.error("An error occurred while updating floating UI floater position:", error)); + }); + + return { + cleanup, + }; };