[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:
parent
b909416c74
commit
28375c46e5
2 changed files with 164 additions and 141 deletions
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue