diff --git a/src/components/calls/group/GroupCallParticipant.tsx b/src/components/calls/group/GroupCallParticipant.tsx index 7784834db..59c74ea07 100644 --- a/src/components/calls/group/GroupCallParticipant.tsx +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -15,7 +15,6 @@ import renderText from '../../common/helpers/renderText'; import formatGroupCallVolume from './helpers/formatGroupCallVolume'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import useMenuPosition from '../../../hooks/useMenuPosition'; import useOldLang from '../../../hooks/useOldLang'; import Avatar from '../../common/Avatar'; @@ -52,7 +51,7 @@ const GroupCallParticipant: FC = ({ const { isContextMenuOpen, - contextMenuPosition, + contextMenuAnchor, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose, @@ -76,16 +75,6 @@ const GroupCallParticipant: FC = ({ [], ); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - const hasCustomVolume = Boolean( !isMuted && isSpeaking && participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME, ); @@ -147,11 +136,11 @@ const GroupCallParticipant: FC = ({ ; -}; +type OwnProps = + { + participant?: GroupCallParticipant; + onCloseAnimationEnd: VoidFunction; + onClose: VoidFunction; + isDropdownOpen: boolean; + menuRef?: React.RefObject; + } + & MenuPositionOptions; type StateProps = { isAdmin: boolean; @@ -58,12 +54,8 @@ const GroupCallParticipantMenu: FC = ({ onClose, isDropdownOpen, isAdmin, - positionY, menuRef, - positionX, - style, - transformOriginY, - transformOriginX, + ...menuPositionOptions }) => { const { toggleGroupCallMute, @@ -175,16 +167,13 @@ const GroupCallParticipantMenu: FC = ({
{!isSelf && !shouldRaiseHand && (
diff --git a/src/components/calls/group/GroupCallParticipantVideo.tsx b/src/components/calls/group/GroupCallParticipantVideo.tsx index bc4e70177..1332a5817 100644 --- a/src/components/calls/group/GroupCallParticipantVideo.tsx +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -22,7 +22,6 @@ import formatGroupCallVolume from './helpers/formatGroupCallVolume'; import useInterval from '../../../hooks/schedulers/useInterval'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMenuPosition from '../../../hooks/useMenuPosition'; import useOldLang from '../../../hooks/useOldLang'; import FullNameTitle from '../../common/FullNameTitle'; @@ -209,7 +208,7 @@ const GroupCallParticipantVideo: FC = ({ const { isContextMenuOpen, - contextMenuPosition, + contextMenuAnchor, handleContextMenu, handleContextMenuClose, handleContextMenuHide, @@ -232,16 +231,6 @@ const GroupCallParticipantVideo: FC = ({ [], ); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - const handleClickPin = useCallback(() => { setPinned(!isPinned ? { id: user?.id || chat!.id, @@ -318,11 +307,11 @@ const GroupCallParticipantVideo: FC = ({ = ({ } = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu)); const { - contextMenuPosition: storyReactionPickerPosition, + contextMenuAnchor: storyReactionPickerAnchor, handleContextMenu: handleStoryPickerContextMenu, handleBeforeContextMenu: handleBeforeStoryPickerContextMenu, handleContextMenuHide: handleStoryPickerContextMenuHide, @@ -839,15 +838,15 @@ const Composer: FC = ({ useEffect(() => { if (isReactionPickerOpen) return; - if (storyReactionPickerPosition) { + if (storyReactionPickerAnchor) { openStoryReactionPicker({ peerId: chatId, storyId: storyId!, - position: storyReactionPickerPosition, + position: storyReactionPickerAnchor, }); handleStoryPickerContextMenuHide(); } - }, [chatId, handleStoryPickerContextMenuHide, isReactionPickerOpen, storyId, storyReactionPickerPosition]); + }, [chatId, handleStoryPickerContextMenuHide, isReactionPickerOpen, storyId, storyReactionPickerAnchor]); useClipboardPaste( isForCurrentMessageList || isInStoryViewer, diff --git a/src/components/common/GifButton.tsx b/src/components/common/GifButton.tsx index 8c501f0bb..3771b79e4 100644 --- a/src/components/common/GifButton.tsx +++ b/src/components/common/GifButton.tsx @@ -17,7 +17,6 @@ import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; -import useMenuPosition from '../../hooks/useMenuPosition'; import useOldLang from '../../hooks/useOldLang'; import Button from '../ui/Button'; @@ -69,7 +68,7 @@ const GifButton: FC = ({ const isVideoReady = loadAndPlay && isBuffered; const { - isContextMenuOpen, contextMenuPosition, + isContextMenuOpen, contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref); @@ -78,15 +77,6 @@ const GifButton: FC = ({ const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll, .no-scrollbar')); const getMenuElement = useLastCallback(() => ref.current!.querySelector('.gif-context-menu .bubble')); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - ); - const handleClick = useLastCallback(() => { if (isContextMenuOpen || !onClick) return; onClick({ @@ -185,14 +175,13 @@ const GifButton: FC = ({ {shouldRenderSpinner && ( )} - {onClick && contextMenuPosition !== undefined && ( + {onClick && contextMenuAnchor !== undefined && ( ref.current); const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll, .no-scrollbar')); const getMenuElement = useLastCallback(() => { return isStatusPicker ? menuRef.current : ref.current!.querySelector('.sticker-context-menu .bubble'); }); - - const getLayout = () => ({ withPortal: isStatusPicker, shouldAvoidNegativePosition: true }); - - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); + const getLayout = useLastCallback(() => ({ withPortal: isStatusPicker, shouldAvoidNegativePosition: true })); useEffect(() => { if (isContextMenuOpen) { @@ -345,11 +333,11 @@ const StickerButton = = ({ ]); const { - isContextMenuOpen, contextMenuPosition, + isContextMenuOpen, contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref); - const isContextMenuShown = contextMenuPosition !== undefined; + const isContextMenuShown = contextMenuAnchor !== undefined; const handleMouseDown = (e: React.MouseEvent) => { preventMessageInputBlur(e); @@ -372,10 +370,10 @@ const ActionMessage: FC = ({ )} {isJoinedMessage && } - {contextMenuPosition && ( + {contextMenuAnchor && ( = ({ const menuButtonRef = useRef(null); const lang = useOldLang(); const [isMenuOpen, setIsMenuOpen] = useState(false); - const [menuPosition, setMenuPosition] = useState(undefined); + const [menuAnchor, setMenuAnchor] = useState(undefined); const handleHeaderMenuOpen = useLastCallback(() => { setIsMenuOpen(true); const rect = menuButtonRef.current!.getBoundingClientRect(); - setMenuPosition({ x: rect.right, y: rect.bottom }); + setMenuAnchor({ x: rect.right, y: rect.bottom }); }); const handleHeaderMenuClose = useLastCallback(() => { @@ -155,7 +155,7 @@ const HeaderActions: FC = ({ }); const handleHeaderMenuHide = useLastCallback(() => { - setMenuPosition(undefined); + setMenuAnchor(undefined); }); const handleSubscribeClick = useLastCallback(() => { @@ -420,12 +420,12 @@ const HeaderActions: FC = ({ > - {menuPosition && ( + {menuAnchor && ( = ({ useEffect(() => (isShown ? captureEscKeyListener(clearEmbedded) : undefined), [isShown, clearEmbedded]); const { - isContextMenuOpen, contextMenuPosition, handleContextMenu, + isContextMenuOpen, contextMenuAnchor, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref); @@ -199,15 +198,6 @@ const ComposerEmbeddedMessage: FC = ({ const getRootElement = useLastCallback(() => ref.current!); const getMenuElement = useLastCallback(() => ref.current!.querySelector('.forward-context-menu .bubble')); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - ); - useEffect(() => { if (!shouldRender) { handleContextMenuClose(); @@ -296,11 +286,10 @@ const ComposerEmbeddedMessage: FC = ({ {(isShowingReply || isForwarding) && !isContextMenuDisabled && ( = ({ isAttachmentModal, canSendPlainText, className, - positionX, - positionY, - transformOriginX, - transformOriginY, - style, isBackgroundTranslucent, onLoad, onClose, @@ -103,6 +95,7 @@ const SymbolMenu: FC = ({ onSearchOpen, addRecentEmoji, addRecentCustomEmoji, + ...menuPositionOptions }) => { const [activeTab, setActiveTab] = useState(0); const [recentEmojis, setRecentEmojis] = useState([]); @@ -325,8 +318,6 @@ const SymbolMenu: FC = ({ return ( = ({ onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} noCloseOnBackdrop={!IS_TOUCH_ENV} noCompact - transformOriginX={transformOriginX} - transformOriginY={transformOriginY} - style={style} + // eslint-disable-next-line react/jsx-props-no-spreading + {...(isAttachmentModal ? menuPositionOptions : { + positionX: 'left', + positionY: 'bottom', + })} > {content} diff --git a/src/components/middle/composer/SymbolMenuButton.tsx b/src/components/middle/composer/SymbolMenuButton.tsx index 0ea7c4d4b..6fb3f5efe 100644 --- a/src/components/middle/composer/SymbolMenuButton.tsx +++ b/src/components/middle/composer/SymbolMenuButton.tsx @@ -10,7 +10,6 @@ import buildClassName from '../../../util/buildClassName'; import useFlag from '../../../hooks/useFlag'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMenuPosition from '../../../hooks/useMenuPosition'; import Button from '../../ui/Button'; import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; @@ -89,7 +88,7 @@ const SymbolMenuButton: FC = ({ const triggerRef = useRef(null); const [isSymbolMenuLoaded, onSymbolMenuLoadingComplete] = useFlag(); - const [contextMenuPosition, setContextMenuPosition] = useState(undefined); + const [contextMenuAnchor, setContextMenuAnchor] = useState(undefined); const symbolMenuButtonClassName = buildClassName( 'mobile-symbol-menu-button', @@ -106,7 +105,7 @@ const SymbolMenuButton: FC = ({ const triggerEl = triggerRef.current; if (!triggerEl) return; const { x, y } = triggerEl.getBoundingClientRect(); - setContextMenuPosition({ x, y }); + setContextMenuAnchor({ x, y }); }); const handleSearchOpen = useLastCallback((type: 'stickers' | 'gifs') => { @@ -141,16 +140,6 @@ const SymbolMenuButton: FC = ({ const getMenuElement = useLastCallback(() => document.querySelector('#portals .SymbolMenu .bubble')); const getLayout = useLastCallback(() => ({ withPortal: true })); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - return ( <> {isMobile ? ( @@ -199,11 +188,11 @@ const SymbolMenuButton: FC = ({ isAttachmentModal={isAttachmentModal} canSendPlainText={canSendPlainText} className={buildClassName(className, forceDarkTheme && 'component-theme-dark')} - positionX={isAttachmentModal ? positionX : undefined} - positionY={isAttachmentModal ? positionY : undefined} - transformOriginX={isAttachmentModal ? transformOriginX : undefined} - transformOriginY={isAttachmentModal ? transformOriginY : undefined} - style={isAttachmentModal ? menuStyle : undefined} + anchor={isAttachmentModal ? contextMenuAnchor : undefined} + getTriggerElement={isAttachmentModal ? getTriggerElement : undefined} + getRootElement={isAttachmentModal ? getRootElement : undefined} + getMenuElement={isAttachmentModal ? getMenuElement : undefined} + getLayout={isAttachmentModal ? getLayout : undefined} /> ); diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index c0cb25fcf..b6f245ce7 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -1,7 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { - memo, useEffect, useRef, -} from '../../../lib/teact/teact'; +import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { @@ -23,7 +21,6 @@ import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useDerivedSignal from '../../../hooks/useDerivedSignal'; import useDerivedState from '../../../hooks/useDerivedState'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMenuPosition from '../../../hooks/useMenuPosition'; import useOldLang from '../../../hooks/useOldLang'; import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated'; import useSyncEffect from '../../../hooks/useSyncEffect'; @@ -122,7 +119,7 @@ const WebPagePreview: FC = ({ }); const { - isContextMenuOpen, contextMenuPosition, handleContextMenu, + isContextMenuOpen, contextMenuAnchor, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref, isEditing, true); @@ -132,15 +129,6 @@ const WebPagePreview: FC = ({ () => ref.current!.querySelector('.web-page-preview-context-menu .bubble'), ); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - ); - const handlePreviewClick = useLastCallback((e: React.MouseEvent): void => { handleContextMenu(e); }); @@ -172,11 +160,10 @@ const WebPagePreview: FC = ({ return ( = ({ <> { isInvertedMedia ? ( - // eslint-disable-next-line react/jsx-no-bind + // eslint-disable-next-line react/jsx-no-bind updateIsInvertedMedia(undefined)}> {lang('PreviewSender.MoveTextUp')} diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 9f57e7239..025137d9f 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -1,6 +1,11 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; @@ -20,15 +25,10 @@ import type { ApiTypeStory, ApiUser, } from '../../../api/types'; -import type { - ActiveEmojiInteraction, - ChatTranslatedMessages, - MessageListType, -} from '../../../global/types'; +import type { ActiveEmojiInteraction, ChatTranslatedMessages, MessageListType } from '../../../global/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { - FocusDirection, IAlbum, ISettings, ScrollTargetPosition, - ThreadId, + FocusDirection, IAlbum, ISettings, ScrollTargetPosition, ThreadId, } from '../../../types'; import type { Signal } from '../../../util/signals'; import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage'; @@ -66,7 +66,8 @@ import { selectActiveDownloads, selectAnimatedEmoji, selectCanAutoLoadMedia, - selectCanAutoPlayMedia, selectCanReplyToMessage, + selectCanAutoPlayMedia, + selectCanReplyToMessage, selectChat, selectChatFullInfo, selectChatMessage, @@ -443,7 +444,7 @@ const Message: FC = ({ const { isContextMenuOpen, - contextMenuPosition, + contextMenuAnchor, contextMenuTarget, handleBeforeContextMenu, handleContextMenu: onContextMenu, @@ -535,7 +536,7 @@ const Message: FC = ({ ) && !album.messages.some((msg) => Object.keys(msg.content).length === 0); const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup; const isInDocumentGroupNotLast = isInDocumentGroup && !isLastInDocumentGroup; - const isContextMenuShown = contextMenuPosition !== undefined; + const isContextMenuShown = contextMenuAnchor !== undefined; const canShowActionButton = ( !(isContextMenuShown || isInSelectMode || isForwarding) && !isInDocumentGroupNotLast @@ -1650,10 +1651,10 @@ const Message: FC = ({ /> )}
- {contextMenuPosition && ( + {contextMenuAnchor && ( = ({ }, ANIMATION_DURATION); }, [isOpen, markIsReady, unmarkIsReady]); - const { - positionX, positionY, transformOriginX, transformOriginY, style, menuStyle, withScroll, - } = useMenuPosition(anchor, getTriggerElement, getRootElement, getMenuElement, getLayout); - useEffect(() => { - disableScrolling(withScroll ? scrollableRef.current : undefined, '.ReactionPicker'); + disableScrolling(scrollableRef.current, '.ReactionPicker'); return enableScrolling; - }, [withScroll]); + }, [isOpen]); const handleOpenMessageReactionPicker = useLastCallback((position: IAnchorPosition) => { onReactionPickerOpen!(position); @@ -342,12 +337,11 @@ const MessageContextMenu: FC = ({ = ({ )}
{canSendNow && {lang('MessageScheduleSend')}} {canReschedule && ( diff --git a/src/components/middle/message/SponsoredMessage.tsx b/src/components/middle/message/SponsoredMessage.tsx index cd63aedb5..89f663894 100644 --- a/src/components/middle/message/SponsoredMessage.tsx +++ b/src/components/middle/message/SponsoredMessage.tsx @@ -65,7 +65,7 @@ const SponsoredMessage: FC = ({ threshold: 1, }); const { - isContextMenuOpen, contextMenuPosition, + isContextMenuOpen, contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(ref, undefined, true, IS_ANDROID); @@ -183,10 +183,10 @@ const SponsoredMessage: FC = ({ )}
- {contextMenuPosition && ( + {contextMenuAnchor && ( = ({ ? -(menuRef.current.offsetWidth - REACTION_SELECTOR_WIDTH) / 2 - FULL_PICKER_SHIFT_DELTA.x / 2 : 0, })); - const { - positionX, positionY, transformOriginX, transformOriginY, style, - } = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout); const handleToggleCustomReaction = useLastCallback((sticker: ApiSticker) => { if (!renderedChatId || !renderedMessageId) { @@ -213,11 +219,11 @@ const ReactionPicker: FC = ({ )} withPortal noCompact - positionX={positionX} - positionY={positionY} - transformOriginX={transformOriginX} - transformOriginY={transformOriginY} - style={style} + anchor={renderingPosition} + getTriggerElement={getTriggerElement} + getRootElement={getRootElement} + getMenuElement={getMenuElement} + getLayout={getLayout} backdropExcludedSelector=".Modal.confirm" onClose={closeReactionPicker} > diff --git a/src/components/middle/message/reactions/SavedTagButton.tsx b/src/components/middle/message/reactions/SavedTagButton.tsx index 33bee9ada..58eac8268 100644 --- a/src/components/middle/message/reactions/SavedTagButton.tsx +++ b/src/components/middle/message/reactions/SavedTagButton.tsx @@ -1,9 +1,7 @@ import React, { memo, useRef } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; -import type { - ApiReaction, ApiSavedReactionTag, -} from '../../../../api/types'; +import type { ApiReaction, ApiSavedReactionTag } from '../../../../api/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import buildClassName from '../../../../util/buildClassName'; @@ -12,7 +10,6 @@ import { REM } from '../../../common/helpers/mediaDimensions'; import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers'; import useFlag from '../../../../hooks/useFlag'; import useLastCallback from '../../../../hooks/useLastCallback'; -import useMenuPosition from '../../../../hooks/useMenuPosition'; import useOldLang from '../../../../hooks/useOldLang'; import ReactionAnimatedEmoji from '../../../common/reactions/ReactionAnimatedEmoji'; @@ -88,7 +85,7 @@ const SavedTagButton = ({ const { isContextMenuOpen, - contextMenuPosition, + contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, @@ -98,18 +95,7 @@ const SavedTagButton = ({ const getTriggerElement = useLastCallback(() => ref.current); const getRootElement = useLastCallback(() => document.body); const getMenuElement = useLastCallback(() => menuRef.current); - - const getLayout = () => ({ withPortal: true, shouldAvoidNegativePosition: true }); - - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); + const getLayout = useLastCallback(() => ({ withPortal: true, shouldAvoidNegativePosition: true })); if (withCount && count === 0) { return undefined; @@ -166,15 +152,15 @@ const SavedTagButton = ({ onSubmit={handleRenameTag} /> )} - {withContextMenu && contextMenuPosition && ( + {withContextMenu && contextMenuAnchor && ( { openStoryViewer({ @@ -156,14 +146,14 @@ function MediaStory({ {isFullyLoaded && } {isProtected && } - {contextMenuPosition !== undefined && ( + {contextMenuAnchor !== undefined && ( ref.current!.querySelector('.story-peer-context-menu .bubble')); const getLayout = useLastCallback(() => ({ withPortal: true, isDense: true })); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - const handleClick = useLastCallback(() => { if (isContextMenuOpen) return; @@ -114,14 +103,14 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
{isSelf ? lang('MyStory') : getSenderTitle(lang, peer)}
- {contextMenuPosition !== undefined && ( + {contextMenuAnchor !== undefined && ( = ({ }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); - // eslint-disable-next-line no-null/no-null - const dropdownRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const toggleIsOpen = () => { setIsOpen(!isOpen); + if (isOpen) { onClose?.(); } else { @@ -96,7 +94,6 @@ const DropdownMenu: FC = ({ return (
= ({ = ({ const [isTouched, markIsTouched, unmarkIsTouched] = useFlag(); const { - isContextMenuOpen, contextMenuPosition, + isContextMenuOpen, contextMenuAnchor, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(containerRef, !contextActions); @@ -133,16 +132,6 @@ const ListItem: FC = ({ }); const getLayout = useLastCallback(() => ({ withPortal: withPortalForMenu })); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - const handleClickEvent = useLastCallback((e: React.MouseEvent) => { const hasModifierKey = e.ctrlKey || e.metaKey || e.shiftKey; if (!hasModifierKey && e.button === MouseButton.Main) { @@ -215,7 +204,7 @@ const ListItem: FC = ({ disabled && 'disabled', allowDisabledClick && 'click-allowed', inactive && 'inactive', - contextMenuPosition && 'has-menu-open', + contextMenuAnchor && 'has-menu-open', focus && 'focus', destructive && 'destructive', multiline && 'multiline', @@ -267,14 +256,14 @@ const ListItem: FC = ({ )} {rightElement} - {contextActions && contextMenuPosition !== undefined && ( + {contextActions && contextMenuAnchor !== undefined && ( ; - containerRef?: React.RefObject; - isOpen: boolean; - shouldCloseFast?: boolean; - id?: string; - className?: string; - bubbleClassName?: string; - style?: string; - bubbleStyle?: string; - ariaLabelledBy?: string; - transformOriginX?: number; - transformOriginY?: number; - positionX?: 'left' | 'right'; - positionY?: 'top' | 'bottom'; - autoClose?: boolean; - footer?: string; - noCloseOnBackdrop?: boolean; - backdropExcludedSelector?: string; - noCompact?: boolean; - onKeyDown?: (e: React.KeyboardEvent) => void; - onCloseAnimationEnd?: () => void; - onClose: () => void; - onMouseEnter?: (e: React.MouseEvent) => void; - onMouseEnterBackdrop?: (e: React.MouseEvent) => void; - onMouseLeave?: (e: React.MouseEvent) => void; - withPortal?: boolean; - children?: React.ReactNode; -}; +export type { MenuPositionOptions } from '../../hooks/useMenuPosition'; + +type OwnProps = + { + ref?: React.RefObject; + isOpen: boolean; + shouldCloseFast?: boolean; + id?: string; + className?: string; + bubbleClassName?: string; + ariaLabelledBy?: string; + autoClose?: boolean; + footer?: string; + noCloseOnBackdrop?: boolean; + backdropExcludedSelector?: string; + noCompact?: boolean; + onKeyDown?: (e: React.KeyboardEvent) => void; + onCloseAnimationEnd?: () => void; + onClose: () => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseEnterBackdrop?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + withPortal?: boolean; + children?: React.ReactNode; + } + & MenuPositionOptions; const ANIMATION_DURATION = 200; const Menu: FC = ({ ref: externalRef, - containerRef, shouldCloseFast, isOpen, id, className, bubbleClassName, - style, - bubbleStyle, ariaLabelledBy, children, - transformOriginX, - transformOriginY, - positionX = 'left', - positionY = 'top', autoClose = false, footer, noCloseOnBackdrop = false, @@ -79,16 +73,20 @@ const Menu: FC = ({ onMouseLeave, withPortal, onMouseEnterBackdrop, + ...positionOptions }) => { const { isTouchScreen } = useAppLayout(); - const { ref: menuRef } = useShowTransition({ + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + const { ref: bubbleRef } = useShowTransition({ isOpen, ref: externalRef, onCloseAnimationEnd, }); - const backdropContainerRef = containerRef || menuRef; + useMenuPosition(isOpen, containerRef, bubbleRef, positionOptions); useEffect( () => (isOpen ? captureEscKeyListener(onClose) : undefined), @@ -107,11 +105,11 @@ const Menu: FC = ({ } }, [isOpen]); - const handleKeyDown = useKeyboardListNavigation(menuRef, isOpen, autoClose ? onClose : undefined, undefined, true); + const handleKeyDown = useKeyboardListNavigation(bubbleRef, isOpen, autoClose ? onClose : undefined, undefined, true); useVirtualBackdrop( isOpen, - backdropContainerRef, + containerRef, noCloseOnBackdrop ? undefined : onClose, undefined, backdropExcludedSelector, @@ -119,16 +117,11 @@ const Menu: FC = ({ const bubbleFullClassName = buildClassName( 'bubble menu-container custom-scroll', - positionY, - positionX, footer && 'with-footer', bubbleClassName, shouldCloseFast && 'close-fast', ); - const transformOriginYStyle = transformOriginY !== undefined ? `${transformOriginY}px` : undefined; - const transformOriginXStyle = transformOriginX !== undefined ? `${transformOriginX}px` : undefined; - const handleClick = useLastCallback((e: React.MouseEvent) => { e.stopPropagation(); if (autoClose) { @@ -138,6 +131,7 @@ const Menu: FC = ({ const menu = (
= ({ withPortal && 'in-portal', className, )} - style={style} aria-labelledby={ariaLabelledBy} role={ariaLabelledBy ? 'menu' : undefined} onKeyDown={isOpen ? handleKeyDown : undefined} @@ -163,12 +156,8 @@ const Menu: FC = ({ )}
{children} diff --git a/src/components/ui/Tab.tsx b/src/components/ui/Tab.tsx index efd5a3579..7ea854b76 100644 --- a/src/components/ui/Tab.tsx +++ b/src/components/ui/Tab.tsx @@ -12,7 +12,6 @@ import renderText from '../common/helpers/renderText'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import { useFastClick } from '../../hooks/useFastClick'; import useLastCallback from '../../hooks/useLastCallback'; -import useMenuPosition from '../../hooks/useMenuPosition'; import Menu from './Menu'; import MenuItem from './MenuItem'; @@ -106,7 +105,7 @@ const Tab: FC = ({ }, [isActive, previousActiveTab]); const { - contextMenuPosition, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose, + contextMenuAnchor, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose, handleContextMenuHide, isContextMenuOpen, } = useContextMenuHandlers(tabRef, !contextActions); @@ -131,16 +130,6 @@ const Tab: FC = ({ ); const getLayout = useLastCallback(() => ({ withPortal: true })); - const { - positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useMenuPosition( - contextMenuPosition, - getTriggerElement, - getRootElement, - getMenuElement, - getLayout, - ); - return (
= ({ - {contextActions && contextMenuPosition !== undefined && ( + {contextActions && contextMenuAnchor !== undefined && ( , ) => { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const [contextMenuPosition, setContextMenuPosition] = useState(undefined); + const [contextMenuAnchor, setContextMenuAnchor] = useState(undefined); const [contextMenuTarget, setContextMenuTarget] = useState(undefined); const handleBeforeContextMenu = useLastCallback((e: React.MouseEvent) => { @@ -51,12 +48,12 @@ const useContextMenuHandlers = ( e.preventDefault(); e.stopPropagation(); - if (contextMenuPosition) { + if (contextMenuAnchor) { return; } setIsContextMenuOpen(true); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuAnchor({ x: e.clientX, y: e.clientY }); setContextMenuTarget(e.target as HTMLElement); }); @@ -65,7 +62,7 @@ const useContextMenuHandlers = ( }); const handleContextMenuHide = useLastCallback(() => { - setContextMenuPosition(undefined); + setContextMenuAnchor(undefined); }); // Support context menu on touch devices @@ -93,7 +90,7 @@ const useContextMenuHandlers = ( const { clientX, clientY, target } = originalEvent.touches[0]; - if (contextMenuPosition || (shouldDisableOnLink && (target as HTMLElement).matches('a[href]'))) { + if (contextMenuAnchor || (shouldDisableOnLink && (target as HTMLElement).matches('a[href]'))) { return; } @@ -129,7 +126,7 @@ const useContextMenuHandlers = ( } setIsContextMenuOpen(true); - setContextMenuPosition({ x: clientX, y: clientY }); + setContextMenuAnchor({ x: clientX, y: clientY }); }; const startLongPressTimer = (e: TouchEvent) => { @@ -156,12 +153,12 @@ const useContextMenuHandlers = ( element.removeEventListener('touchmove', clearLongPressTimer); }; }, [ - contextMenuPosition, isMenuDisabled, shouldDisableOnLongTap, elementRef, shouldDisableOnLink, getIsReady, + contextMenuAnchor, isMenuDisabled, shouldDisableOnLongTap, elementRef, shouldDisableOnLink, getIsReady, ]); return { isContextMenuOpen, - contextMenuPosition, + contextMenuAnchor, contextMenuTarget, handleBeforeContextMenu, handleContextMenu, diff --git a/src/hooks/useMenuPosition.ts b/src/hooks/useMenuPosition.ts index 90779ad8d..013edf4f2 100644 --- a/src/hooks/useMenuPosition.ts +++ b/src/hooks/useMenuPosition.ts @@ -1,8 +1,35 @@ -import { useEffect, useState } from '../lib/teact/teact'; +import type React from '../lib/teact/teact'; +import { useLayoutEffect } from '../lib/teact/teact'; +import { addExtraClass, setExtraStyles } from '../lib/teact/teact-dom'; import type { IAnchorPosition } from '../types'; -interface Layout { +import { requestForcedReflow } from '../lib/fasterdom/fasterdom'; +import { useStateRef } from './useStateRef'; + +interface StaticPositionOptions { + anchor?: IAnchorPosition; + positionX?: 'left' | 'right'; + positionY?: 'top' | 'bottom'; + transformOriginX?: number; + transformOriginY?: number; + style?: string; + bubbleStyle?: string; +} + +interface DynamicPositionOptions { + anchor: IAnchorPosition; + getTriggerElement: () => HTMLElement | null; + getRootElement: () => HTMLElement | null; + getMenuElement: () => HTMLElement | null; + getLayout?: () => Layout; +} + +export type MenuPositionOptions = + StaticPositionOptions + | DynamicPositionOptions; + +export interface Layout { extraPaddingX?: number; extraTopPadding?: number; extraMarginTop?: number; @@ -21,124 +48,175 @@ const EMPTY_RECT = { }; export default function useMenuPosition( - anchor: IAnchorPosition | undefined, - getTriggerElement: () => HTMLElement | null, - getRootElement: () => HTMLElement | null, - getMenuElement: () => HTMLElement | null, - getLayout?: () => Layout, + isOpen: boolean, + containerRef: React.RefObject, + bubbleRef: React.RefObject, + options: MenuPositionOptions, ) { - const [positionX, setPositionX] = useState<'right' | 'left'>('right'); - const [positionY, setPositionY] = useState<'top' | 'bottom'>('bottom'); - const [transformOriginX, setTransformOriginX] = useState(); - const [transformOriginY, setTransformOriginY] = useState(); - const [withScroll, setWithScroll] = useState(false); - const [style, setStyle] = useState(''); - const [menuStyle, setMenuStyle] = useState('opacity: 0;'); + const optionsRef = useStateRef(options); - useEffect(() => { - const triggerEl = getTriggerElement(); - if (!anchor || !triggerEl) { - return; - } + useLayoutEffect(() => { + if (!isOpen) return; - let { x, y } = anchor; - const anchorX = x; - const anchorY = y; + const options2 = optionsRef.current; - const menuEl = getMenuElement(); - const rootEl = getRootElement(); - - const { - extraPaddingX = 0, - extraTopPadding = 0, - extraMarginTop = 0, - topShiftY = 0, - menuElMinWidth = 0, - deltaX = 0, - shouldAvoidNegativePosition = false, - withPortal = false, - isDense = false, - } = getLayout?.() || {}; - - const marginTop = menuEl ? parseInt(getComputedStyle(menuEl).marginTop, 10) + extraMarginTop : undefined; - const { offsetWidth: menuElWidth, offsetHeight: menuElHeight } = menuEl || { offsetWidth: 0, offsetHeight: 0 }; - const menuRect = menuEl ? { - width: Math.max(menuElWidth, menuElMinWidth), - height: menuElHeight + marginTop!, - } : EMPTY_RECT; - - const rootRect = rootEl ? rootEl.getBoundingClientRect() : EMPTY_RECT; - - let horizontalPosition: 'left' | 'right'; - let verticalPosition: 'top' | 'bottom'; - if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) { - x += 3; - horizontalPosition = 'left'; - } else if (x - menuRect.width - rootRect.left > 0) { - horizontalPosition = 'right'; - x -= 3; + if (!('getTriggerElement' in options2)) { + applyStaticOptions(containerRef, bubbleRef, options2); } else { - horizontalPosition = 'left'; - x = 16; + requestForcedReflow(() => { + const staticOptions = processDynamically(containerRef, bubbleRef, options2); + + return () => { + applyStaticOptions(containerRef, bubbleRef, staticOptions); + }; + }); } - setPositionX(horizontalPosition); + }, [isOpen, containerRef, bubbleRef, optionsRef]); +} - x += deltaX; +function applyStaticOptions( + containerRef: React.RefObject, + bubbleRef: React.RefObject, + { + positionX = 'left', + positionY = 'top', + transformOriginX, + transformOriginY, + style, + bubbleStyle, + }: StaticPositionOptions, +) { + const containerEl = containerRef.current!; + const bubbleEl = bubbleRef.current!; - const yWithTopShift = y + topShiftY; + if (style) { + containerEl.style.cssText = style; + } - if (isDense || (yWithTopShift + menuRect.height < rootRect.height + rootRect.top)) { - verticalPosition = 'top'; - y = yWithTopShift; - } else { - verticalPosition = 'bottom'; + if (bubbleStyle) { + bubbleEl.style.cssText = bubbleStyle; + } - if (y - menuRect.height < rootRect.top + extraTopPadding) { - y = rootRect.top + rootRect.height; - } + if (positionX) { + addExtraClass(bubbleEl, positionX); + } + + if (positionY) { + addExtraClass(bubbleEl, positionY); + } + + setExtraStyles(bubbleEl, { + transformOrigin: [ + transformOriginX ? `${transformOriginX}px` : positionX, + transformOriginY ? `${transformOriginY}px` : positionY, + ].join(' '), + }); +} + +function processDynamically( + containerRef: React.RefObject, + bubbleRef: React.RefObject, + { + anchor, + getRootElement, + getMenuElement, + getTriggerElement, + getLayout, + }: DynamicPositionOptions, +) { + const triggerEl = getTriggerElement()!; + + let { x, y } = anchor; + const anchorX = x; + const anchorY = y; + + const menuEl = getMenuElement(); + const rootEl = getRootElement(); + + const { + extraPaddingX = 0, + extraTopPadding = 0, + extraMarginTop = 0, + topShiftY = 0, + menuElMinWidth = 0, + deltaX = 0, + shouldAvoidNegativePosition = false, + withPortal = false, + isDense = false, + } = getLayout?.() || {}; + + const marginTop = menuEl ? parseInt(getComputedStyle(menuEl).marginTop, 10) + extraMarginTop : undefined; + const { offsetWidth: menuElWidth, offsetHeight: menuElHeight } = menuEl || { offsetWidth: 0, offsetHeight: 0 }; + const menuRect = menuEl ? { + width: Math.max(menuElWidth, menuElMinWidth), + height: menuElHeight + marginTop!, + } : EMPTY_RECT; + + const rootRect = rootEl ? rootEl.getBoundingClientRect() : EMPTY_RECT; + + let positionX: 'left' | 'right'; + let positionY: 'top' | 'bottom'; + if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) { + x += 3; + positionX = 'left'; + } else if (x - menuRect.width - rootRect.left > 0) { + positionX = 'right'; + x -= 3; + } else { + positionX = 'left'; + x = 16; + } + + x += deltaX; + + const yWithTopShift = y + topShiftY; + + if (isDense || (yWithTopShift + menuRect.height < rootRect.height + rootRect.top)) { + positionY = 'top'; + y = yWithTopShift; + } else { + positionY = 'bottom'; + + if (y - menuRect.height < rootRect.top + extraTopPadding) { + y = rootRect.top + rootRect.height; } + } - setPositionY(verticalPosition); + const triggerRect = triggerEl.getBoundingClientRect(); - const triggerRect = triggerEl.getBoundingClientRect(); + const addedYForPortalPositioning = (withPortal ? triggerRect.top : 0); + const addedXForPortalPositioning = (withPortal ? triggerRect.left : 0); - const addedYForPortalPositioning = (withPortal ? triggerRect.top : 0); - const addedXForPortalPositioning = (withPortal ? triggerRect.left : 0); + const leftWithPossibleNegative = Math.min( + x - triggerRect.left, + rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX, + ); + let left = (positionX === 'left' + ? (withPortal || shouldAvoidNegativePosition + ? Math.max(MENU_POSITION_VISUAL_COMFORT_SPACE_PX, leftWithPossibleNegative) + : leftWithPossibleNegative) + : (x - triggerRect.left)) + addedXForPortalPositioning; + let top = y - triggerRect.top + addedYForPortalPositioning; - const leftWithPossibleNegative = Math.min( - x - triggerRect.left, - rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX, - ); - let left = (horizontalPosition === 'left' - ? (withPortal || shouldAvoidNegativePosition - ? Math.max(MENU_POSITION_VISUAL_COMFORT_SPACE_PX, leftWithPossibleNegative) - : leftWithPossibleNegative) - : (x - triggerRect.left)) + addedXForPortalPositioning; - let top = y - triggerRect.top + addedYForPortalPositioning; + if (isDense) { + left = Math.min(left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); + top = Math.min(top, rootRect.height - menuRect.height - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); + } - if (isDense) { - left = Math.min(left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); - top = Math.min(top, rootRect.height - menuRect.height - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); - } + // Avoid hiding external parts of menus on mobile devices behind the edges of the screen (ReactionSelector for example) + const addedXForMenuPositioning = menuElMinWidth ? Math.max(0, (menuElMinWidth - menuElWidth) / 2) : 0; + if (left - addedXForMenuPositioning < 0 && shouldAvoidNegativePosition) { + left = addedXForMenuPositioning + MENU_POSITION_VISUAL_COMFORT_SPACE_PX; + } - // Avoid hiding external parts of menus on mobile devices behind the edges of the screen (ReactionSelector for example) - const addedXForMenuPositioning = menuElMinWidth ? Math.max(0, (menuElMinWidth - menuElWidth) / 2) : 0; - if (left - addedXForMenuPositioning < 0 && shouldAvoidNegativePosition) { - left = addedXForMenuPositioning + MENU_POSITION_VISUAL_COMFORT_SPACE_PX; - } + const offsetX = (anchorX + addedXForPortalPositioning - triggerRect.left) - left; + const offsetY = (anchorY + addedYForPortalPositioning - triggerRect.top) - top - (marginTop || 0); + const transformOriginX = positionX === 'left' ? offsetX : menuRect.width + offsetX; + const transformOriginY = positionY === 'bottom' ? menuRect.height + offsetY : offsetY; - const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); - - setWithScroll(menuMaxHeight < menuRect.height); - setMenuStyle(`max-height: ${menuMaxHeight}px;`); - setStyle(`left: ${left}px; top: ${top}px`); - const offsetX = (anchorX + addedXForPortalPositioning - triggerRect.left) - left; - const offsetY = (anchorY + addedYForPortalPositioning - triggerRect.top) - top - (marginTop || 0); - setTransformOriginX(horizontalPosition === 'left' ? offsetX : menuRect.width + offsetX); - setTransformOriginY(verticalPosition === 'bottom' ? menuRect.height + offsetY : offsetY); - }, [ - anchor, getMenuElement, getRootElement, getTriggerElement, getLayout, - ]); + const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); + const bubbleStyle = `max-height: ${menuMaxHeight}px;`; + const style = `left: ${left}px; top: ${top}px`; return { positionX, @@ -146,7 +224,6 @@ export default function useMenuPosition( transformOriginX, transformOriginY, style, - menuStyle, - withScroll, + bubbleStyle, }; } diff --git a/src/hooks/useVirtualBackdrop.ts b/src/hooks/useVirtualBackdrop.ts index 0139e6f02..d5501f05a 100644 --- a/src/hooks/useVirtualBackdrop.ts +++ b/src/hooks/useVirtualBackdrop.ts @@ -7,7 +7,7 @@ const BACKDROP_CLASSNAME = 'backdrop'; // without adding extra elements to the DOM export default function useVirtualBackdrop( isOpen: boolean, - menuRef: RefObject, + containerRef: RefObject, onClose?: () => void | undefined, ignoreRightClick?: boolean, excludedClosestSelector?: string, @@ -18,14 +18,14 @@ export default function useVirtualBackdrop( } const handleEvent = (e: MouseEvent) => { - const menu = menuRef.current; + const container = containerRef.current; const target = e.target as HTMLElement | null; - if (!menu || !target || (ignoreRightClick && e.button === 2)) { + if (!container || !target || (ignoreRightClick && e.button === 2)) { return; } if (( - !menu.contains(e.target as Node | null) + !container.contains(e.target as Node | null) || target.classList.contains(BACKDROP_CLASSNAME) ) && !(excludedClosestSelector && ( target.matches(excludedClosestSelector) || target.closest(excludedClosestSelector) @@ -41,5 +41,5 @@ export default function useVirtualBackdrop( return () => { document.removeEventListener('mousedown', handleEvent); }; - }, [excludedClosestSelector, ignoreRightClick, isOpen, menuRef, onClose]); + }, [excludedClosestSelector, ignoreRightClick, isOpen, containerRef, onClose]); }