Merge pull request #14406 from JoVictorNunes/issue-14380
feat(presentation): new presentation menu and snapshot of the current presentation
This commit is contained in:
commit
d89470a96f
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
@ -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);
|
@ -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,
|
||||
};
|
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user