[WIKI-521] fix : emoji unexpectedly scroll (#7344)

* fix: emoji modal scroll

* refactor: emoji list

* fix: escape behavior

* fix: minor type fixes
This commit is contained in:
Vipin Chaudhary 2025-07-04 18:35:23 +05:30 committed by GitHub
parent b909416c74
commit 28375c46e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 164 additions and 141 deletions

View file

@ -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";
@ -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 => {
@ -37,26 +58,68 @@ 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;
@ -64,9 +127,7 @@ export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) =
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" });
}
}
@ -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(" - ");
<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}
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
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>
);
});

View file

@ -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"];
@ -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,
};
getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
component = new ReactRenderer(EmojiList, {
props: emojiListProps,
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],
editor = props.editor;
// Track active dropdown
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
component = new ReactRenderer(EmojiList, {
props: {
items: props.items,
command: props.command,
editor: props.editor,
},
editor: props.editor,
});
// 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();
}