DocSpace-client/packages/shared/components/context-menu/ContextMenu.tsx

569 lines
16 KiB
TypeScript

// (c) Copyright Ascensio System SIA 2009-2024
//
// This program is a free software product.
// You can redistribute it and/or modify it under the terms
// of the GNU Affero General Public License (AGPL) version 3 as published by the Free Software
// Foundation. In accordance with Section 7(a) of the GNU AGPL its Section 15 shall be amended
// to the effect that Ascensio System SIA expressly excludes the warranty of non-infringement of
// any third-party rights.
//
// This program is distributed WITHOUT ANY WARRANTY, without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For details, see
// the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
//
// You can contact Ascensio System SIA at Lubanas st. 125a-25, Riga, Latvia, EU, LV-1021.
//
// The interactive user interfaces in modified source and object code versions of the Program must
// display Appropriate Legal Notices, as required under Section 5 of the GNU AGPL version 3.
//
// Pursuant to Section 7(b) of the License you must retain the original Product logo when
// distributing the program. Pursuant to Section 7(e) we decline to grant you any rights under
// trademark law for use of our trademarks.
//
// All the Product's GUI elements, including illustrations and icon sets, as well as technical writing
// content are licensed under the terms of the Creative Commons Attribution-ShareAlike 4.0
// International. See the License terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
/* eslint-disable @typescript-eslint/no-unused-vars */
import React from "react";
import { CSSTransition } from "react-transition-group";
import { useTheme } from "styled-components";
import { isMobileOnly } from "react-device-detect";
import ArrowLeftReactUrl from "PUBLIC_DIR/images/arrow-left.react.svg?url";
import {
classNames,
DomHelpers,
trimSeparator,
isMobile as isMobileUtils,
isTablet as isTabletUtils,
} from "../../utils";
import { Portal } from "../portal";
import { Backdrop } from "../backdrop";
import { Text } from "../text";
import { Avatar, AvatarRole, AvatarSize } from "../avatar";
import { IconButton } from "../icon-button";
import { RoomIcon } from "../room-icon";
import { StyledContextMenu } from "./ContextMenu.styled";
import { SubMenu } from "./sub-components/SubMenu";
import { MobileSubMenu } from "./sub-components/MobileSubMenu";
import { ContextMenuModel, ContextMenuProps } from "./ContextMenu.types";
const ContextMenu = React.forwardRef((props: ContextMenuProps, ref) => {
const [visible, setVisible] = React.useState(false);
const [reshow, setReshow] = React.useState(false);
const [resetMenu, setResetMenu] = React.useState(false);
const [model, setModel] = React.useState<ContextMenuModel[] | null>(null);
const [changeView, setChangeView] = React.useState(false);
const [showMobileMenu, setShowMobileMenu] = React.useState(false);
const [onLoad, setOnLoad] = React.useState<
undefined | (() => Promise<ContextMenuModel[]>)
>(undefined);
const [articleWidth, setArticleWidth] = React.useState(0);
const prevReshow = React.useRef(false);
const menuRef = React.useRef<null | HTMLDivElement>(null);
const currentEvent = React.useRef<null | React.MouseEvent | MouseEvent>(null);
const currentChangeEvent = React.useRef<
null | Event | React.ChangeEvent<HTMLInputElement>
>(null);
const theme = useTheme();
const {
getContextModel,
onShow,
onHide,
autoZIndex = true,
baseZIndex,
leftOffset,
rightOffset,
containerRef,
scaled,
global,
className,
header,
fillIcon = true,
isRoom,
id,
style,
isArchive,
ignoreChangeView,
appendTo,
withBackdrop,
model: propsModel,
badgeUrl,
} = props;
const onMenuClick = () => {
setResetMenu(false);
};
const onMenuMouseEnter = () => {
setResetMenu(false);
};
const show = React.useCallback(
(e: React.MouseEvent | MouseEvent) => {
if (getContextModel) {
const m = trimSeparator(getContextModel());
setModel(m);
}
e.stopPropagation();
e.preventDefault();
currentEvent.current = e;
if (visible) {
if (!isMobileUtils()) {
setReshow(true);
prevReshow.current = true;
}
} else {
setVisible(true);
if (currentEvent.current) onShow?.(currentEvent.current);
}
},
[visible, onShow, getContextModel],
);
const hide = React.useCallback(
(
e:
| React.MouseEvent
| MouseEvent
| Event
| React.ChangeEvent<HTMLInputElement>,
) => {
if (e instanceof Event) {
currentChangeEvent.current = e;
} else {
// @ts-expect-error need fix
currentEvent.current = e;
}
onHide?.(e);
setVisible(false);
setReshow(false);
prevReshow.current = false;
setChangeView(false);
setShowMobileMenu(false);
setArticleWidth(0);
},
[onHide],
);
const toggle = React.useCallback(
(
e:
| React.MouseEvent
| MouseEvent
| Event
| React.ChangeEvent<HTMLInputElement>,
) => {
if (currentChangeEvent.current === e || currentEvent.current === e)
return;
if (visible) {
hide(e);
return false;
}
// @ts-expect-error fix types
show(e);
return true;
},
[visible, hide, show],
);
React.useEffect(() => {
if (visible && prevReshow.current !== reshow) {
setVisible(false);
setReshow(false);
prevReshow.current = false;
setResetMenu(true);
setChangeView(false);
setArticleWidth(0);
if (currentEvent.current) show(currentEvent.current);
}
}, [visible, reshow, show]);
const position = (event: React.MouseEvent | MouseEvent) => {
if (event) {
const rects = containerRef?.current?.getBoundingClientRect();
const currentLeftOffset = leftOffset ?? 0;
const currentRightOffset = rightOffset ?? 0;
let left = rects
? rects.left - currentLeftOffset - currentRightOffset
: event.pageX + 1;
let top = rects ? rects.top : event.pageY + 1;
const width =
menuRef.current && menuRef.current.offsetParent
? menuRef.current.offsetWidth
: DomHelpers.getHiddenElementOuterWidth(menuRef.current);
const height =
menuRef.current && menuRef.current.offsetParent
? menuRef.current.offsetHeight
: DomHelpers.getHiddenElementOuterHeight(menuRef.current);
const viewport = DomHelpers.getViewport();
if (theme.interfaceDirection === "rtl" && !rects && left > width) {
left = event.pageX - width + 1;
}
if (
isTabletUtils() &&
(height > 483 ||
(isMobileOnly && window.innerHeight < window.innerWidth))
) {
const article = document.getElementById("article-container");
let currentArticleWidth = 0;
if (article) {
currentArticleWidth = article.offsetWidth;
}
setChangeView(true);
setArticleWidth(currentArticleWidth);
return;
}
if (isMobileUtils() && (height > 210 || ignoreChangeView)) {
setChangeView(true);
setArticleWidth(0);
return;
}
// flip
if (left + width - document.body.scrollLeft > viewport.width) {
left -= width;
}
// flip
if (top + height - document.body.scrollTop > viewport.height) {
top -= height;
}
// fit
if (left < document.body.scrollLeft) {
left = document.body.scrollLeft;
}
// fit
if (top < document.body.scrollTop) {
top = document.body.scrollTop;
}
if (containerRef) {
if (rects) top += rects.height + 4;
if (menuRef.current) {
if (scaled && rects) {
menuRef.current.style.width = `${rects.width}px`;
}
menuRef.current.style.minWidth = "210px";
}
}
if (menuRef.current) {
menuRef.current.style.left = `${left}px`;
menuRef.current.style.top = `${top}px`;
}
}
};
const onEnter = () => {
if (autoZIndex && menuRef.current) {
const zIndex = baseZIndex || 0;
menuRef.current.style.zIndex = String(
zIndex + DomHelpers.generateZIndex(),
);
}
if (currentChangeEvent.current) {
currentChangeEvent.current = null;
}
if (currentEvent.current) position(currentEvent.current);
};
const onExited = () => {
DomHelpers.revertZIndex();
};
const onLeafClick = (
e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>,
) => {
setResetMenu(true);
hide(e);
e.stopPropagation();
};
const isOutsideClicked = React.useCallback(
(e: React.MouseEvent | MouseEvent) => {
const target = e.target as HTMLElement;
return (
menuRef.current &&
!(
menuRef.current.isSameNode(target) || menuRef.current.contains(target)
)
);
},
[],
);
const documentClickListener = React.useCallback(
(e: MouseEvent) => {
if (isOutsideClicked(e)) {
// TODO: (&& e.button !== 2) restore after global usage
hide(e);
setResetMenu(true);
}
},
[setResetMenu, isOutsideClicked, hide],
);
const documentContextMenuListener = React.useCallback(
(e: MouseEvent) => {
show(e);
},
[show],
);
const documentResizeListener = React.useCallback(
(e: Event) => {
if (visible) {
hide(e);
}
},
[visible, hide],
);
const bindDocumentListeners = () => {
window.addEventListener("resize", documentResizeListener);
document.addEventListener("click", documentClickListener);
document.addEventListener("mousedown", documentClickListener);
};
const unbindDocumentListeners = () => {
window.removeEventListener("resize", documentResizeListener);
document.removeEventListener("click", documentClickListener);
document.removeEventListener("mousedown", documentClickListener);
};
const onEntered = () => {
bindDocumentListeners();
};
const onExit = () => {
currentEvent.current = null;
unbindDocumentListeners();
};
React.useEffect(() => {
if (global)
document.addEventListener("contextmenu", documentContextMenuListener);
return () => {
document.removeEventListener("contextmenu", documentContextMenuListener);
document.removeEventListener("click", documentClickListener);
document.removeEventListener("mousedown", documentClickListener);
DomHelpers.revertZIndex();
};
}, [documentClickListener, documentContextMenuListener, global]);
React.useEffect(() => {
return () => {
if (visible && onHide) {
onHide();
setVisible(false);
setReshow(false);
prevReshow.current = false;
setChangeView(false);
setShowMobileMenu(false);
setArticleWidth(0);
}
window.removeEventListener("resize", documentResizeListener);
};
}, [documentResizeListener, onHide, visible]);
const onMobileItemClick = (
e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>,
loadFunc?: () => Promise<ContextMenuModel[]>,
) => {
e.stopPropagation();
setShowMobileMenu(true);
if (loadFunc) setOnLoad(loadFunc);
};
const onBackClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
setShowMobileMenu(false);
};
React.useImperativeHandle(
ref,
() => {
return { show, hide, toggle, menuRef };
},
[hide, show, toggle],
);
const renderContextMenu = () => {
const currentClassName = className
? classNames("p-contextmenu p-component", className) ||
"p-contextmenu p-component"
: "p-contextmenu p-component";
const isIconExist = !!header?.icon;
const isAvatarExist = header?.avatar;
const withHeader = !!header?.title;
const defaultIcon = !!header?.color;
return (
<StyledContextMenu
changeView={changeView}
articleWidth={articleWidth}
isRoom={isRoom}
fillIcon={fillIcon}
isIconExist={isIconExist}
data-testid="context-menu"
>
<CSSTransition
nodeRef={menuRef}
classNames="p-contextmenu"
in={visible}
timeout={{ enter: 250, exit: 0 }}
unmountOnExit
onEnter={onEnter}
onEntered={onEntered}
onExit={onExit}
onExited={onExited}
>
<div
ref={menuRef}
id={id}
className={currentClassName}
style={style}
onClick={onMenuClick}
onMouseEnter={onMenuMouseEnter}
>
{changeView && withHeader && (
<div className="contextmenu-header">
{isIconExist &&
(showMobileMenu ? (
<IconButton
className="edit_icon"
iconName={ArrowLeftReactUrl}
onClick={onBackClick}
size={16}
/>
) : (
<div className="icon-wrapper">
{header.icon ? (
<RoomIcon
title={header.title}
isArchive={isArchive}
showDefault={defaultIcon}
imgClassName="drop-down-item_icon"
imgSrc={header.icon}
badgeUrl={badgeUrl}
color={header.color || ""}
/>
) : (
<RoomIcon
color={header.color || ""}
title={header.title}
isArchive={isArchive}
showDefault={defaultIcon}
badgeUrl={badgeUrl}
/>
)}
</div>
))}
{isAvatarExist && (
<div className="avatar-wrapper">
<Avatar
role={AvatarRole.none}
source={header.avatar || ""}
size={AvatarSize.min}
className="drop-down-item_avatar"
/>
</div>
)}
<Text className="text" truncate dir="auto">
{header.title}
</Text>
</div>
)}
{showMobileMenu ? (
<MobileSubMenu
root
resetMenu={resetMenu}
onLeafClick={onLeafClick}
onLoad={onLoad}
/>
) : (
<SubMenu
model={getContextModel ? model || [] : propsModel}
root
resetMenu={resetMenu}
onLeafClick={onLeafClick}
changeView={changeView}
onMobileItemClick={onMobileItemClick}
/>
)}
</div>
</CSSTransition>
</StyledContextMenu>
);
};
const element = renderContextMenu();
const isMobileUtil = isMobileUtils();
const contextMenu = (
<>
{withBackdrop && (
<Backdrop
visible={(visible && (changeView || ignoreChangeView)) || false}
withBackground
withoutBlur={false}
zIndex={baseZIndex}
/>
)}
<Portal element={element} appendTo={appendTo} />
</>
);
const root = document.getElementById("root");
if (root && isMobileUtil) {
const portal = <Portal element={contextMenu} appendTo={root} />;
return portal;
}
return contextMenu;
});
ContextMenu.displayName = "ContextMenu";
export { ContextMenu };