fix: editor dropdowns positioning (#7927)

* fix: editor dropdowns positioning

* fix: add cleanup to prevent memory leak

* chore: add editor fallback
This commit is contained in:
Aaryan Khandelwal 2025-10-09 00:25:21 +05:30 committed by GitHub
parent 5d60d6d702
commit f2539c5051
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 319 additions and 279 deletions

View file

@ -1,8 +1,8 @@
import { computePosition, flip, shift } from "@floating-ui/dom"; import { FloatingOverlay } from "@floating-ui/react";
import { type Editor, posToDOMRect } from "@tiptap/react"; import { SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
import { SuggestionKeyDownProps } from "@tiptap/suggestion";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
// plane imports // plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
export type EmojiItem = { export type EmojiItem = {
@ -13,41 +13,21 @@ export type EmojiItem = {
fallbackImage?: string; 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 = { export type EmojiListRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean; onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}; };
type Props = { export type EmojisListDropdownProps = SuggestionProps<EmojiItem, { name: string }> & {
items: EmojiItem[]; onClose: () => void;
command: (item: { name: string }) => void;
editor: Editor;
query: string;
}; };
export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => { export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownProps>((props, ref) => {
const { items, command, editor, query } = props; const { items, command, query, onClose } = props;
// states
const [selectedIndex, setSelectedIndex] = useState<number>(0); const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); // refs
const dropdownContainerRef = useRef<HTMLDivElement>(null);
const selectItem = useCallback( const selectItem = useCallback(
(index: number): void => { (index: number): void => {
@ -92,25 +72,6 @@ export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
[query.length, items.length, selectItem, selectedIndex] [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 // Show animation
useEffect(() => { useEffect(() => {
setIsVisible(false); setIsVisible(false);
@ -123,7 +84,7 @@ export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
// Scroll selected item into view // Scroll selected item into view
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = dropdownContainerRef.current;
if (!container) return; if (!container) return;
const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement; const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;
@ -145,20 +106,31 @@ export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
[handleKeyDown] [handleKeyDown]
); );
if (query.length <= 0) { useOutsideClickDetector(dropdownContainerRef, onClose);
return null;
} if (query.length <= 0) return null;
return ( return (
<div <>
ref={containerRef} {/* Backdrop */}
style={{ <FloatingOverlay
position: "absolute", style={{
zIndex: 100, zIndex: 99,
}} }}
className={`transition-all duration-200 transform ${isVisible ? "opacity-100 scale-100" : "opacity-0 scale-95"}`} lockScroll
> />
<div className="z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1"> <div
ref={dropdownContainerRef}
className={cn(
"relative max-h-80 w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2 opacity-0 invisible transition-opacity",
{
"opacity-100 visible": isVisible,
}
)}
style={{
zIndex: 100,
}}
>
{items.length ? ( {items.length ? (
items.map((item, index) => { items.map((item, index) => {
const isSelected = index === selectedIndex; const isSelected = index === selectedIndex;
@ -195,8 +167,8 @@ export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
<div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div> <div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div>
)} )}
</div> </div>
</div> </>
); );
}); });
EmojiList.displayName = "EmojiList"; EmojisListDropdown.displayName = "EmojisListDropdown";

View file

@ -1,10 +1,12 @@
import type { EmojiOptions } from "@tiptap/extension-emoji"; import type { EmojiOptions } from "@tiptap/extension-emoji";
import { ReactRenderer, type Editor } from "@tiptap/react"; import { ReactRenderer, type Editor } from "@tiptap/react";
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
// constants // constants
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
import { CommandListInstance } from "@/helpers/tippy";
// local imports // 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"]; const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
@ -44,71 +46,52 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
allowSpaces: false, allowSpaces: false,
render: () => { render: () => {
let component: ReactRenderer<EmojiListRef>; let component: ReactRenderer<CommandListInstance, EmojisListDropdownProps> | null = null;
let editor: Editor; 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 { return {
onStart: (props: SuggestionProps): void => { onStart: (props) => {
if (!props.clientRect) return; editorRef = props.editor;
component = new ReactRenderer<CommandListInstance, EmojisListDropdownProps>(EmojisListDropdown, {
editor = props.editor;
// Track active dropdown
editor.storage.utility.activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
component = new ReactRenderer(EmojiList, {
props: { props: {
items: props.items, ...props,
command: props.command, onClose: () => handleClose(props.editor),
editor: props.editor, } satisfies EmojisListDropdownProps,
query: props.query,
},
editor: props.editor, editor: props.editor,
className: "fixed z-[100]",
}); });
if (!props.clientRect) return;
// Append to editor container props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
const targetElement = const element = component.element as HTMLElement;
(props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body; cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
targetElement.appendChild(component.element);
}, },
onUpdate: (props: SuggestionProps): void => { onUpdate: (props) => {
if (!component) return; if (!component || !component.element) return;
component.updateProps(props);
component.updateProps({ if (!props.clientRect) return;
items: props.items, cleanup();
command: props.command, cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
editor: props.editor,
query: props.query,
});
}, },
onKeyDown: ({ event }) => {
onKeyDown: (props: SuggestionKeyDownProps): boolean => { if (event.key === "Escape") {
if (props.event.key === "Escape") { handleClose();
if (component) {
component.destroy();
}
return true; return true;
} }
return component?.ref?.onKeyDown({ event }) || false;
// Delegate to EmojiList
return component?.ref?.onKeyDown(props) || false;
}, },
onExit: (): void => { onExit: ({ editor }) => {
// Remove from active dropdowns component?.element.remove();
if (editor) { handleClose(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();
}
}, },
}; };
}, },

View file

@ -1,23 +1,24 @@
"use client"; "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 { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
// plane utils // plane utils
import { useOutsideClickDetector } from "@plane/hooks";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// helpers // helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
// types // types
import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types"; import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types";
export type MentionsListDropdownProps = { export type MentionsListDropdownProps = SuggestionProps<TMentionSection, TMentionSuggestion> &
command: (item: TMentionSuggestion) => void; Pick<TMentionHandler, "searchCallback"> & {
query: string; onClose: () => void;
editor: Editor; };
} & Pick<TMentionHandler, "searchCallback">;
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => { export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
const { command, query, searchCallback } = props; const { command, query, searchCallback, onClose } = props;
// states // states
const [sections, setSections] = useState<TMentionSection[]>([]); const [sections, setSections] = useState<TMentionSection[]>([]);
const [selectedIndex, setSelectedIndex] = useState({ const [selectedIndex, setSelectedIndex] = useState({
@ -26,7 +27,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// refs // refs
const commandListContainer = useRef<HTMLDivElement>(null); const dropdownContainer = useRef<HTMLDivElement>(null);
const selectItem = useCallback( const selectItem = useCallback(
(sectionIndex: number, itemIndex: number) => { (sectionIndex: number, itemIndex: number) => {
@ -97,7 +98,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
// scroll to the dropdown item when navigating via keyboard // scroll to the dropdown item when navigating via keyboard
useLayoutEffect(() => { useLayoutEffect(() => {
const container = commandListContainer?.current; const container = dropdownContainer?.current;
if (!container) return; if (!container) return;
const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
@ -113,63 +114,77 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
} }
}, [selectedIndex]); }, [selectedIndex]);
return ( useOutsideClickDetector(dropdownContainer, onClose);
<div
ref={commandListContainer}
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{isLoading ? (
<div className="text-center text-sm text-custom-text-400">Loading...</div>
) : sections.length ? (
sections.map((section, sectionIndex) => (
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
{section.items.map((item, itemIndex) => {
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
return ( return (
<button <>
key={item.id} {/* Backdrop */}
id={`mention-item-${sectionIndex}-${itemIndex}`} <FloatingOverlay
type="button" style={{
className={cn( zIndex: 99,
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200", }}
{ lockScroll
"bg-custom-background-80": isSelected, />
<div
ref={dropdownContainer}
className="relative max-h-80 w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
style={{
zIndex: 100,
}}
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{isLoading ? (
<div className="text-center text-sm text-custom-text-400">Loading...</div>
) : sections.length ? (
sections.map((section, sectionIndex) => (
<div key={section.key} className="space-y-2">
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
{section.items.map((item, itemIndex) => {
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
return (
<button
key={item.id}
id={`mention-item-${sectionIndex}-${itemIndex}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
{
"bg-custom-background-80": isSelected,
}
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
} }
)} >
onClick={(e) => { <span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
e.preventDefault(); {item.subTitle && (
e.stopPropagation(); <h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
selectItem(sectionIndex, itemIndex); )}
}} <p className="flex-grow truncate">{item.title}</p>
onMouseEnter={() => </button>
setSelectedIndex({ );
section: sectionIndex, })}
item: itemIndex, </div>
}) ))
} ) : (
> <div className="text-center text-sm text-custom-text-400">No results</div>
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span> )}
{item.subTitle && ( </div>
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5> </>
)}
<p className="flex-grow truncate">{item.title}</p>
</button>
);
})}
</div>
))
) : (
<div className="text-center text-sm text-custom-text-400">No results</div>
)}
</div>
); );
}); });

View file

@ -1,4 +1,4 @@
import { ReactRenderer } from "@tiptap/react"; import { type Editor, ReactRenderer } from "@tiptap/react";
import type { SuggestionOptions } from "@tiptap/suggestion"; import type { SuggestionOptions } from "@tiptap/suggestion";
// constants // constants
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
@ -15,43 +15,52 @@ export const renderMentionsDropdown =
() => { () => {
const { searchCallback } = args; const { searchCallback } = args;
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null; let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | 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 { return {
onStart: (props) => { onStart: (props) => {
if (!searchCallback) return; if (!searchCallback) return;
editorRef = props.editor;
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, { component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
props: { props: {
...props, ...props,
searchCallback, searchCallback,
}, onClose: () => handleClose(props.editor),
} satisfies MentionsListDropdownProps,
editor: props.editor, editor: props.editor,
className: "fixed z-[100]",
}); });
if (!props.clientRect) return; if (!props.clientRect) return;
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.MENTION); props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
const element = component.element as HTMLElement; const element = component.element as HTMLElement;
element.style.position = "absolute"; cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
element.style.zIndex = "100";
updateFloatingUIFloaterPosition(props.editor, element);
}, },
onUpdate: (props) => { onUpdate: (props) => {
if (!component || !component.element) return; if (!component || !component.element) return;
component.updateProps(props); component.updateProps(props);
if (!props.clientRect) return; if (!props.clientRect) return;
updateFloatingUIFloaterPosition(props.editor, component.element); cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
}, },
onKeyDown: ({ event }) => { onKeyDown: ({ event }) => {
if (event.key === "Escape") { if (event.key === "Escape") {
component?.destroy(); handleClose();
component = null;
return true; return true;
} }
return component?.ref?.onKeyDown({ event }) ?? false; return component?.ref?.onKeyDown({ event }) ?? false;
}, },
onExit: ({ editor }) => { onExit: ({ editor }) => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
component?.element.remove(); component?.element.remove();
component?.destroy(); handleClose(editor);
}, },
}; };
}; };

View file

@ -10,10 +10,38 @@ type Props = {
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseEnter: () => void; onMouseEnter: () => void;
sectionIndex: number; 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}
<span className="font-medium text-custom-text-100">{match}</span>
{after}
</>
);
}
// Otherwise just return the text
return text;
}; };
export const CommandMenuItem: React.FC<Props> = (props) => { export const CommandMenuItem: React.FC<Props> = (props) => {
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex, query } = props;
return ( return (
<button <button
@ -31,7 +59,7 @@ export const CommandMenuItem: React.FC<Props> = (props) => {
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}> <span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
{item.icon} {item.icon}
</span> </span>
<p className="flex-grow truncate">{item.title}</p> <p className="flex-grow truncate">{query ? highlightMatch(item.title, query) : item.title}</p>
{item.badge} {item.badge}
</button> </button>
); );

View file

@ -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"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
// helpers // helpers
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
// types
import type { ISlashCommandItem } from "@/types";
// components // components
import { ISlashCommandItem } from "@/types";
import { TSlashCommandSection } from "./command-items-list"; import { TSlashCommandSection } from "./command-items-list";
import { CommandMenuItem } from "./command-menu-item"; import { CommandMenuItem } from "./command-menu-item";
export type SlashCommandsMenuProps = { export type SlashCommandsMenuProps = SuggestionProps<TSlashCommandSection, ISlashCommandItem> & {
editor: Editor; onClose: () => void;
items: TSlashCommandSection[];
command: (item: ISlashCommandItem) => void;
}; };
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
const { items: sections, command } = props; const { items: sections, command, query, onClose } = props;
// states // states
const [selectedIndex, setSelectedIndex] = useState({ const [selectedIndex, setSelectedIndex] = useState({
section: 0, 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; const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0;
if (areSearchResultsEmpty) return null; if (areSearchResultsEmpty) return null;
return ( return (
<div <>
id="slash-command" {/* Backdrop */}
ref={commandListContainer} <FloatingOverlay
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2" style={{
> zIndex: 99,
{sections.map((section, sectionIndex) => ( }}
<div key={section.key} className="space-y-2"> lockScroll
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>} />
<div> <div
{section.items?.map((item, itemIndex) => ( id="slash-command"
<CommandMenuItem ref={commandListContainer}
key={item.key} className="relative max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item} style={{
item={item} zIndex: 100,
itemIndex={itemIndex} }}
onClick={(e) => { >
e.stopPropagation(); {sections.map((section, sectionIndex) => (
selectItem(sectionIndex, itemIndex); <div key={section.key} className="space-y-2">
}} {section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
onMouseEnter={() => <div>
setSelectedIndex({ {section.items?.map((item, itemIndex) => (
section: sectionIndex, <CommandMenuItem
item: itemIndex, key={item.key}
}) isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
} item={item}
sectionIndex={sectionIndex} itemIndex={itemIndex}
/> onClick={(e) => {
))} e.stopPropagation();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
sectionIndex={sectionIndex}
query={query}
/>
))}
</div>
</div> </div>
</div> ))}
))} </div>
</div> </>
); );
}); });

View file

@ -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 { ReactRenderer } from "@tiptap/react";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
// constants // constants
@ -27,7 +27,7 @@ const Command = Extension.create<SlashCommandOptions>({
return { return {
suggestion: { suggestion: {
char: "/", char: "/",
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { command: ({ editor, range, props }) => {
props.command({ editor, range }); props.command({ editor, range });
}, },
allow({ editor }: { editor: Editor }) { allow({ editor }: { editor: Editor }) {
@ -50,20 +50,32 @@ const Command = Extension.create<SlashCommandOptions>({
editor: this.editor, editor: this.editor,
render: () => { render: () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null; let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | 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 { return {
onStart: (props) => { onStart: (props) => {
// Track active dropdown editorRef = props.editor;
// React renderer component, which wraps the actual dropdown component
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, { component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
props, props: {
...props,
onClose: () => handleClose(props.editor),
} satisfies SlashCommandsMenuProps,
editor: props.editor, editor: props.editor,
className: "fixed z-[100]",
}); });
if (!props.clientRect) return; if (!props.clientRect) return;
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
const element = component.element as HTMLElement; const element = component.element as HTMLElement;
element.style.position = "absolute"; cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
element.style.zIndex = "100";
updateFloatingUIFloaterPosition(props.editor, element);
}, },
onUpdate: (props) => { onUpdate: (props) => {
@ -71,24 +83,22 @@ const Command = Extension.create<SlashCommandOptions>({
component.updateProps(props); component.updateProps(props);
if (!props.clientRect) return; if (!props.clientRect) return;
const element = component.element as HTMLElement; const element = component.element as HTMLElement;
updateFloatingUIFloaterPosition(props.editor, element); cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
}, },
onKeyDown: (props) => { onKeyDown: ({ event }) => {
if (props.event.key === "Escape") { if (event.key === "Escape") {
component?.destroy(); handleClose(this.editor);
component = null;
return true; return true;
} }
return component?.ref?.onKeyDown(props) ?? false; return component?.ref?.onKeyDown({ event }) ?? false;
}, },
onExit: ({ editor }) => { onExit: ({ editor }) => {
// Remove from active dropdowns component?.element.remove();
editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); handleClose(editor);
component?.destroy();
component = null;
}, },
}; };
}, },

View file

@ -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"; import { type Editor, posToDOMRect } from "@tiptap/core";
export const updateFloatingUIFloaterPosition = ( export type UpdateFloatingUIFloaterPosition = (
editor: Editor, editor: Editor,
element: HTMLElement, element: HTMLElement,
options?: { options?: {
elementStyle?: Partial<CSSStyleDeclaration>; elementStyle?: Partial<CSSStyleDeclaration>;
middleware?: Middleware[];
placement?: Placement; placement?: Placement;
strategy?: Strategy; strategy?: Strategy;
} }
) => { ) => {
const editorElement = editor.options.element; cleanup: () => void;
let container: Element | HTMLElement = document.body; };
if (editorElement instanceof Element) { export const updateFloatingUIFloaterPosition: UpdateFloatingUIFloaterPosition = (editor, element, options) => {
container = editorElement; document.body.appendChild(element);
} else if (editorElement && typeof editorElement === "object" && "mount" in editorElement) {
container = editorElement.mount;
} else if (typeof editorElement === "function") {
container = document.body;
}
container.appendChild(element); const virtualElement: ReferenceElement = {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to), getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}; };
computePosition(virtualElement, element, { const cleanup = autoUpdate(virtualElement, element, () => {
placement: options?.placement ?? "bottom-start", computePosition(virtualElement, element, {
strategy: options?.strategy ?? "absolute", placement: options?.placement ?? "bottom-start",
middleware: options?.middleware ?? [shift(), flip()], strategy: options?.strategy ?? "fixed",
}) middleware: [shift(), flip()],
.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 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,
};
}; };