From 6d8b9ad8b51aebf170d7267e0ca6db96da4a0919 Mon Sep 17 00:00:00 2001 From: Joao Victor Date: Fri, 18 Feb 2022 17:45:16 -0300 Subject: [PATCH] New presentation menu and snapshot of the current presentation --- .../ui/components/presentation/component.jsx | 13 +- .../presentation-menu/component.jsx | 301 ++++++++++++++++++ .../presentation-menu/container.jsx | 41 +++ .../presentation/presentation-menu/styles.js | 188 +++++++++++ bigbluebutton-html5/package.json | 1 + bigbluebutton-html5/public/locales/en.json | 7 + 6 files changed, 546 insertions(+), 5 deletions(-) create mode 100755 bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx create mode 100755 bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/styles.js diff --git a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx index 0beab0c07a..2667fc3a34 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx @@ -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: { @@ -614,7 +615,7 @@ class Presentation extends PureComponent { {slideContent} {this.renderPresentationClose()} {this.renderPresentationDownload()} - {this.renderPresentationFullscreen()} + {this.renderPresentationMenu()} ); } diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx new file mode 100755 index 0000000000..e54a739676 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx @@ -0,0 +1,301 @@ +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'; + +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 ( + + + + {loading && !hasError && intl.formatMessage(intlMessages.downloading)} + {!loading && !hasError && intl.formatMessage(intlMessages.downloaded)} + {!loading && hasError && intl.formatMessage(intlMessages.downloadFailed)} + + + + + + + ); + } + + function renderMenuItems() { + 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); + }, + }, + ); + } + + 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(); + } + }); + + return ( + + + setIsDropdownOpen((isOpen) => !isOpen)} + > + + + + { isDropdownOpen && ( + <> + setIsDropdownOpen(false)} /> + setIsDropdownOpen(false)} + tabIndex={0} + > + + { renderMenuItems().map((option) => { + const { + label, onClick, key, dataTest, + } = option; + + return ( + + {label} + + ); + }) } + + + + ) } + + ); +}; + +PresentationMenu.propTypes = propTypes; +PresentationMenu.defaultProps = defaultProps; + +export default injectIntl(PresentationMenu); diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx new file mode 100755 index 0000000000..f5bc376f74 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx @@ -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 ( + + ); +}; + +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); diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/styles.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/styles.js new file mode 100644 index 0000000000..17638141ea --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/styles.js @@ -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, +}; diff --git a/bigbluebutton-html5/package.json b/bigbluebutton-html5/package.json index 981c6a1f2c..0823cf9610 100755 --- a/bigbluebutton-html5/package.json +++ b/bigbluebutton-html5/package.json @@ -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", diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index f6c08a834b..0bb86d40c0 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -167,6 +167,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",