mirror of
https://github.com/makeplane/plane.git
synced 2026-01-31 02:39:32 -06:00
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:
committed by
GitHub
parent
5d60d6d702
commit
f2539c5051
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user