feat(context-menu) Show participants context menu overlaid in a portal

This commit is contained in:
hmuresan 2021-10-06 12:53:27 +03:00 committed by Horatiu Muresan
parent 9ef71e3b15
commit 9f7a4f86d8
9 changed files with 374 additions and 121 deletions

View File

@ -3,48 +3,45 @@
* to allow mouse movement from the popover trigger to the popover itself * to allow mouse movement from the popover trigger to the popover itself
* without triggering a mouseleave event. * without triggering a mouseleave event.
*/ */
.popover-mousemove-padding-bottom {
bottom: -15px;
height: 20px;
position: absolute;
right: 0;
width: 100%;
}
%vertical-popover-padding { %vertical-popover-padding {
height: 100%; height: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
width: 40px; width: 20px;
padding: 20px 0;
top: -20px;
}
%horizontal-popover-padding {
height: 25px;
position: absolute;
right: 0;
width: 100%;
padding: 0 35px;
left: -35px;
} }
.popover-mousemove-padding-left { .popover-mousemove-padding-left {
@extend %vertical-popover-padding; @extend %vertical-popover-padding;
left: -20px; left: -35px;
} }
.popover-mousemove-padding-right { .popover-mousemove-padding-right {
@extend %vertical-popover-padding; @extend %vertical-popover-padding;
right: -20px; right: -35px;
} }
/** .popover-mousemove-padding-bottom {
* An invisible element is added to the top of the popover to ensure the mouse @extend %horizontal-popover-padding;
* stays over the popover when the popover's height is shrunk, which would then bottom: -40px;
* normally leave the mouse outside of the popover itself and cause a mouseleave }
* event.
*/ .popover-mousemove-padding-top {
.popover-mouse-padding-top { @extend %horizontal-popover-padding;
height: 30px; top: -40px;
position: absolute;
right: 0;
top: -25px;
width: 100%;
} }
.popover { .popover {
background-color: $popoverBg;
border-radius: 3px;
margin: -16px -24px; margin: -16px -24px;
padding: 16px 24px; padding: 16px 24px;
z-index: $popoverZ; z-index: $popoverZ;

View File

@ -1,35 +1,10 @@
/* @flow */ /* @flow */
import InlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web'; import { Drawer, DrawerPortal, DialogPortal } from '../../../toolbox/components/web';
import { isMobileBrowser } from '../../environment/utils';
/** import { getContextMenuStyle } from '../functions.web';
* A map of dialog positions, relative to trigger, to css classes used to
* manipulate elements for handling mouse events.
*
* @private
* @type {object}
*/
const DIALOG_TO_PADDING_POSITION = {
'left': 'popover-mousemove-padding-right',
'right': 'popover-mousemove-padding-left',
'top': 'popover-mousemove-padding-bottom'
};
/**
* Takes the position expected by {@code InlineDialog} and maps it to a CSS
* class that can be used styling the elements used for preventing mouseleave
* events when moving from the trigger to the dialog.
*
* @param {string} position - From which position the dialog will display.
* @private
* @returns {string}
*/
function _mapPositionToPaddingClass(position = 'left') {
return DIALOG_TO_PADDING_POSITION[position.split('-')[0]];
}
/** /**
* The type of the React {@code Component} props of {@link Popover}. * The type of the React {@code Component} props of {@link Popover}.
@ -90,6 +65,11 @@ type Props = {
*/ */
type State = { type State = {
/**
* The style to apply to the context menu in order to position it correctly.
*/
contextMenuStyle: Object,
/** /**
* Whether or not the {@code InlineDialog} should be displayed. * Whether or not the {@code InlineDialog} should be displayed.
*/ */
@ -118,6 +98,7 @@ class Popover extends Component<Props, State> {
*/ */
_containerRef: Object; _containerRef: Object;
_contextMenuRef: HTMLElement;
/** /**
* Initializes a new {@code Popover} instance. * Initializes a new {@code Popover} instance.
@ -129,7 +110,8 @@ class Popover extends Component<Props, State> {
super(props); super(props);
this.state = { this.state = {
showDialog: false showDialog: false,
contextMenuStyle: null
}; };
// Bind event handlers so they are only bound once for every instance. // Bind event handlers so they are only bound once for every instance.
@ -140,6 +122,9 @@ class Popover extends Component<Props, State> {
this._onEscKey = this._onEscKey.bind(this); this._onEscKey = this._onEscKey.bind(this);
this._onThumbClick = this._onThumbClick.bind(this); this._onThumbClick = this._onThumbClick.bind(this);
this._onTouchStart = this._onTouchStart.bind(this); this._onTouchStart = this._onTouchStart.bind(this);
this._setContextMenuRef = this._setContextMenuRef.bind(this);
this._setContextMenuStyle = this._setContextMenuStyle.bind(this);
this._getCustomDialogStyle = this._getCustomDialogStyle.bind(this);
} }
/** /**
@ -179,7 +164,7 @@ class Popover extends Component<Props, State> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { children, className, content, id, overflowDrawer, position } = this.props; const { children, className, content, id, overflowDrawer } = this.props;
if (overflowDrawer) { if (overflowDrawer) {
return ( return (
@ -208,16 +193,47 @@ class Popover extends Component<Props, State> {
onMouseEnter = { this._onShowDialog } onMouseEnter = { this._onShowDialog }
onMouseLeave = { this._onHideDialog } onMouseLeave = { this._onHideDialog }
ref = { this._containerRef }> ref = { this._containerRef }>
<InlineDialog { this.state.showDialog && (
content = { this._renderContent() } <DialogPortal
isOpen = { this.state.showDialog } getRef = { this._setContextMenuRef }
placement = { position }> setSize = { this._setContextMenuStyle }
{ children } style = { this.state.contextMenuStyle }>
</InlineDialog> {this._renderContent()}
</DialogPortal>
)}
{ children }
</div> </div>
); );
} }
_setContextMenuStyle: (size: Object) => void;
/**
* Sets the context menu dialog style for positioning it on screen.
*
* @param {DOMRectReadOnly} size -The size info of the current context menu.
*
* @returns {void}
*/
_setContextMenuStyle(size) {
const style = this._getCustomDialogStyle(size);
this.setState({ contextMenuStyle: style });
}
_setContextMenuRef: (elem: HTMLElement) => void;
/**
* Sets the context menu's ref.
*
* @param {HTMLElement} elem -The html element of the context menu.
*
* @returns {void}
*/
_setContextMenuRef(elem) {
this._contextMenuRef = elem;
}
_onTouchStart: (event: TouchEvent) => void; _onTouchStart: (event: TouchEvent) => void;
/** /**
@ -230,9 +246,9 @@ class Popover extends Component<Props, State> {
_onTouchStart(event) { _onTouchStart(event) {
if (this.state.showDialog if (this.state.showDialog
&& !this.props.overflowDrawer && !this.props.overflowDrawer
&& this._containerRef && this._contextMenuRef
&& this._containerRef.current && this._contextMenuRef.contains
&& !this._containerRef.current.contains(event.target)) { && !this._contextMenuRef.contains(event.target)) {
this._onHideDialog(); this._onHideDialog();
} }
} }
@ -246,7 +262,10 @@ class Popover extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onHideDialog() { _onHideDialog() {
this.setState({ showDialog: false }); this.setState({
showDialog: false,
contextMenuStyle: null
});
if (this.props.onPopoverClose) { if (this.props.onPopoverClose) {
this.props.onPopoverClose(); this.props.onPopoverClose();
@ -327,6 +346,24 @@ class Popover extends Component<Props, State> {
} }
} }
_getCustomDialogStyle: (DOMRectReadOnly) => void;
/**
* Gets style for positioning the context menu on screen in regards to the trigger's
* position.
*
* @param {DOMRectReadOnly} size -The current context menu's size info.
*
* @returns {Object} - The new style of the context menu.
*/
_getCustomDialogStyle(size) {
if (this._containerRef && this._containerRef.current) {
const bounds = this._containerRef.current.getBoundingClientRect();
return getContextMenuStyle(bounds, size, this.props.position);
}
}
/** /**
* Renders the React Element to be displayed in the {@code InlineDialog}. * Renders the React Element to be displayed in the {@code InlineDialog}.
* Also adds padding to support moving the mouse from the trigger to the * Also adds padding to support moving the mouse from the trigger to the
@ -336,15 +373,20 @@ class Popover extends Component<Props, State> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
_renderContent() { _renderContent() {
const { content, position } = this.props; const { content } = this.props;
return ( return (
<div <div
className = 'popover' className = 'popover popupmenu'
onKeyDown = { this._onEscKey }> onKeyDown = { this._onEscKey }>
{ content } { content }
<div className = 'popover-mouse-padding-top' /> {!isMobileBrowser() && (
<div className = { _mapPositionToPaddingClass(position) } /> <>
<div className = 'popover-mousemove-padding-top' />
<div className = 'popover-mousemove-padding-right' />
<div className = 'popover-mousemove-padding-left' />
<div className = 'popover-mousemove-padding-bottom' />
</>)}
</div> </div>
); );
} }

View File

@ -0,0 +1,158 @@
// @flow
const LEFT_RIGHT_OFFSET = 25;
const TOP_BOTTOM_OFFSET = 20;
const getLeftAlignedStyle = bounds => {
return {
position: 'fixed',
right: `${window.innerWidth - bounds.x + LEFT_RIGHT_OFFSET}px`
};
};
const getRightAlignedStyle = bounds => {
return {
position: 'fixed',
left: `${bounds.x + bounds.width + LEFT_RIGHT_OFFSET}px`
};
};
const getTopAlignedStyle = bounds => {
return {
position: 'fixed',
bottom: `${window.innerHeight - bounds.y + TOP_BOTTOM_OFFSET}px`
};
};
const getBottomAlignedStyle = bounds => {
return {
position: 'fixed',
top: `${bounds.y + bounds.height + TOP_BOTTOM_OFFSET}px`
};
};
const getLeftRightStartAlign = (bounds, size) => {
return {
top: `${Math.min(bounds.y + 15, window.innerHeight - size.height - 20)}px`
};
};
const getLeftRightMidAlign = (bounds, size) => {
return {
bottom: `${window.innerHeight - bounds.y - bounds.height - (size.height / 2)}px`
};
};
const getLeftRightEndAlign = (bounds, size) => {
return {
bottom: `${Math.min(window.innerHeight - bounds.y - bounds.height, window.innerHeight - size.height)}px`
};
};
const getTopBotStartAlign = bounds => {
return {
right: `${window.innerWidth - bounds.x + 10}px`
};
};
const getTopBotMidAlign = (bounds, size) => {
return {
right: `${window.innerWidth - bounds.x - (size.width / 2)}px`
};
};
const getTopBotEndAlign = bounds => {
return {
left: `${bounds.x + bounds.width + 10}px`
};
};
/**
* Gets the trigger element's and the context menu's bounds/size info and
* computes the style to apply to the context menu to positioning it correctly
* in regards to the given position info.
*
* @param {DOMRect} triggerBounds -The bounds info of the trigger html element.
* @param {DOMRectReadOnly} dialogSize - The size info of the context menu.
* @param {string} position - The position of the context menu in regards to the trigger element.
*
* @returns {Object} = The style to apply to context menu for positioning it correctly.
*/
export const getContextMenuStyle = (triggerBounds: DOMRect,
dialogSize: DOMRectReadOnly,
position: string) => {
const parsed = position.split('-');
switch (parsed[0]) {
case 'top': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getTopBotStartAlign(triggerBounds)
: getTopBotEndAlign(triggerBounds);
} else {
alignmentStyle = getTopBotMidAlign(triggerBounds, dialogSize);
}
return {
...getTopAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'bottom': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getTopBotStartAlign(triggerBounds)
: getTopBotEndAlign(triggerBounds);
} else {
alignmentStyle = getTopBotMidAlign(triggerBounds, dialogSize);
}
return {
...getBottomAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'left': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getLeftRightStartAlign(triggerBounds, dialogSize)
: getLeftRightEndAlign(triggerBounds, dialogSize);
} else {
alignmentStyle = getLeftRightMidAlign(triggerBounds, dialogSize);
}
return {
...getLeftAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'right': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getLeftRightStartAlign(triggerBounds, dialogSize)
: getLeftRightEndAlign(triggerBounds, dialogSize);
} else {
alignmentStyle = getLeftRightMidAlign(triggerBounds, dialogSize);
}
return {
...getRightAlignedStyle(triggerBounds),
...alignmentStyle
};
}
default: {
return {
...getLeftAlignedStyle(triggerBounds),
...getLeftRightEndAlign(triggerBounds, dialogSize)
};
}
}
};

View File

@ -3,7 +3,6 @@
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { overwriteConfig } from '../base/config'; import { overwriteConfig } from '../base/config';
import { isLayoutTileView } from '../video-layout';
import { import {
CLEAR_TOOLBOX_TIMEOUT, CLEAR_TOOLBOX_TIMEOUT,
@ -134,13 +133,10 @@ export function showToolbox(timeout: number = 0): Object {
const { const {
enabled, enabled,
visible, visible
overflowDrawer
} = state['features/toolbox']; } = state['features/toolbox'];
const { contextMenuOpened } = state['features/base/responsive-ui'];
const contextMenuOpenedInTileview = isLayoutTileView(state) && contextMenuOpened && !overflowDrawer;
if (enabled && !visible && !contextMenuOpenedInTileview) { if (enabled && !visible) {
dispatch(setToolboxVisible(true)); dispatch(setToolboxVisible(true));
// If the Toolbox is always visible, there's no need for a timeout // If the Toolbox is always visible, there's no need for a timeout
@ -178,23 +174,6 @@ export function setOverflowDrawer(displayAsDrawer: boolean) {
}; };
} }
/**
* Disables and hides the toolbox on demand when in tile view.
*
* @returns {void}
*/
export function hideToolboxOnTileView() {
return (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const { overflowDrawer } = state['features/toolbox'];
if (!overflowDrawer && isLayoutTileView(state)) {
dispatch(hideToolbox(true));
}
};
}
/** /**
* Signals that toolbox timeout should be cleared. * Signals that toolbox timeout should be cleared.
* *

View File

@ -0,0 +1,99 @@
// @flow
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
type Props = {
/**
* The component(s) to be displayed within the drawer portal.
*/
children: React$Node,
/**
* Custom class name to apply on the container div.
*/
className?: string,
/**
* Function used to get the refferrence to the container div.
*/
getRef?: Function,
/**
* Function used to get the updated size info of the container on it's resize.
*/
setSize?: Function,
/**
* Custom style to apply to the container div.
*/
style?: Object,
};
/**
* Component meant to render a drawer at the bottom of the screen,
* by creating a portal containing the component's children.
*
* @returns {ReactElement}
*/
function DialogPortal({ children, className, style, getRef, setSize }: Props) {
const [ portalTarget ] = useState(() => {
const portalDiv = document.createElement('div');
return portalDiv;
});
useEffect(() => {
if (style) {
for (const styleProp of Object.keys(style)) {
// https://github.com/facebook/flow/issues/3733
const objStyle: Object = portalTarget.style;
objStyle[styleProp] = style[styleProp];
}
}
if (className) {
portalTarget.className = className;
}
}, [ style, className ]);
useEffect(() => {
if (portalTarget && getRef) {
getRef(portalTarget);
}
}, [ portalTarget ]);
useEffect(() => {
const size = {
width: 1,
height: 1
};
const observer = new ResizeObserver(entries => {
const { contentRect } = entries[0];
if (contentRect.width !== size.width || contentRect.height !== size.height) {
setSize && setSize(contentRect);
}
});
if (document.body) {
document.body.appendChild(portalTarget);
observer.observe(portalTarget);
}
return () => {
observer.unobserve(portalTarget);
if (document.body) {
document.body.removeChild(portalTarget);
}
};
}, []);
return ReactDOM.createPortal(
children,
portalTarget
);
}
export default DialogPortal;

View File

@ -1,7 +1,7 @@
// @flow // @flow
import React from 'react';
import { useEffect, useState } from 'react'; import DialogPortal from './DialogPortal';
import ReactDOM from 'react-dom';
type Props = { type Props = {
@ -18,29 +18,10 @@ type Props = {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
function DrawerPortal({ children }: Props) { function DrawerPortal({ children }: Props) {
const [ portalTarget ] = useState(() => { return (
const portalDiv = document.createElement('div'); <DialogPortal className = 'drawer-portal'>
{ children }
portalDiv.className = 'drawer-portal'; </DialogPortal>
return portalDiv;
});
useEffect(() => {
if (document.body) {
document.body.appendChild(portalTarget);
}
return () => {
if (document.body) {
document.body.removeChild(portalTarget);
}
};
}, []);
return ReactDOM.createPortal(
children,
portalTarget
); );
} }

View File

@ -4,3 +4,4 @@ export { default as ToolbarButton } from './ToolbarButton';
export { default as Toolbox } from './Toolbox'; export { default as Toolbox } from './Toolbox';
export { default as Drawer } from './Drawer'; export { default as Drawer } from './Drawer';
export { default as DrawerPortal } from './DrawerPortal'; export { default as DrawerPortal } from './DrawerPortal';
export { default as DialogPortal } from './DialogPortal';

View File

@ -14,7 +14,6 @@ import { connect } from '../../../base/redux';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions'; import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import { getLocalVideoTrack } from '../../../base/tracks'; import { getLocalVideoTrack } from '../../../base/tracks';
import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent'; import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent';
import { hideToolboxOnTileView } from '../../../toolbox/actions';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web'; import { renderConnectionStatus } from '../../actions.web';
@ -196,7 +195,6 @@ class LocalVideoMenuTriggerButton extends Component<Props> {
*/ */
_onPopoverOpen() { _onPopoverOpen() {
this.props.dispatch(setParticipantContextMenuOpen(true)); this.props.dispatch(setParticipantContextMenuOpen(true));
this.props.dispatch(hideToolboxOnTileView());
} }
_onPopoverClose: () => void; _onPopoverClose: () => void;

View File

@ -13,7 +13,6 @@ import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions'; import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import { requestRemoteControl, stopController } from '../../../remote-control'; import { requestRemoteControl, stopController } from '../../../remote-control';
import { hideToolboxOnTileView } from '../../../toolbox/actions';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web'; import { renderConnectionStatus } from '../../actions.web';
@ -234,7 +233,6 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
*/ */
_onPopoverOpen() { _onPopoverOpen() {
this.props.dispatch(setParticipantContextMenuOpen(true)); this.props.dispatch(setParticipantContextMenuOpen(true));
this.props.dispatch(hideToolboxOnTileView());
} }
_onPopoverClose: () => void; _onPopoverClose: () => void;