From f4363dcbff11a4ffa4db7efccdf0cd270e4fba8c Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sun, 5 Apr 2026 17:27:22 -0500 Subject: [PATCH 1/2] Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text - Switch autocomplete dropdowns from absolute to fixed positioning so they aren't clipped by ancestor overflow containers (e.g. the compose modal's overflow-y-auto wrapper) - Add viewport-relative coordinate calculation using getBoundingClientRect - Add flip logic to show dropdown above cursor when near viewport bottom - Dismiss dropdown on scroll/resize since fixed position doesn't track - Add font-emoji utility class to force emoji presentation for native Unicode characters (star, fire, etc.) that may render as text glyphs - Apply same fixes to MentionAutocomplete for consistency Closes #216 --- src/components/EmojiShortcodeAutocomplete.tsx | 40 ++++++++++++++++--- src/components/MentionAutocomplete.tsx | 39 +++++++++++++++--- tailwind.config.ts | 1 + 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/components/EmojiShortcodeAutocomplete.tsx b/src/components/EmojiShortcodeAutocomplete.tsx index 8e72be1a..f7985e14 100644 --- a/src/components/EmojiShortcodeAutocomplete.tsx +++ b/src/components/EmojiShortcodeAutocomplete.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import data from '@emoji-mart/data'; import { CustomEmojiImg } from '@/components/CustomEmoji'; import { cn } from '@/lib/utils'; @@ -237,12 +238,22 @@ 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; + const rect = textarea.getBoundingClientRect(); + const top = rect.top + coords.top - textarea.scrollTop + lineHeight + 4; + const left = rect.left + Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)); + + // If the dropdown would overflow the bottom of the viewport, flip it above the cursor + const dropdownHeight = 280; // max-h-[280px] + const flippedTop = rect.top + coords.top - textarea.scrollTop - dropdownHeight - 4; + const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0; + setDropdownPos({ - top: coords.top + lineHeight + 4, - left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)), + top: useFlipped ? flippedTop : top, + left: Math.max(8, Math.min(left, window.innerWidth - 288)), }); }, [textareaRef]); @@ -279,6 +290,19 @@ export function EmojiShortcodeAutocomplete({ detectShortcode(content, textarea.selectionStart); }, [content, detectShortcode, textareaRef]); + // Dismiss the dropdown when any ancestor scrolls, since we use fixed + // positioning and the dropdown would become misaligned. + useEffect(() => { + if (!isOpen) return; + const handleScroll = () => setIsOpen(false); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleScroll); + }; + }, [isOpen]); + // Handle keyboard navigation within the dropdown useEffect(() => { if (!isOpen || results.length === 0) return; @@ -357,10 +381,10 @@ export function EmojiShortcodeAutocomplete({ return null; } - return ( + const dropdown = (
@@ -382,7 +406,7 @@ export function EmojiShortcodeAutocomplete({ className="size-5 object-contain shrink-0" /> ) : ( - {emoji.native} + {emoji.native} )} :{emoji.id.replace('custom:', '')}: @@ -392,4 +416,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 createPortal(dropdown, document.body); } diff --git a/src/components/MentionAutocomplete.tsx b/src/components/MentionAutocomplete.tsx index 5faf9ecc..734bb2e1 100644 --- a/src/components/MentionAutocomplete.tsx +++ b/src/components/MentionAutocomplete.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { nip19 } from 'nostr-tools'; import { UserRoundCheck } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -140,13 +141,22 @@ 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; + const rect = textarea.getBoundingClientRect(); + const top = rect.top + coords.top - textarea.scrollTop + lineHeight + 4; + const left = rect.left + Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)); + + // If the dropdown would overflow the bottom of the viewport, flip it above the cursor + const dropdownHeight = 240; // max-h-[240px] + const flippedTop = rect.top + coords.top - textarea.scrollTop - dropdownHeight - 4; + const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0; + setDropdownPos({ - top: coords.top + lineHeight + 4, - left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)), + top: useFlipped ? flippedTop : top, + left: Math.max(8, Math.min(left, window.innerWidth - 288)), }); }, [textareaRef]); @@ -191,6 +201,19 @@ export function MentionAutocomplete({ detectMention(content, textarea.selectionStart); }, [content, detectMention, textareaRef]); + // Dismiss the dropdown when any ancestor scrolls, since we use fixed + // positioning and the dropdown would become misaligned. + useEffect(() => { + if (!isOpen) return; + const handleScroll = () => setIsOpen(false); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleScroll); + }; + }, [isOpen]); + // Handle keyboard navigation within the dropdown useEffect(() => { if (!isOpen || !profiles || profiles.length === 0) return; @@ -254,10 +277,10 @@ export function MentionAutocomplete({ return null; } - return ( + const dropdown = (
@@ -273,6 +296,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 createPortal(dropdown, document.body); } function MentionItem({ diff --git a/tailwind.config.ts b/tailwind.config.ts index a4cf2460..f753b008 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))', From 173f7892428e02c0d8cdbcf1d55cc3ca7440fc7b Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 10 Apr 2026 12:50:05 -0500 Subject: [PATCH 2/2] Extract shared portal dropdown logic into usePortalDropdown hook Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical logic for fixed viewport positioning with viewport-flip, scroll/resize dismissal, and portal rendering. Extract into a shared hook to reduce duplication and centralize the positioning behavior. --- src/components/EmojiShortcodeAutocomplete.tsx | 42 +++------- src/components/MentionAutocomplete.tsx | 42 +++------- src/hooks/usePortalDropdown.ts | 79 +++++++++++++++++++ 3 files changed, 103 insertions(+), 60 deletions(-) create mode 100644 src/hooks/usePortalDropdown.ts diff --git a/src/components/EmojiShortcodeAutocomplete.tsx b/src/components/EmojiShortcodeAutocomplete.tsx index f7985e14..40555055 100644 --- a/src/components/EmojiShortcodeAutocomplete.tsx +++ b/src/components/EmojiShortcodeAutocomplete.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { createPortal } from 'react-dom'; 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; @@ -187,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 @@ -241,21 +249,8 @@ export function EmojiShortcodeAutocomplete({ // 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; - const rect = textarea.getBoundingClientRect(); - const top = rect.top + coords.top - textarea.scrollTop + lineHeight + 4; - const left = rect.left + Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)); - - // If the dropdown would overflow the bottom of the viewport, flip it above the cursor - const dropdownHeight = 280; // max-h-[280px] - const flippedTop = rect.top + coords.top - textarea.scrollTop - dropdownHeight - 4; - const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0; - - setDropdownPos({ - top: useFlipped ? flippedTop : top, - left: Math.max(8, Math.min(left, window.innerWidth - 288)), - }); - }, [textareaRef]); + setDropdownPos(computePosition(coords)); + }, [textareaRef, computePosition]); // Listen for input/cursor changes on the textarea element useEffect(() => { @@ -290,19 +285,6 @@ export function EmojiShortcodeAutocomplete({ detectShortcode(content, textarea.selectionStart); }, [content, detectShortcode, textareaRef]); - // Dismiss the dropdown when any ancestor scrolls, since we use fixed - // positioning and the dropdown would become misaligned. - useEffect(() => { - if (!isOpen) return; - const handleScroll = () => setIsOpen(false); - window.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - return () => { - window.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - }; - }, [isOpen]); - // Handle keyboard navigation within the dropdown useEffect(() => { if (!isOpen || results.length === 0) return; @@ -419,5 +401,5 @@ 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 createPortal(dropdown, document.body); + return renderPortal(dropdown, document.body); } diff --git a/src/components/MentionAutocomplete.tsx b/src/components/MentionAutocomplete.tsx index 734bb2e1..ea537eda 100644 --- a/src/components/MentionAutocomplete.tsx +++ b/src/components/MentionAutocomplete.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { createPortal } from 'react-dom'; import { nip19 } from 'nostr-tools'; import { UserRoundCheck } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -9,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; @@ -90,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 : '', ); @@ -144,21 +152,8 @@ export function MentionAutocomplete({ // 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; - const rect = textarea.getBoundingClientRect(); - const top = rect.top + coords.top - textarea.scrollTop + lineHeight + 4; - const left = rect.left + Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)); - - // If the dropdown would overflow the bottom of the viewport, flip it above the cursor - const dropdownHeight = 240; // max-h-[240px] - const flippedTop = rect.top + coords.top - textarea.scrollTop - dropdownHeight - 4; - const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0; - - setDropdownPos({ - top: useFlipped ? flippedTop : top, - left: Math.max(8, Math.min(left, window.innerWidth - 288)), - }); - }, [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 @@ -201,19 +196,6 @@ export function MentionAutocomplete({ detectMention(content, textarea.selectionStart); }, [content, detectMention, textareaRef]); - // Dismiss the dropdown when any ancestor scrolls, since we use fixed - // positioning and the dropdown would become misaligned. - useEffect(() => { - if (!isOpen) return; - const handleScroll = () => setIsOpen(false); - window.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - return () => { - window.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - }; - }, [isOpen]); - // Handle keyboard navigation within the dropdown useEffect(() => { if (!isOpen || !profiles || profiles.length === 0) return; @@ -299,7 +281,7 @@ 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 createPortal(dropdown, document.body); + 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 }; +}