Merge pull request #14406 from JoVictorNunes/issue-14380

feat(presentation): new presentation menu and snapshot of the current presentation
This commit is contained in:
Anton Georgiev 2022-03-02 13:50:34 -05:00 committed by GitHub
commit d89470a96f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 555 additions and 33 deletions

View File

@ -24,6 +24,7 @@ import { ACTIONS, LAYOUT_TYPE } from '../layout/enums';
import DEFAULT_VALUES from '../layout/defaultValues';
import { colorContentBackground } from '/imports/ui/stylesheets/styled-components/palette';
import browserInfo from '/imports/utils/browserInfo';
import PresentationMenu from './presentation-menu/container';
const intlMessages = defineMessages({
presentationLabel: {
@ -450,31 +451,6 @@ class Presentation extends PureComponent {
zoomSlide(currentSlide.num, podId, w, h, x, y);
}
renderPresentationClose() {
const { isFullscreen } = this.state;
const {
layoutType,
fullscreenContext,
layoutContextDispatch,
isIphone,
} = this.props;
if (!OLD_MINIMIZE_BUTTON_ENABLED
|| !shouldEnableSwapLayout()
|| isFullscreen
|| fullscreenContext
|| layoutType === LAYOUT_TYPE.PRESENTATION_FOCUS) {
return null;
}
return (
<PresentationCloseButton
toggleSwapLayout={MediaService.toggleSwapLayout}
layoutContextDispatch={layoutContextDispatch}
isIphone={isIphone}
/>
);
}
renderOverlays(slideObj, svgDimensions, viewBoxPosition, viewBoxDimensions, physicalDimensions) {
const {
userIsPresenter,
@ -612,9 +588,8 @@ class Presentation extends PureComponent {
}}
>
<Styled.VisuallyHidden id="currentSlideText">{slideContent}</Styled.VisuallyHidden>
{this.renderPresentationClose()}
{this.renderPresentationDownload()}
{this.renderPresentationFullscreen()}
{this.renderPresentationMenu()}
<Styled.PresentationSvg
key={currentSlide.id}
data-test="whiteboard"
@ -747,23 +722,23 @@ class Presentation extends PureComponent {
);
}
renderPresentationFullscreen() {
renderPresentationMenu() {
const {
intl,
fullscreenElementId,
layoutContextDispatch,
} = this.props;
const { isFullscreen } = this.state;
if (!ALLOW_FULLSCREEN) return null;
return (
<Styled.PresentationFullscreenButton
<PresentationMenu
fullscreenRef={this.refPresentationContainer}
screenshotRef={this.getSvgRef()}
elementName={intl.formatMessage(intlMessages.presentationLabel)}
elementId={fullscreenElementId}
isFullscreen={isFullscreen}
color="muted"
fullScreenStyle={false}
toggleSwapLayout={MediaService.toggleSwapLayout}
layoutContextDispatch={layoutContextDispatch}
/>
);
}

View File

@ -0,0 +1,310 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { toPng } from 'html-to-image';
import { toast } from 'react-toastify';
import logger from '/imports/startup/client/logger';
import Styled from './styles';
import TooltipContainer from '/imports/ui/components/common/tooltip/container';
import { ACTIONS } from '/imports/ui/components/layout/enums';
import browserInfo from '/imports/utils/browserInfo';
const OLD_MINIMIZE_BUTTON_ENABLED = Meteor.settings.public.presentation.oldMinimizeButton;
const intlMessages = defineMessages({
downloading: {
id: 'app.presentation.options.downloading',
description: 'Downloading label',
defaultMessage: 'Downloading...',
},
downloaded: {
id: 'app.presentation.options.downloaded',
description: 'Downloaded label',
defaultMessage: 'Current presentation was downloaded',
},
downloadFailed: {
id: 'app.presentation.options.downloadFailed',
description: 'Downloaded failed label',
defaultMessage: 'Could not download current presentation',
},
fullscreenLabel: {
id: 'app.presentation.options.fullscreen',
description: 'Fullscreen label',
defaultMessage: 'Fullscreen',
},
exitFullscreenLabel: {
id: 'app.presentation.options.exitFullscreen',
description: 'Exit fullscreen label',
defaultMessage: 'Exit fullscreen',
},
minimizePresentationLabel: {
id: 'app.presentation.options.minimize',
description: 'Minimize presentation label',
defaultMessage: 'Minimize',
},
optionsLabel: {
id: 'app.navBar.settingsDropdown.optionsLabel',
description: 'Options button label',
defaultMessage: 'Options',
},
snapshotLabel: {
id: 'app.presentation.options.snapshot',
description: 'Snapshot of current presentation label',
defaultMessage: 'Snapshot of current presentation',
},
});
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
handleToggleFullscreen: PropTypes.func.isRequired,
isDropdownOpen: PropTypes.bool,
toggleSwapLayout: PropTypes.func.isRequired,
isFullscreen: PropTypes.bool,
elementName: PropTypes.string,
fullscreenRef: PropTypes.instanceOf(Element),
screenshotRef: PropTypes.instanceOf(Element),
meetingName: PropTypes.string,
isIphone: PropTypes.bool,
};
const defaultProps = {
isDropdownOpen: false,
isIphone: false,
isFullscreen: false,
elementName: '',
meetingName: '',
fullscreenRef: null,
screenshotRef: null,
};
const PresentationMenu = (props) => {
const {
intl,
toggleSwapLayout,
isFullscreen,
elementId,
elementName,
elementGroup,
currentElement,
currentGroup,
fullscreenRef,
screenshotRef,
handleToggleFullscreen,
layoutContextDispatch,
meetingName,
isIphone,
} = props;
const [state, setState] = useState({
hasError: false,
loading: false,
});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const toastId = useRef(null);
const dropdownRef = useRef(null);
const formattedLabel = (fullscreen) => (fullscreen
? intl.formatMessage(intlMessages.exitFullscreenLabel)
: intl.formatMessage(intlMessages.fullscreenLabel)
);
function renderToastContent() {
const { loading, hasError } = state;
let icon = loading ? 'blank' : 'check';
if (hasError) icon = 'circle_close';
return (
<Styled.Line>
<Styled.ToastText>
<span>
{loading && !hasError && intl.formatMessage(intlMessages.downloading)}
{!loading && !hasError && intl.formatMessage(intlMessages.downloaded)}
{!loading && hasError && intl.formatMessage(intlMessages.downloadFailed)}
</span>
</Styled.ToastText>
<Styled.StatusIcon>
<Styled.ToastIcon
done={!loading && !hasError}
error={hasError}
loading={loading}
iconName={icon}
/>
</Styled.StatusIcon>
</Styled.Line>
);
}
function getAvailableOptions() {
const menuItems = [];
if (!isIphone) {
menuItems.push(
{
key: 'list-item-fullscreen',
dataTest: 'presentationFullscreen',
label: formattedLabel(isFullscreen),
onClick: () => {
handleToggleFullscreen(fullscreenRef);
const newElement = (elementId === currentElement) ? '' : elementId;
const newGroup = (elementGroup === currentGroup) ? '' : elementGroup;
layoutContextDispatch({
type: ACTIONS.SET_FULLSCREEN_ELEMENT,
value: {
element: newElement,
group: newGroup,
},
});
},
},
);
}
if (OLD_MINIMIZE_BUTTON_ENABLED) {
menuItems.push(
{
key: 'list-item-minimize',
label: intl.formatMessage(intlMessages.minimizePresentationLabel),
onClick: () => {
toggleSwapLayout(layoutContextDispatch);
},
},
);
}
const { isSafari } = browserInfo;
if (!isSafari) {
menuItems.push(
{
key: 'list-item-screenshot',
label: intl.formatMessage(intlMessages.snapshotLabel),
onClick: () => {
setState({
loading: true,
hasError: false,
});
toastId.current = toast.info(renderToastContent(), {
hideProgressBar: true,
autoClose: false,
newestOnTop: true,
closeOnClick: true,
onClose: () => {
toastId.current = null;
},
});
toPng(screenshotRef, {
width: window.screen.width,
height: window.screen.height,
}).then((data) => {
const anchor = document.createElement('a');
anchor.href = data;
anchor.setAttribute(
'download',
`${elementName}_${meetingName}_${new Date().toISOString()}.png`,
);
anchor.click();
setState({
loading: false,
hasError: false,
});
}).catch((error) => {
logger.warn({
logCode: 'presentation_snapshot_error',
extraInfo: error,
});
setState({
loading: false,
hasError: true,
});
});
},
},
);
}
return menuItems;
}
useEffect(() => {
if (toastId.current) {
toast.update(toastId.current, {
render: renderToastContent(),
hideProgressBar: state.loading,
autoClose: state.loading ? false : 3000,
newestOnTop: true,
closeOnClick: true,
onClose: () => {
toastId.current = null;
},
});
}
if (dropdownRef.current) {
document.activeElement.blur();
dropdownRef.current.focus();
}
});
const options = getAvailableOptions();
if (options.length === 0) return null;
return (
<Styled.Right>
<TooltipContainer title={intl.formatMessage(intlMessages.optionsLabel)}>
<Styled.DropdownButton
state={isDropdownOpen ? 'open' : 'closed'}
aria-label={intl.formatMessage(intlMessages.optionsLabel)}
data-test="whiteboardOptionsButton"
onClick={() => setIsDropdownOpen((isOpen) => !isOpen)}
>
<Styled.ButtonIcon iconName="more" />
</Styled.DropdownButton>
</TooltipContainer>
{ isDropdownOpen && (
<>
<Styled.Overlay onClick={() => setIsDropdownOpen(false)} />
<Styled.Dropdown
ref={dropdownRef}
onBlur={() => setIsDropdownOpen(false)}
tabIndex={0}
>
<Styled.List>
{ options.map((option) => {
const {
label, onClick, key, dataTest,
} = option;
return (
<Styled.ListItem
{...{
onClick,
key,
'data-test': dataTest ?? '',
}}
>
{label}
</Styled.ListItem>
);
}) }
</Styled.List>
</Styled.Dropdown>
</>
) }
</Styled.Right>
);
};
PresentationMenu.propTypes = propTypes;
PresentationMenu.defaultProps = defaultProps;
export default injectIntl(PresentationMenu);

View File

@ -0,0 +1,41 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import PresentationMenu from './component';
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
import Auth from '/imports/ui/services/auth';
import Meetings from '/imports/api/meetings';
import { layoutSelect, layoutDispatch } from '/imports/ui/components/layout/context';
const PresentationMenuContainer = (props) => {
const fullscreen = layoutSelect((i) => i.fullscreen);
const { element: currentElement, group: currentGroup } = fullscreen;
const layoutContextDispatch = layoutDispatch();
return (
<PresentationMenu
{...props}
{...{
currentElement,
currentGroup,
layoutContextDispatch,
}}
/>
);
};
export default withTracker((props) => {
const handleToggleFullscreen = (ref) => FullscreenService.toggleFullScreen(ref);
const { isFullscreen } = props;
const isIphone = !!(navigator.userAgent.match(/iPhone/i));
const meetingId = Auth.meetingID;
const meetingObject = Meetings.findOne({ meetingId }, { fields: { 'meetingProp.name': 1 } });
return {
...props,
handleToggleFullscreen,
isIphone,
isFullscreen,
isDropdownOpen: Session.get('dropdownOpen'),
meetingName: meetingObject.meetingProp.name,
};
})(PresentationMenuContainer);

View File

@ -0,0 +1,188 @@
import styled, { css, keyframes } from 'styled-components';
import Icon from '/imports/ui/components/common/icon/component';
import { headingsFontWeight } from '/imports/ui/stylesheets/styled-components/typography';
import {
colorDanger,
colorGray,
colorGrayDark,
colorSuccess,
colorGrayLightest,
colorPrimary,
colorWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
borderSizeLarge,
lgPaddingX,
statusIconSize,
borderSize,
statusInfoHeight,
borderRadius,
mdPaddingY,
mdPaddingX,
} from '/imports/ui/stylesheets/styled-components/general';
const DropdownButton = styled.button`
background-color: ${colorGrayLightest};
border: none;
border-radius: 1px;
color: ${colorGrayDark};
cursor: pointer;
padding: .2rem .5rem;
&:hover, &:focus {
color: ${colorGray};
}
&:focus {
outline: ${colorGray} solid ${borderSize};
}
`;
const Right = styled.div`
cursor: pointer;
position: absolute;
left: auto;
top: ${borderSize};
right: ${borderSize};
z-index: 999;
[dir="rtl"] & {
right: auto;
left : ${borderSize};
}
`;
const ToastText = styled.span`
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
white-space: nowrap;
position: relative;
top: ${borderSizeLarge};
width: auto;
font-weight: ${headingsFontWeight};
[dir="rtl"] & {
text-align: right;
}
`;
const StatusIcon = styled.span`
margin-left: auto;
[dir="rtl"] & {
margin-right: auto;
margin-left: 0;
}
& > i {
position: relative;
top: 1px;
height: ${statusIconSize};
width: ${statusIconSize};
}
`;
const rotate = keyframes`
0% { transform: rotate(0); }
100% { transform: rotate(360deg); }
`;
const ToastIcon = styled(Icon)`
position: relative;
width: ${statusIconSize};
height: ${statusIconSize};
font-size: 117%;
bottom: ${borderSize};
left: ${statusInfoHeight};
[dir="rtl"] & {
left: auto;
right: ${statusInfoHeight};
}
${({ done }) => done && `
color: ${colorSuccess};
`}
${({ error }) => error && `
color: ${colorDanger};
`}
${({ loading }) => loading && css`
color: ${colorGrayLightest};
border: 1px solid;
border-radius: 50%;
border-right-color: ${colorGray};
animation: ${rotate} 1s linear infinite;
`}
`;
const Line = styled.div`
display: flex;
width: 100%;
flex-wrap: nowrap;
padding: ${lgPaddingX} 0;
`;
const List = styled.ul`
list-style-type: none;
padding: ${mdPaddingY} ${borderSize};
margin: 0;
white-space: nowrap;
text-align: left;
[dir="rtl"] & {
text-align: right;
}
`;
const ListItem = styled.li`
padding: ${mdPaddingY} ${mdPaddingX};
&:hover {
background-color: ${colorPrimary};
color: white;
}
`;
const Dropdown = styled.div`
position: absolute;
right: 0;
top: 117%;
background-color: ${colorWhite};
z-index: 1000;
box-shadow: 0 0 10px 1px ${colorGrayLightest};
border-radius: ${borderRadius};
[dir="rtl"] & {
right: auto;
left: 0;
}
`;
const ButtonIcon = styled(Icon)`
width: 1em;
text-align: center;
`;
const Overlay = styled.div`
position: fixed;
inset: 0;
z-index: 999;
cursor: auto;
`;
export default {
DropdownButton,
Right,
ToastText,
StatusIcon,
ToastIcon,
Line,
List,
Dropdown,
ListItem,
ButtonIcon,
Overlay,
};

View File

@ -44,6 +44,7 @@
"fibers": "^4.0.2",
"flat": "^4.1.1",
"hark": "^1.2.3",
"html-to-image": "^1.9.0",
"immutability-helper": "~2.8.1",
"langmap": "0.0.16",
"lodash": "^4.17.21",

View File

@ -168,6 +168,13 @@
"app.presentation.endSlideContent": "Slide content end",
"app.presentation.changedSlideContent": "Presentation changed to slide: {0}",
"app.presentation.emptySlideContent": "No content for current slide",
"app.presentation.options.fullscreen": "Fullscreen",
"app.presentation.options.exitFullscreen": "Exit Fullscreen",
"app.presentation.options.minimize": "Minimize",
"app.presentation.options.snapshot": "Snapshot of current presentation",
"app.presentation.options.downloading": "Downloading...",
"app.presentation.options.downloaded": "Current presentation was downloaded",
"app.presentation.options.downloadFailed": "Could not download current presentation",
"app.presentation.presentationToolbar.noNextSlideDesc": "End of presentation",
"app.presentation.presentationToolbar.noPrevSlideDesc": "Start of presentation",
"app.presentation.presentationToolbar.selectLabel": "Select slide",