Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 130 additions & 87 deletions packages/editor/src/core/extensions/emoji/components/emojis-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Editor } from "@tiptap/react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { computePosition, flip, shift } from "@floating-ui/dom";
import { Editor, posToDOMRect } from "@tiptap/react";
import { SuggestionKeyDownProps } from "@tiptap/suggestion";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
// plane imports
import { cn } from "@plane/utils";

Expand All @@ -18,14 +20,33 @@ export interface EmojiListProps {
}

export interface EmojiListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}

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 const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) => {
const { items, command } = props;
const { items, command, editor } = props;
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// refs
const emojiListContainer = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

const selectItem = useCallback(
(index: number): void => {
Expand All @@ -37,36 +58,76 @@ export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) =
[command, items]
);

const upHandler = useCallback(() => {
setSelectedIndex((prevIndex) => (prevIndex + items.length - 1) % items.length);
}, [items.length]);
const handleKeyDown = useCallback(
(event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
event.preventDefault();
return true;
}

const downHandler = useCallback(() => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);
}, [items.length]);
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
return true;
}

const enterHandler = useCallback(() => {
setSelectedIndex((prevIndex) => {
selectItem(prevIndex);
return prevIndex;
});
}, [selectItem]);
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((prev) => (prev + 1) % items.length);
return true;
}

if (event.key === "Enter") {
event.preventDefault();
selectItem(selectedIndex);
return true;
}

return false;
},
[items.length, selectedIndex, selectItem]
);

// 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);
const timeout = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timeout);
}, []);

// Reset selection when items change
useEffect(() => setSelectedIndex(0), [items]);

// scroll to the dropdown item when navigating via keyboard
useLayoutEffect(() => {
const container = emojiListContainer?.current;
// Scroll selected item into view
useEffect(() => {
const container = containerRef.current;
if (!container) return;

const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;
if (item) {
const containerRect = container.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();

const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;

if (!isItemInView) {
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
}
Expand All @@ -75,75 +136,57 @@ export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) =
useImperativeHandle(
ref,
() => ({
onKeyDown: ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}

if (event.key === "ArrowDown") {
downHandler();
return true;
}

if (event.key === "Enter") {
enterHandler();
event.preventDefault();
event.stopPropagation();

return true;
}

return false;
},
onKeyDown: ({ event }: SuggestionKeyDownProps): boolean => handleKeyDown(event),
}),
[upHandler, downHandler, enterHandler]
[handleKeyDown]
);

return (
<div
ref={emojiListContainer}
role="listbox"
aria-label="Emoji suggestions"
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"
ref={containerRef}
style={{
position: "absolute",
zIndex: 100,
}}
className={`transition-all duration-200 transform ${isVisible ? "opacity-100 scale-100" : "opacity-0 scale-95"}`}
>
{items.length ? (
items.map((item, index) => {
const isSelected = index === selectedIndex;
const emojiKey = item.shortcodes.join(" - ");

return (
<button
key={emojiKey}
role="option"
aria-selected={isSelected}
aria-label={`${item.name} emoji`}
id={`emoji-item-${index}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150",
{
"bg-custom-background-80": isSelected,
}
)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="size-5 grid place-items-center flex-shrink-0 text-base">
{item.fallbackImage ? (
<img src={item.fallbackImage} alt={item.name} className="size-4 object-contain" />
) : (
item.emoji
<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">
{items.length ? (
items.map((item, index) => {
const isSelected = index === selectedIndex;
const emojiKey = item.shortcodes.join(" - ");

return (
<button
key={emojiKey}
id={`emoji-item-${index}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150",
{
"bg-custom-background-80": isSelected,
}
)}
</span>
<span className="flex-grow truncate">
<span className="font-medium">:{item.name}:</span>
</span>
</button>
);
})
) : (
<div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div>
)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="size-5 grid place-items-center flex-shrink-0 text-base">
{item.fallbackImage ? (
<img src={item.fallbackImage} alt={item.name} className="size-4 object-contain" />
) : (
item.emoji
)}
</span>
<span className="flex-grow truncate">
<span className="font-medium">:{item.name}:</span>
</span>
</button>
);
})
) : (
<div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div>
)}
</div>
</div>
);
});
Expand Down
84 changes: 32 additions & 52 deletions packages/editor/src/core/extensions/emoji/suggestion.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { EmojiOptions } from "@tiptap/extension-emoji";
import { ReactRenderer, Editor } from "@tiptap/react";
import { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import tippy, { Instance as TippyInstance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { EmojiItem, EmojiList, EmojiListRef, EmojiListProps } from "./components/emojis-list";
import { EmojiItem, EmojiList, EmojiListRef } from "./components/emojis-list";

const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];

Expand Down Expand Up @@ -36,85 +35,66 @@ const emojiSuggestion: EmojiOptions["suggestion"] = {
allowSpaces: false,

render: () => {
let component: ReactRenderer<EmojiListRef, EmojiListProps>;
let popup: TippyInstance[] | null = null;
let component: ReactRenderer<EmojiListRef>;
let editor: Editor;

return {
onStart: (props: SuggestionProps): void => {
const emojiListProps: EmojiListProps = {
items: props.items,
command: props.command,
editor: props.editor,
};
if (!props.clientRect) return;

getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
editor = props.editor;

// Track active dropdown
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);

component = new ReactRenderer(EmojiList, {
props: emojiListProps,
props: {
items: props.items,
command: props.command,
editor: props.editor,
},
editor: props.editor,
});

if (!props.clientRect) return;

popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () =>
document.querySelector(".active-editor") ??
document.querySelector('[id^="editor-container"]') ??
document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
hideOnClick: false,
sticky: "reference",
animation: false,
duration: 0,
offset: [0, 8],
});
// Append to editor container
const targetElement =
(props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body;
targetElement.appendChild(component.element);
},

onUpdate: (props: SuggestionProps): void => {
const emojiListProps: EmojiListProps = {
if (!component) return;

component.updateProps({
items: props.items,
command: props.command,
editor: props.editor,
};

component.updateProps(emojiListProps);

if (popup && props.clientRect) {
popup[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
});
},

onKeyDown: (props: SuggestionKeyDownProps): boolean => {
if (props.event.key === "Escape") {
if (popup) {
popup[0]?.hide();
}
if (component) {
component.destroy();
}
return true;
}

return component.ref?.onKeyDown(props) || false;
// Delegate to EmojiList
return component?.ref?.onKeyDown(props) || false;
},

onExit: (props: SuggestionProps): void => {
const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
onExit: (): void => {
// Remove from active dropdowns
if (editor) {
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
}
}

if (popup) {
popup[0]?.destroy();
}
// Cleanup
if (component) {
component.destroy();
}
Expand Down