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:
Chad Curtis
2026-04-12 14:13:32 +00:00
4 changed files with 119 additions and 20 deletions
+20 -10
View File
@@ -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);
}
+19 -10
View File
@@ -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({
+79
View File
@@ -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 };
}
+1
View File
@@ -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))',