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
8 changed files with 319 additions and 279 deletions

View File

@@ -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<EmojiItem, { name: string }> & {
onClose: () => void;
};
export const EmojiList = forwardRef<EmojiListRef, Props>((props, ref) => {
const { items, command, editor, query } = props;
export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownProps>((props, ref) => {
const { items, command, query, onClose } = props;
// states
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// refs
const dropdownContainerRef = useRef<HTMLDivElement>(null);
const selectItem = useCallback(
(index: number): void => {
@@ -92,25 +72,6 @@ export const EmojiList = forwardRef<EmojiListRef, Props>((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<EmojiListRef, Props>((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<EmojiListRef, Props>((props, ref) => {
[handleKeyDown]
);
if (query.length <= 0) {
return null;
}
useOutsideClickDetector(dropdownContainerRef, onClose);
if (query.length <= 0) return null;
return (
<div
ref={containerRef}
style={{
position: "absolute",
zIndex: 100,
}}
className={`transition-all duration-200 transform ${isVisible ? "opacity-100 scale-100" : "opacity-0 scale-95"}`}
>
<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">
<>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<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.map((item, index) => {
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>
</div>
</>
);
});
EmojiList.displayName = "EmojiList";
EmojisListDropdown.displayName = "EmojisListDropdown";

View File

@@ -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<EmojiListRef>;
let editor: Editor;
let component: ReactRenderer<CommandListInstance, EmojisListDropdownProps> | 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<CommandListInstance, EmojisListDropdownProps>(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);
},
};
},

View File

@@ -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<TMentionHandler, "searchCallback">;
export type MentionsListDropdownProps = SuggestionProps<TMentionSection, TMentionSuggestion> &
Pick<TMentionHandler, "searchCallback"> & {
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<TMentionSection[]>([]);
const [selectedIndex, setSelectedIndex] = useState({
@@ -26,7 +27,7 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
});
const [isLoading, setIsLoading] = useState(false);
// refs
const commandListContainer = useRef<HTMLDivElement>(null);
const dropdownContainer = useRef<HTMLDivElement>(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 (
<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;
useOutsideClickDetector(dropdownContainer, onClose);
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,
return (
<>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<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) => {
e.preventDefault();
e.stopPropagation();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
>
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
{item.subTitle && (
<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>
>
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
{item.subTitle && (
<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";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -15,43 +15,52 @@ export const renderMentionsDropdown =
() => {
const { searchCallback } = args;
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 {
onStart: (props) => {
if (!searchCallback) return;
editorRef = props.editor;
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(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);
},
};
};

View File

@@ -10,10 +10,38 @@ type Props = {
onClick: (e: React.MouseEvent<HTMLButtonElement, 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}
<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) => {
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex, query } = props;
return (
<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}>
{item.icon}
</span>
<p className="flex-grow truncate">{item.title}</p>
<p className="flex-grow truncate">{query ? highlightMatch(item.title, query) : item.title}</p>
{item.badge}
</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";
// 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<TSlashCommandSection, ISlashCommandItem> & {
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 (
<div
id="slash-command"
ref={commandListContainer}
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"
>
{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>}
<div>
{section.items?.map((item, itemIndex) => (
<CommandMenuItem
key={item.key}
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
item={item}
itemIndex={itemIndex}
onClick={(e) => {
e.stopPropagation();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
sectionIndex={sectionIndex}
/>
))}
<>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<div
id="slash-command"
ref={commandListContainer}
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"
style={{
zIndex: 100,
}}
>
{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>}
<div>
{section.items?.map((item, itemIndex) => (
<CommandMenuItem
key={item.key}
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
item={item}
itemIndex={itemIndex}
onClick={(e) => {
e.stopPropagation();
selectItem(sectionIndex, itemIndex);
}}
onMouseEnter={() =>
setSelectedIndex({
section: sectionIndex,
item: itemIndex,
})
}
sectionIndex={sectionIndex}
query={query}
/>
))}
</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 Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
// constants
@@ -27,7 +27,7 @@ const Command = Extension.create<SlashCommandOptions>({
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<SlashCommandOptions>({
editor: this.editor,
render: () => {
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 {
onStart: (props) => {
// Track active dropdown
editorRef = props.editor;
// React renderer component, which wraps the actual dropdown component
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(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<SlashCommandOptions>({
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);
},
};
},

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";
export const updateFloatingUIFloaterPosition = (
export type UpdateFloatingUIFloaterPosition = (
editor: Editor,
element: HTMLElement,
options?: {
elementStyle?: Partial<CSSStyleDeclaration>;
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,
};
};