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:
parent
5d60d6d702
commit
f2539c5051
8 changed files with 319 additions and 279 deletions
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue