diff --git a/src/components/EmojiShortcodeAutocomplete.tsx b/src/components/EmojiShortcodeAutocomplete.tsx index 8e72be1a..40555055 100644 --- a/src/components/EmojiShortcodeAutocomplete.tsx +++ b/src/components/EmojiShortcodeAutocomplete.tsx @@ -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(null); const listRef = useRef(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 = (
@@ -382,7 +388,7 @@ export function EmojiShortcodeAutocomplete({ className="size-5 object-contain shrink-0" /> ) : ( - {emoji.native} + {emoji.native} )} :{emoji.id.replace('custom:', '')}: @@ -392,4 +398,8 @@ export function EmojiShortcodeAutocomplete({
); + + // 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); } diff --git a/src/components/MentionAutocomplete.tsx b/src/components/MentionAutocomplete.tsx index 5faf9ecc..ea537eda 100644 --- a/src/components/MentionAutocomplete.tsx +++ b/src/components/MentionAutocomplete.tsx @@ -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; @@ -89,6 +90,14 @@ export function MentionAutocomplete({ const dropdownRef = useRef(null); const listRef = useRef(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 = (
@@ -273,6 +278,10 @@ export function MentionAutocomplete({
); + + // 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({ diff --git a/src/hooks/usePortalDropdown.ts b/src/hooks/usePortalDropdown.ts new file mode 100644 index 00000000..5b150877 --- /dev/null +++ b/src/hooks/usePortalDropdown.ts @@ -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; + /** 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 }; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index e676f0dc..c6881f45 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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))',