Merge branch 'fix/emoji-shortcode-autocomplete' into 'main'
Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text Closes #216 See merge request soapbox-pub/ditto!160
This commit is contained in:
@@ -3,6 +3,7 @@ import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface EmojiData {
|
||||
id: string;
|
||||
@@ -186,6 +187,14 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 280, // must match max-h-[280px] below
|
||||
});
|
||||
|
||||
const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]);
|
||||
|
||||
// Detect :shortcode query at cursor
|
||||
@@ -237,14 +246,11 @@ export function EmojiShortcodeAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the : character
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, colonPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element
|
||||
useEffect(() => {
|
||||
@@ -357,10 +363,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
@@ -382,7 +388,7 @@ export function EmojiShortcodeAutocomplete({
|
||||
className="size-5 object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
<span className="text-xl leading-none shrink-0 font-emoji">{emoji.native}</span>
|
||||
)}
|
||||
<span className="text-sm truncate">
|
||||
:{emoji.id.replace('custom:', '')}:
|
||||
@@ -392,4 +398,8 @@ export function EmojiShortcodeAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useNip05Verify } from '@/hooks/useNip05Verify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface MentionAutocompleteProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
@@ -89,6 +90,14 @@ export function MentionAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 240, // must match max-h-[240px] below
|
||||
});
|
||||
|
||||
const { data: profiles, followedPubkeys } = useSearchProfiles(
|
||||
isOpen ? mentionQuery : '',
|
||||
);
|
||||
@@ -140,15 +149,11 @@ export function MentionAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the @ character, relative to the textarea's
|
||||
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, atPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element.
|
||||
// Re-attaches whenever the underlying DOM element changes (e.g. after
|
||||
@@ -254,10 +259,10 @@ export function MentionAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
@@ -273,6 +278,10 @@ export function MentionAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
function MentionItem({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useCallback, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface UsePortalDropdownOptions {
|
||||
/** Ref to the textarea the dropdown is anchored to. */
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
/** Whether the dropdown is currently visible. */
|
||||
isOpen: boolean;
|
||||
/** Callback to close the dropdown (e.g. on scroll/resize). */
|
||||
onClose: () => void;
|
||||
/** Max height of the dropdown in px (must match the CSS max-h value). */
|
||||
dropdownHeight: number;
|
||||
/** Width of the dropdown in px (must match the CSS width value). */
|
||||
dropdownWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes fixed viewport coordinates for an autocomplete dropdown anchored
|
||||
* to a caret position inside a textarea. The dropdown is positioned below
|
||||
* the caret line, or flipped above if it would overflow the viewport bottom.
|
||||
*
|
||||
* Also dismisses the dropdown on scroll or resize, since fixed positioning
|
||||
* would cause misalignment.
|
||||
*
|
||||
* Use `renderPortal` to render the dropdown as a portal to `document.body`
|
||||
* so it escapes ancestor overflow clipping and CSS transform containing
|
||||
* blocks (e.g. Radix Dialog).
|
||||
*/
|
||||
export function usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose,
|
||||
dropdownHeight,
|
||||
dropdownWidth = 280,
|
||||
}: UsePortalDropdownOptions) {
|
||||
|
||||
/** Compute fixed viewport position for the dropdown given a caret index. */
|
||||
const computePosition = useCallback(
|
||||
(caretCoords: { top: number; left: number }): DropdownPosition => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return { top: 0, left: 0 };
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const top = rect.top + caretCoords.top - textarea.scrollTop + lineHeight + 4;
|
||||
const left = rect.left + Math.max(0, Math.min(caretCoords.left, textarea.clientWidth - dropdownWidth));
|
||||
|
||||
// If the dropdown would overflow the bottom of the viewport, flip above
|
||||
const flippedTop = rect.top + caretCoords.top - textarea.scrollTop - dropdownHeight - 4;
|
||||
const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0;
|
||||
|
||||
return {
|
||||
top: useFlipped ? flippedTop : top,
|
||||
left: Math.max(8, Math.min(left, window.innerWidth - dropdownWidth - 8)),
|
||||
};
|
||||
},
|
||||
[textareaRef, dropdownHeight, dropdownWidth],
|
||||
);
|
||||
|
||||
// Dismiss the dropdown when any ancestor scrolls or the window resizes,
|
||||
// since fixed positioning would cause the dropdown to become misaligned.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleDismiss = () => onClose();
|
||||
window.addEventListener('scroll', handleDismiss, true);
|
||||
window.addEventListener('resize', handleDismiss);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleDismiss, true);
|
||||
window.removeEventListener('resize', handleDismiss);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return { computePosition, renderPortal: createPortal };
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
emoji: ['Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'EmojiSymbols', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
|
||||
Reference in New Issue
Block a user