[Perf] [Refactoring] Move useMenuPosition into Menu component

This commit is contained in:
Alexander Zinchuk 2024-09-06 15:43:21 +02:00
parent 8aa7eb2fcb
commit 03de021b82
26 changed files with 404 additions and 503 deletions

View File

@ -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<OwnProps & StateProps> = ({
const {
isContextMenuOpen,
contextMenuPosition,
contextMenuAnchor,
handleContextMenu,
handleBeforeContextMenu,
handleContextMenuClose,
@ -76,16 +75,6 @@ const GroupCallParticipant: FC<OwnProps & StateProps> = ({
[],
);
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<OwnProps & StateProps> = ({
<GroupCallParticipantMenu
participant={participant}
isDropdownOpen={isContextMenuOpen}
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
menuRef={menuRef}

View File

@ -1,10 +1,9 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useState,
} from '../../../lib/teact/teact';
import React, { memo, useEffect, useState } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
import type { MenuPositionOptions } from '../../ui/Menu';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { selectIsAdminInActiveGroupCall } from '../../../global/selectors/calls';
@ -26,18 +25,15 @@ import './GroupCallParticipantMenu.scss';
const SPEAKER_ICON_DISABLED_SEGMENT: [number, number] = [0, 17];
const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34];
type OwnProps = {
participant?: GroupCallParticipant;
onCloseAnimationEnd: VoidFunction;
onClose: VoidFunction;
isDropdownOpen: boolean;
positionX?: 'left' | 'right';
positionY?: 'top' | 'bottom';
transformOriginX?: number;
transformOriginY?: number;
style?: string;
menuRef?: React.RefObject<HTMLDivElement>;
};
type OwnProps =
{
participant?: GroupCallParticipant;
onCloseAnimationEnd: VoidFunction;
onClose: VoidFunction;
isDropdownOpen: boolean;
menuRef?: React.RefObject<HTMLDivElement>;
}
& MenuPositionOptions;
type StateProps = {
isAdmin: boolean;
@ -58,12 +54,8 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
onClose,
isDropdownOpen,
isAdmin,
positionY,
menuRef,
positionX,
style,
transformOriginY,
transformOriginX,
...menuPositionOptions
}) => {
const {
toggleGroupCallMute,
@ -175,16 +167,13 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
<div>
<Menu
isOpen={isDropdownOpen}
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={style}
ref={menuRef}
withPortal
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
className="participant-menu with-menu-transitions"
// eslint-disable-next-line react/jsx-props-no-spreading
{...menuPositionOptions}
>
{!isSelf && !shouldRaiseHand && (
<div className="group">

View File

@ -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<OwnProps & StateProps> = ({
const {
isContextMenuOpen,
contextMenuPosition,
contextMenuAnchor,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
@ -232,16 +231,6 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
[],
);
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<OwnProps & StateProps> = ({
<GroupCallParticipantMenu
participant={participant}
isDropdownOpen={isContextMenuOpen}
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
menuRef={menuRef}

View File

@ -29,8 +29,7 @@ import type {
ApiWebPage,
} from '../../api/types';
import type {
ApiDraft, GlobalState, MessageList,
MessageListType, TabState,
ApiDraft, GlobalState, MessageList, MessageListType, TabState,
} from '../../global/types';
import type {
IAnchorPosition, InlineBotSettings, ISettings, ThreadId,
@ -830,7 +829,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu));
const {
contextMenuPosition: storyReactionPickerPosition,
contextMenuAnchor: storyReactionPickerAnchor,
handleContextMenu: handleStoryPickerContextMenu,
handleBeforeContextMenu: handleBeforeStoryPickerContextMenu,
handleContextMenuHide: handleStoryPickerContextMenuHide,
@ -839,15 +838,15 @@ const Composer: FC<OwnProps & StateProps> = ({
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,

View File

@ -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<OwnProps> = ({
const isVideoReady = loadAndPlay && isBuffered;
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
@ -78,15 +77,6 @@ const GifButton: FC<OwnProps> = ({
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<OwnProps> = ({
{shouldRenderSpinner && (
<Spinner color={previewBlobUrl || withThumb ? 'white' : 'black'} />
)}
{onClick && contextMenuPosition !== undefined && (
{onClick && contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
className="gif-context-menu"
autoClose
onClose={handleContextMenuClose}

View File

@ -16,7 +16,6 @@ import useDynamicColorListener from '../../hooks/stickers/useDynamicColorListene
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLastCallback from '../../hooks/useLastCallback';
import useMenuPosition from '../../hooks/useMenuPosition';
import useOldLang from '../../hooks/useOldLang';
import Button from '../ui/Button';
@ -118,29 +117,18 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const isIntesectingForShowing = useIsIntersecting(ref, observeIntersectionForShowing);
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
const shouldRenderContextMenu = Boolean(!noContextMenu && contextMenuPosition);
const shouldRenderContextMenu = Boolean(!noContextMenu && contextMenuAnchor);
const getTriggerElement = useLastCallback(() => 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 = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="sticker-context-menu"
autoClose
withPortal={isStatusPicker}

View File

@ -12,9 +12,7 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { FocusDirection, ThreadId } from '../../types';
import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage';
import {
getChatTitle, getMessageHtmlId, isJoinedChannelMessage,
} from '../../global/helpers';
import { getChatTitle, getMessageHtmlId, isJoinedChannelMessage } from '../../global/helpers';
import { getMessageReplyInfo } from '../../global/helpers/replies';
import {
selectCanPlayAnimatedEmojis,
@ -191,11 +189,11 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
]);
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
const isContextMenuShown = contextMenuPosition !== undefined;
const isContextMenuShown = contextMenuAnchor !== undefined;
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
preventMessageInputBlur(e);
@ -372,10 +370,10 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
<ActionMessageSuggestedAvatar message={message} renderContent={renderContent} />
)}
{isJoinedMessage && <SimilarChannels chatId={targetChatId!} />}
{contextMenuPosition && (
{contextMenuAnchor && (
<ContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuPosition}
anchor={contextMenuAnchor}
message={message}
messageListType="thread"
onClose={handleContextMenuClose}

View File

@ -142,12 +142,12 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
const menuButtonRef = useRef<HTMLButtonElement>(null);
const lang = useOldLang();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<IAnchorPosition | undefined>(undefined);
const [menuAnchor, setMenuAnchor] = useState<IAnchorPosition | undefined>(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<OwnProps & StateProps> = ({
});
const handleHeaderMenuHide = useLastCallback(() => {
setMenuPosition(undefined);
setMenuAnchor(undefined);
});
const handleSubscribeClick = useLastCallback(() => {
@ -420,12 +420,12 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
>
<i className="icon icon-more" aria-hidden />
</Button>
{menuPosition && (
{menuAnchor && (
<HeaderMenuContainer
chatId={chatId}
threadId={threadId}
isOpen={isMenuOpen}
anchor={menuPosition}
anchor={menuAnchor}
withExtraActions={isMobile || !canExpandActions}
isChannel={isChannel}
canStartBot={canStartBot}

View File

@ -34,7 +34,6 @@ import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMenuPosition from '../../../hooks/useMenuPosition';
import useOldLang from '../../../hooks/useOldLang';
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
@ -160,7 +159,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
{(isShowingReply || isForwarding) && !isContextMenuDisabled && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
className="forward-context-menu"
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}

View File

@ -7,6 +7,7 @@ import { withGlobal } from '../../../global';
import type { ApiSticker, ApiVideo } from '../../../api/types';
import type { GlobalActions } from '../../../global';
import type { ThreadId } from '../../../types';
import type { MenuPositionOptions } from '../../ui/Menu';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { selectIsContextMenuTranslucent, selectTabState } from '../../../global/selectors';
@ -61,12 +62,8 @@ export type OwnProps = {
className?: string;
isAttachmentModal?: boolean;
canSendPlainText?: boolean;
positionX?: 'left' | 'right';
positionY?: 'top' | 'bottom';
transformOriginX?: number;
transformOriginY?: number;
style?: string;
};
}
& MenuPositionOptions;
type StateProps = {
isLeftColumnShown: boolean;
@ -87,11 +84,6 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
isAttachmentModal,
canSendPlainText,
className,
positionX,
positionY,
transformOriginX,
transformOriginY,
style,
isBackgroundTranslucent,
onLoad,
onClose,
@ -103,6 +95,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onSearchOpen,
addRecentEmoji,
addRecentCustomEmoji,
...menuPositionOptions
}) => {
const [activeTab, setActiveTab] = useState<number>(0);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
@ -325,8 +318,6 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
return (
<UnfreezableMenu
isOpen={isOpen}
positionX={isAttachmentModal ? positionX : 'left'}
positionY={isAttachmentModal ? positionY : 'bottom'}
onClose={onClose}
withPortal={isAttachmentModal}
className={buildClassName('SymbolMenu', className)}
@ -335,9 +326,11 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
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}
</UnfreezableMenu>

View File

@ -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<OwnProps> = ({
const triggerRef = useRef<HTMLDivElement>(null);
const [isSymbolMenuLoaded, onSymbolMenuLoadingComplete] = useFlag();
const [contextMenuPosition, setContextMenuPosition] = useState<IAnchorPosition | undefined>(undefined);
const [contextMenuAnchor, setContextMenuAnchor] = useState<IAnchorPosition | undefined>(undefined);
const symbolMenuButtonClassName = buildClassName(
'mobile-symbol-menu-button',
@ -106,7 +105,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
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}
/>
</>
);

View File

@ -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<OwnProps & StateProps> = ({
});
const {
isContextMenuOpen, contextMenuPosition, handleContextMenu,
isContextMenuOpen, contextMenuAnchor, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref, isEditing, true);
@ -132,15 +129,6 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
() => 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<OwnProps & StateProps> = ({
return (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
className="web-page-preview-context-menu"
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
@ -185,7 +172,7 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
<>
{
isInvertedMedia ? (
// eslint-disable-next-line react/jsx-no-bind
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="move-caption-up" onClick={() => updateIsInvertedMedia(undefined)}>
{lang('PreviewSender.MoveTextUp')}
</MenuItem>

View File

@ -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<OwnProps & StateProps> = ({
const {
isContextMenuOpen,
contextMenuPosition,
contextMenuAnchor,
contextMenuTarget,
handleBeforeContextMenu,
handleContextMenu: onContextMenu,
@ -535,7 +536,7 @@ const Message: FC<OwnProps & StateProps> = ({
) && !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<OwnProps & StateProps> = ({
/>
)}
</div>
{contextMenuPosition && (
{contextMenuAnchor && (
<ContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuPosition}
anchor={contextMenuAnchor}
targetHref={contextMenuTarget?.matches('a[href]') ? (contextMenuTarget as HTMLAnchorElement).href : undefined}
message={message}
album={album}

View File

@ -28,7 +28,6 @@ import { getMessageCopyOptions } from './helpers/copyOptions';
import useAppLayout from '../../../hooks/useAppLayout';
import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback';
import useMenuPosition from '../../../hooks/useMenuPosition';
import useOldLang from '../../../hooks/useOldLang';
import AvatarList from '../../common/AvatarList';
@ -323,15 +322,11 @@ const MessageContextMenu: FC<OwnProps> = ({
}, 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<OwnProps> = ({
<Menu
ref={menuRef}
isOpen={isOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={style}
bubbleStyle={menuStyle}
anchor={anchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className={buildClassName(
'MessageContextMenu', 'fluid', withReactions && 'with-reactions',
)}
@ -376,13 +370,12 @@ const MessageContextMenu: FC<OwnProps> = ({
)}
<div
ref={scrollableRef}
className={buildClassName(
'MessageContextMenu_items scrollable-content custom-scroll',
areItemsHidden && 'MessageContextMenu_items-hidden',
)}
style={menuStyle}
dir={lang.isRtl ? 'rtl' : undefined}
ref={scrollableRef}
>
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
{canReschedule && (

View File

@ -65,7 +65,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
threshold: 1,
});
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref, undefined, true, IS_ANDROID);
@ -183,10 +183,10 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
)}
</div>
</div>
{contextMenuPosition && (
{contextMenuAnchor && (
<SponsoredMessageContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuPosition}
anchor={contextMenuAnchor}
message={message!}
onAboutAdsClick={openAboutAdsModal}
onReportAd={handleReportSponsoredMessage}

View File

@ -5,15 +5,25 @@ import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { IAnchorPosition } from '../../../../types';
import {
type ApiAvailableEffect,
type ApiMessage, type ApiMessageEntity,
type ApiReaction, type ApiReactionCustomEmoji, type ApiSticker, type ApiStory, type ApiStorySkipped,
type ApiMessage,
type ApiMessageEntity,
type ApiReaction,
type ApiReactionCustomEmoji,
type ApiSticker,
type ApiStory,
type ApiStorySkipped,
MAIN_THREAD_ID,
} from '../../../../api/types';
import { getReactionKey, getStoryKey, isUserId } from '../../../../global/helpers';
import {
selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectIsCurrentUserPremium,
selectPeerStory, selectTabState,
selectChat,
selectChatFullInfo,
selectChatMessage,
selectIsContextMenuTranslucent,
selectIsCurrentUserPremium,
selectPeerStory,
selectTabState,
} from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
@ -23,7 +33,6 @@ import { buildCustomEmojiHtml } from '../../composer/helpers/customEmoji';
import { getIsMobile } from '../../../../hooks/useAppLayout';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLastCallback from '../../../../hooks/useLastCallback';
import useMenuPosition from '../../../../hooks/useMenuPosition';
import useOldLang from '../../../../hooks/useOldLang';
import CustomEmojiPicker from '../../../common/CustomEmojiPicker';
@ -104,9 +113,6 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
? -(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<OwnProps & StateProps> = ({
)}
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}
>

View File

@ -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 && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
autoClose
withPortal
onClose={handleContextMenuClose}

View File

@ -15,7 +15,6 @@ import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMe
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useMenuPosition from '../../hooks/useMenuPosition';
import useOldLang from '../../hooks/useOldLang';
import Icon from '../common/icons/Icon';
@ -73,19 +72,10 @@ function MediaStory({
}, [isDeleted, isFullyLoaded, story]);
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(containerRef, !isOwn);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
const handleClick = useCallback(() => {
openStoryViewer({
@ -156,14 +146,14 @@ function MediaStory({
{isFullyLoaded && <MediaAreaOverlay story={story} />}
{isProtected && <span className="protector" />}
</div>
{contextMenuPosition !== undefined && (
{contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className={buildClassName(styles.contextMenu, 'story-context-menu')}
autoClose
onClose={handleContextMenuClose}

View File

@ -10,7 +10,6 @@ import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMe
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLastCallback from '../../hooks/useLastCallback';
import useMenuPosition from '../../hooks/useMenuPosition';
import useOldLang from '../../hooks/useOldLang';
import useStoryPreloader from './hooks/useStoryPreloader';
@ -43,7 +42,7 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
useStoryPreloader(peer.id);
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
@ -53,16 +52,6 @@ function StoryRibbonButton({ peer, isArchived }: OwnProps) {
const getMenuElement = useLastCallback(() => 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) {
<div className={buildClassName(styles.name, peer.hasUnreadStories && styles.name_hasUnreadStory)}>
{isSelf ? lang('MyStory') : getSenderTitle(lang, peer)}
</div>
{contextMenuPosition !== undefined && (
{contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className={buildClassName('story-peer-context-menu', styles.contextMenu)}
autoClose
withPortal

View File

@ -1,7 +1,6 @@
import type { FC } from '../../lib/teact/teact';
import React, {
useCallback, useMemo,
useRef, useState,
useCallback, useMemo, useRef, useState,
} from '../../lib/teact/teact';
import Button from './Button';
@ -44,12 +43,11 @@ const DropdownMenu: FC<OwnProps> = ({
}) => {
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const toggleIsOpen = () => {
setIsOpen(!isOpen);
if (isOpen) {
onClose?.();
} else {
@ -96,7 +94,6 @@ const DropdownMenu: FC<OwnProps> = ({
return (
<div
ref={dropdownRef}
className={`DropdownMenu ${className || ''}`}
onKeyDown={handleKeyDown}
onTransitionEnd={onTransitionEnd}
@ -105,7 +102,6 @@ const DropdownMenu: FC<OwnProps> = ({
<Menu
ref={menuRef}
containerRef={dropdownRef}
isOpen={isOpen || Boolean(forceOpen)}
className={className || ''}
transformOriginX={transformOriginX}

View File

@ -13,7 +13,6 @@ import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import { useFastClick } from '../../hooks/useFastClick';
import useFlag from '../../hooks/useFlag';
import useLastCallback from '../../hooks/useLastCallback';
import useMenuPosition from '../../hooks/useMenuPosition';
import useOldLang from '../../hooks/useOldLang';
import Icon from '../common/icons/Icon';
@ -120,7 +119,7 @@ const ListItem: FC<OwnProps> = ({
const [isTouched, markIsTouched, unmarkIsTouched] = useFlag();
const {
isContextMenuOpen, contextMenuPosition,
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(containerRef, !contextActions);
@ -133,16 +132,6 @@ const ListItem: FC<OwnProps> = ({
});
const getLayout = useLastCallback(() => ({ withPortal: withPortalForMenu }));
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
const handleClickEvent = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
const hasModifierKey = e.ctrlKey || e.metaKey || e.shiftKey;
if (!hasModifierKey && e.button === MouseButton.Main) {
@ -215,7 +204,7 @@ const ListItem: FC<OwnProps> = ({
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<OwnProps> = ({
)}
{rightElement}
</ButtonElementTag>
{contextActions && contextMenuPosition !== undefined && (
{contextActions && contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="ListItem-context-menu with-menu-transitions"
autoClose
onClose={handleContextMenuClose}

View File

@ -1,7 +1,10 @@
import React, { type FC, memo, useEffect } from '../../lib/teact/teact';
import React, {
type FC, memo, useEffect, useRef,
} from '../../lib/teact/teact';
import type { MenuPositionOptions } from '../../hooks/useMenuPosition';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import freezeWhenClosed from '../../util/hoc/freezeWhenClosed';
import { IS_BACKDROP_BLUR_SUPPORTED } from '../../util/windowEnvironment';
@ -13,6 +16,7 @@ import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'
import useHistoryBack from '../../hooks/useHistoryBack';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import useLastCallback from '../../hooks/useLastCallback';
import useMenuPosition from '../../hooks/useMenuPosition';
import useShowTransition from '../../hooks/useShowTransition';
import useVirtualBackdrop from '../../hooks/useVirtualBackdrop';
@ -20,54 +24,44 @@ import Portal from './Portal';
import './Menu.scss';
type OwnProps = {
ref?: React.RefObject<HTMLDivElement>;
containerRef?: React.RefObject<HTMLElement>;
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<any>) => void;
onCloseAnimationEnd?: () => void;
onClose: () => void;
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
withPortal?: boolean;
children?: React.ReactNode;
};
export type { MenuPositionOptions } from '../../hooks/useMenuPosition';
type OwnProps =
{
ref?: React.RefObject<HTMLDivElement>;
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<any>) => void;
onCloseAnimationEnd?: () => void;
onClose: () => void;
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
withPortal?: boolean;
children?: React.ReactNode;
}
& MenuPositionOptions;
const ANIMATION_DURATION = 200;
const Menu: FC<OwnProps> = ({
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<OwnProps> = ({
onMouseLeave,
withPortal,
onMouseEnterBackdrop,
...positionOptions
}) => {
const { isTouchScreen } = useAppLayout();
const { ref: menuRef } = useShowTransition({
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(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<OwnProps> = ({
}
}, [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<OwnProps> = ({
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<HTMLDivElement>) => {
e.stopPropagation();
if (autoClose) {
@ -138,6 +131,7 @@ const Menu: FC<OwnProps> = ({
const menu = (
<div
ref={containerRef}
id={id}
className={buildClassName(
'Menu',
@ -146,7 +140,6 @@ const Menu: FC<OwnProps> = ({
withPortal && 'in-portal',
className,
)}
style={style}
aria-labelledby={ariaLabelledBy}
role={ariaLabelledBy ? 'menu' : undefined}
onKeyDown={isOpen ? handleKeyDown : undefined}
@ -163,12 +156,8 @@ const Menu: FC<OwnProps> = ({
)}
<div
role="presentation"
ref={menuRef}
ref={bubbleRef}
className={bubbleFullClassName}
style={buildStyle(
`transform-origin: ${transformOriginXStyle || positionX} ${transformOriginYStyle || positionY}`,
bubbleStyle,
)}
onClick={handleClick}
>
{children}

View File

@ -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<OwnProps> = ({
}, [isActive, previousActiveTab]);
const {
contextMenuPosition, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose,
contextMenuAnchor, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose,
handleContextMenuHide, isContextMenuOpen,
} = useContextMenuHandlers(tabRef, !contextActions);
@ -131,16 +130,6 @@ const Tab: FC<OwnProps> = ({
);
const getLayout = useLastCallback(() => ({ withPortal: true }));
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
return (
<div
className={buildClassName('Tab', onClick && 'Tab--interactive', className)}
@ -158,14 +147,14 @@ const Tab: FC<OwnProps> = ({
<i className="platform" />
</span>
{contextActions && contextMenuPosition !== undefined && (
{contextActions && contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="Tab-context-menu"
autoClose
onClose={handleContextMenuClose}

View File

@ -6,10 +6,7 @@ import type { IAnchorPosition } from '../types';
import type { Signal } from '../util/signals';
import { requestMutation } from '../lib/fasterdom/fasterdom';
import {
IS_IOS,
IS_PWA, IS_TOUCH_ENV,
} from '../util/windowEnvironment';
import { IS_IOS, IS_PWA, IS_TOUCH_ENV } from '../util/windowEnvironment';
import useLastCallback from './useLastCallback';
const LONG_TAP_DURATION_MS = 200;
@ -29,7 +26,7 @@ const useContextMenuHandlers = (
getIsReady?: Signal<boolean>,
) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<IAnchorPosition | undefined>(undefined);
const [contextMenuAnchor, setContextMenuAnchor] = useState<IAnchorPosition | undefined>(undefined);
const [contextMenuTarget, setContextMenuTarget] = useState<HTMLElement | undefined>(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,

View File

@ -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<HTMLDivElement>,
bubbleRef: React.RefObject<HTMLDivElement>,
options: MenuPositionOptions,
) {
const [positionX, setPositionX] = useState<'right' | 'left'>('right');
const [positionY, setPositionY] = useState<'top' | 'bottom'>('bottom');
const [transformOriginX, setTransformOriginX] = useState<number>();
const [transformOriginY, setTransformOriginY] = useState<number>();
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<HTMLDivElement>,
bubbleRef: React.RefObject<HTMLDivElement>,
{
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<HTMLDivElement>,
bubbleRef: React.RefObject<HTMLDivElement>,
{
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,
};
}

View File

@ -7,7 +7,7 @@ const BACKDROP_CLASSNAME = 'backdrop';
// without adding extra elements to the DOM
export default function useVirtualBackdrop(
isOpen: boolean,
menuRef: RefObject<HTMLElement>,
containerRef: RefObject<HTMLElement>,
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]);
}