+ const wbContainer =
+ (
{isFullscreen && }
- {!tldrawIsMounting && (
+ );
+
+ const pToolbar =
+ !tldrawIsMounting && (
{ this.refPresentationToolbar = ref; }}
style={
@@ -800,7 +1047,73 @@ class Presentation extends PureComponent {
>
{this.renderPresentationToolbar(svgWidth)}
- )}
+ );
+
+ if (svgHeight != 0 && svgWidth != 0) {
+ setPreviousSvgSize(svgWidth, svgHeight);
+ }
+ if (toolbarHeight != 0) {
+ setPreviousToolbarHeight(toolbarHeight);
+ }
+
+ //const allStyles = document.getElementsByTagName("style");
+ //console.log("ALLSTYLE", allStyles);
+ const tldStyles = [/*'tldraw-fonts',*/ 'tl-canvas', 'tl-theme']; // tldraw-fonts is not in the style anymore, see the change at copyStyles
+ if (this.tlStyles.filter(v => v).length < tldStyles.length) {
+ this.tlStyles = tldStyles.map(id => document.getElementById(id) ? document.getElementById(id).sheet : null);
+ }
+ //console.log("ALLSTYLE", this.tlStyles);
+
+ return (
+ <>
+ { this.refPresentationContainer = ref; }}
+ style={{
+ top: isPresentationDetached ? 0 : presentationBounds.top,//these changes do not probably affect anything..
+ left: isPresentationDetached ? 0 : presentationBounds.left,
+ right: isPresentationDetached ? 0 : presentationBounds.right,
+ width: isPresentationDetached ? presentationWindow.document.documentElement.clientWidth : presentationBounds.width,
+ height: isPresentationDetached ? presentationWindow.document.documentElement.clientHeight : presentationBounds.height,
+ display: !presentationIsOpen ? 'none' : 'flex',
+ overflow: 'hidden',
+ zIndex: fullscreenContext ? presentationBounds.zIndex : undefined,
+ background: layoutType === LAYOUT_TYPE.VIDEO_FOCUS && numCameras > 0 && !fullscreenContext
+ ? colorContentBackground
+ : null,
+ }}
+ >
+ { this.refPresentation = ref; }}>
+
+
+ {userIsPresenter && isPresentationDetached
+ ?
+
+ v)}
+ >
+ {wbContainer}
+ {pToolbar}
+
+ {/*
+ wToolbar can be here if it impairs the slide visibility
+ */}
+
+ :
+
+ {wbContainer}
+ {pToolbar}
+
+ }
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/container.jsx
index 3a9b7f81f035..844be39db74e 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/container.jsx
@@ -69,7 +69,7 @@ const PRELOAD_NEXT_SLIDE = APP_CONFIG.preloadNextSlides;
const fetchedpresentation = {};
export default lockContextContainer(
- withTracker(({ podId, presentationIsOpen, userLocks }) => {
+ withTracker(({ podId, presentationIsOpen, userLocks, isPresentationDetached, setPresentationDetached }) => {
const currentSlide = PresentationService.getCurrentSlide(podId);
const numPages = PresentationService.getSlidesLength(podId);
const presentationIsDownloadable = PresentationService.isPresentationDownloadable(podId);
@@ -133,6 +133,8 @@ export default lockContextContainer(
notify,
zoomSlide: PresentationToolbarService.zoomSlide,
podId,
+ isPresentationDetached,
+ setPresentationDetached,
publishedPoll: Meetings.findOne({ meetingId: Auth.meetingID }, {
fields: {
publishedPoll: 1,
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx
index 75e4aec3e719..e30ee11d67ca 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx
@@ -4,13 +4,17 @@ 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 { TDShapeType } from '@tldraw/tldraw';
import Styled from './styles';
import BBBMenu from '/imports/ui/components/common/menu/component';
import TooltipContainer from '/imports/ui/components/common/tooltip/container';
import { ACTIONS } from '/imports/ui/components/layout/enums';
import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
import AppService from '/imports/ui/components/app/service';
+let firstReact = 0; //To touch TLD popup menus and shapes only once
+
const intlMessages = defineMessages({
downloading: {
id: 'app.presentation.options.downloading',
@@ -65,6 +69,14 @@ const intlMessages = defineMessages({
id: 'app.presentation.presentationToolbar.showToolsDesc',
description: 'Show toolbar label',
},
+ splitPresentationDesc: {
+ id: 'app.presentation.presentationToolbar.splitPresentationDesc',
+ description: 'Detach the presentation area label',
+ },
+ mergePresentationDesc: {
+ id: 'app.presentation.presentationToolbar.mergePresentationDesc',
+ description: 'Merge the detached presentation area label',
+ },
});
const propTypes = {
@@ -120,6 +132,9 @@ const PresentationMenu = (props) => {
meetingName,
isIphone,
isRTL,
+ isPresentationDetached,
+ presentationWindow,
+ togglePresentationDetached,
isToolbarVisible,
setIsToolbarVisible,
} = props;
@@ -143,6 +158,11 @@ const PresentationMenu = (props) => {
: intl.formatMessage(intlMessages.showToolsDesc)
);
+ const formattedDetachedLabel = (detached) => (detached
+ ? intl.formatMessage(intlMessages.mergePresentationDesc)
+ : intl.formatMessage(intlMessages.splitPresentationDesc)
+ );
+
function renderToastContent() {
const { loading, hasError } = state;
@@ -181,7 +201,7 @@ const PresentationMenu = (props) => {
label: formattedLabel(isFullscreen),
icon: isFullscreen ? 'exit_fullscreen' : 'fullscreen',
onClick: () => {
- handleToggleFullscreen(fullscreenRef);
+ handleToggleFullscreen(isPresentationDetached ? presentationWindow.document.documentElement : fullscreenRef, isPresentationDetached, presentationWindow);
const newElement = (elementId === currentElement) ? '' : elementId;
const newGroup = (elementGroup === currentGroup) ? '' : elementGroup;
@@ -232,15 +252,15 @@ const PresentationMenu = (props) => {
try {
const { copySvg, getShapes, currentPageId } = tldrawAPI;
const svgString = await copySvg(getShapes(currentPageId).map((shape) => shape.id));
- const container = document.createElement('div');
+ const container = presentationWindow.document.createElement('div');
container.innerHTML = svgString;
const svgElem = container.firstChild;
- const width = svgElem?.width?.baseVal?.value ?? window.screen.width;
- const height = svgElem?.height?.baseVal?.value ?? window.screen.height;
+ const width = svgElem?.width?.baseVal?.value ?? presentationWindow.screen.width;
+ const height = svgElem?.height?.baseVal?.value ?? presentationWindow.screen.height;
const data = await toPng(svgElem, { width, height, backgroundColor: '#FFF' });
- const anchor = document.createElement('a');
+ const anchor = presentationWindow.document.createElement('a');
anchor.href = data;
anchor.setAttribute(
'download',
@@ -285,10 +305,52 @@ const PresentationMenu = (props) => {
},
);
}
-
+
+ const {isMobile, isTablet} = deviceInfo;
+ if (!isMobile && !isTablet && props.amIPresenter && !props.darkTheme) {
+ //Currently the detach presentation function does not work on darkTheme
+ // (spreadArray not defined for document.styleSheets).
+ menuItems.push(
+ {
+ key: 'list-item-detachscreen',
+ dataTest: 'presentationDetached',
+ label: formattedDetachedLabel(isPresentationDetached),
+ icon: isPresentationDetached ? 'application' : 'rooms',
+ onClick: () => {
+ toggleDetachPresentation();
+ },
+ },
+ );
+ }
+
return menuItems;
}
+ function toggleDetachPresentation(){
+ if (firstReact == 0){
+ firstReact = 1;
+ tldrawAPI.selectTool(TDShapeType.Text);
+ tldrawAPI.setSetting('keepStyleMenuOpen', true);
+ //tldrawAPI.setSetting('dockPosition', isRTL ? 'left' : 'right'); // -> whiteboard/component
+ tldrawAPI.createShapes({ id: 'rectdummy', type: 'rectangle', point: [0, 0], size: [0, 0], },
+ { id: 'textdummy', type: 'text', text: ' ', point: [0, 0], },
+ { id: 'stickydummy', type: 'sticky', text: ' ', point: [0, 0], size: [0, 0], });
+ const ms = 50; // a dirty workaround...
+ new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, ms)
+ }).then(() => {
+ tldrawAPI.setSetting('keepStyleMenuOpen', false);
+ tldrawAPI.delete(['rectdummy', 'textdummy', 'stickydummy']);
+ tldrawAPI.selectNone();
+ togglePresentationDetached();
+ });
+ } else {
+ togglePresentationDetached();
+ }
+ }
+
useEffect(() => {
if (toastId.current) {
toast.update(toastId.current, {
@@ -304,7 +366,7 @@ const PresentationMenu = (props) => {
}
if (dropdownRef.current) {
- document.activeElement.blur();
+ presentationWindow.document.activeElement.blur();
dropdownRef.current.focus();
}
});
@@ -312,7 +374,7 @@ const PresentationMenu = (props) => {
const options = getAvailableOptions();
if (options.length === 0) {
- const undoCtrls = document.getElementById('TD-Styles')?.nextSibling;
+ const undoCtrls = presentationWindow.document.getElementById('TD-Styles')?.nextSibling;
if (undoCtrls?.style) {
undoCtrls.style = 'padding:0px';
}
@@ -349,9 +411,11 @@ const PresentationMenu = (props) => {
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
- container: fullscreenRef,
+ container: isPresentationDetached ? presentationWindow.document.body : fullscreenRef
}}
actions={options}
+ isPresentationDetached={isPresentationDetached}
+ presentationWindow={presentationWindow}
/>
);
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx
index 24bc903ae70a..6999fd3e9c27 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/container.jsx
@@ -32,7 +32,7 @@ const PresentationMenuContainer = (props) => {
};
export default withTracker((props) => {
- const handleToggleFullscreen = (ref) => FullscreenService.toggleFullScreen(ref);
+ const handleToggleFullscreen = (ref, d, w) => FullscreenService.toggleFullScreen(ref, d, w);
const isIphone = !!(navigator.userAgent.match(/iPhone/i));
const meetingId = Auth.meetingID;
const meetingObject = Meetings.findOne({ meetingId }, { fields: { 'meetingProp.name': 1 } });
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx
index 32a7301453bf..6b4402519995 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx
@@ -11,6 +11,7 @@ import {
import Styled from './styles';
import ZoomTool from './zoom-tool/component';
import SmartMediaShareContainer from './smart-video-share/container';
+import QuickLinksDropdown from './quick-links-dropdown/component';
import TooltipContainer from '/imports/ui/components/common/tooltip/container';
import KEY_CODES from '/imports/utils/keyCodes';
@@ -87,6 +88,16 @@ const intlMessages = defineMessages({
id: 'app.whiteboard.toolbar.tools.hand',
description: 'presentation toolbar pan label',
},
+ /*
+ splitPresentationDesc: {
+ id: 'app.presentation.presentationToolbar.splitPresentationDesc',
+ description: 'detach the presentation area label',
+ },
+ mergePresentationDesc: {
+ id: 'app.presentation.presentationToolbar.mergePresentationDesc',
+ description: 'merge the detached presentation area label',
+ },
+ */
});
class PresentationToolbar extends PureComponent {
@@ -104,7 +115,8 @@ class PresentationToolbar extends PureComponent {
}
componentDidMount() {
- document.addEventListener('keydown', this.switchSlide);
+ const { presentationWindow } = this.props;
+ presentationWindow.document.addEventListener('keydown', this.switchSlide);
}
componentDidUpdate(prevProps) {
@@ -113,7 +125,8 @@ class PresentationToolbar extends PureComponent {
}
componentWillUnmount() {
- document.removeEventListener('keydown', this.switchSlide);
+ const { presentationWindow } = this.props;
+ presentationWindow.document.removeEventListener('keydown', this.switchSlide);
}
handleSkipToSlideChange(event) {
@@ -145,9 +158,11 @@ class PresentationToolbar extends PureComponent {
fullscreenAction,
fullscreenRef,
handleToggleFullScreen,
+ presentationWindow,
+ isPresentationDetached,
} = this.props;
- handleToggleFullScreen(fullscreenRef);
+ handleToggleFullScreen(isPresentationDetached ? presentationWindow.document.documentElement : fullscreenRef);
const newElement = isFullscreen ? '' : fullscreenElementId;
layoutContextDispatch({
@@ -232,6 +247,14 @@ class PresentationToolbar extends PureComponent {
{intl.formatMessage(intlMessages.fitToPageDesc)}
+ {/*
+
+ {intl.formatMessage(intlMessages.mergePresentationDesc)}
+
+
+ {intl.formatMessage(intlMessages.splitPresentationDesc)}
+
+ */}
);
}
@@ -269,6 +292,14 @@ class PresentationToolbar extends PureComponent {
slidePosition,
multiUserSize,
multiUser,
+ allowExternalVideo,
+ screenSharingCheck,
+ fullscreenElementId,
+ isFullscreen,
+ fullscreenRef,
+ toolbarWidth,
+ //togglePresentationDetached,
+ //isPresentationDetached,
} = this.props;
const { isMobile } = deviceInfo;
@@ -305,8 +336,45 @@ class PresentationToolbar extends PureComponent {
/>
) : null}
-
+ {
+
+ {
+
+ }
+
+ }
+ {/*
+
+
+
+ */}
{
currentSlidHasContent: PresentationService.currentSlidHasContent(),
parseCurrentSlideContent: PresentationService.parseCurrentSlideContent,
startPoll,
+ allowExternalVideo: Meteor.settings.public.externalVideoPlayer.enabled,
};
})(PresentationToolbarContainer);
@@ -83,6 +84,7 @@ PresentationToolbarContainer.propTypes = {
nextSlide: PropTypes.func.isRequired,
previousSlide: PropTypes.func.isRequired,
skipToSlide: PropTypes.func.isRequired,
+ allowExternalVideo: PropTypes.bool.isRequired,
layoutSwapped: PropTypes.bool,
};
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/quick-links-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/quick-links-dropdown/component.jsx
new file mode 100644
index 000000000000..b9de9f0d012c
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/quick-links-dropdown/component.jsx
@@ -0,0 +1,190 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import _ from 'lodash';
+import { makeCall } from '/imports/ui/services/api';
+//import browser from 'browser-detect';
+//import Button from '/imports/ui/components/button/component';
+import Styled from '../styles';
+import Dropdown from '/imports/ui/components/dropdown/component';
+import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component';
+import DropdownContent from '/imports/ui/components/dropdown/content/component';
+import DropdownList from '/imports/ui/components/dropdown/list/component';
+import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
+import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component';
+import DropdownListTitle from '/imports/ui/components/dropdown/list/title/component';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import Panopto from '/imports/ui/components/external-video-player/custom-players/panopto';
+import Auth from '/imports/ui/services/auth';
+
+const intlMessages = defineMessages({
+ quickLinksLabel: {
+ id: 'app.externalLinks.title',
+ description: 'Quick external links title',
+ },
+ quickLinksVideoLabel: {
+ id: 'app.externalLinks.videotitle',
+ description: 'Quick external links title for videos',
+ },
+ quickLinksUrlLabel: {
+ id: 'app.externalLinks.urltitle',
+ description: 'Quick external links title for URLs',
+ },
+ trueOptionLabel: {
+ id: 'app.poll.t',
+ description: 'Poll true option value',
+ },
+ falseOptionLabel: {
+ id: 'app.poll.f',
+ description: 'Poll false option value',
+ },
+ yesOptionLabel: {
+ id: 'app.poll.y',
+ description: 'Poll yes option value',
+ },
+ noOptionLabel: {
+ id: 'app.poll.n',
+ description: 'Poll no option value',
+ },
+ abstentionOptionLabel: {
+ id: 'app.poll.abstention',
+ description: 'Poll Abstention option value',
+ },
+});
+
+//const BROWSER_RESULTS = browser();
+//const isMobileBrowser = (BROWSER_RESULTS ? BROWSER_RESULTS.mobile : false)
+// || (BROWSER_RESULTS && BROWSER_RESULTS.os
+// ? BROWSER_RESULTS.os.includes('Android') // mobile flag doesn't always work
+// : false);
+
+const propTypes = {
+ parseCurrentSlideContent: PropTypes.func.isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+ allowExternalVideo: PropTypes.bool.isRequired,
+ fullscreenRef: PropTypes.instanceOf(Element),
+ isFullscreen: PropTypes.bool.isRequired,
+};
+
+const sendGroupMessage = (message) => {
+ const CHAT_CONFIG = Meteor.settings.public.chat;
+ const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid;
+ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+ const payload = {
+ color: '0',
+ correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`,
+ sender: {
+ id: Auth.userID,
+ name: '',
+ },
+ message,
+ };
+
+ return makeCall('sendGroupChatMsg', PUBLIC_GROUP_CHAT_ID, payload);
+};
+
+const handleClickQuickVideo = (videoUrl, isFullscreen, fullscreenRef) => {
+ if (isFullscreen) {
+ FullscreenService.toggleFullScreen(fullscreenRef);
+ }
+ sendGroupMessage(videoUrl);
+
+ let externalVideoUrl = videoUrl;
+ if (Panopto.canPlay(videoUrl)) {
+ externalVideoUrl = Panopto.getSocialUrl(videoUrl);
+ }
+ makeCall('startWatchingExternalVideo', externalVideoUrl);
+};
+
+const handleClickQuickUrl = (url, isFullscreen, fullscreenRef) => {
+ if (isFullscreen) {
+ // may not be necessary; presentation automatically becomes small when the slide is moved on (but depending on browser..)
+ FullscreenService.toggleFullScreen(fullscreenRef);
+ }
+ sendGroupMessage(url);
+ window.open(url, null, 'menubar,toolbar,location,resizable');
+};
+
+function getAvailableLinks(slideId, videoUrls, urls, videoLabel, urlLabel, isFullscreen, fullscreenRef, allowEV){
+ const linkItems = [];
+ if (allowEV && videoUrls && videoUrls.length ) {
+ linkItems.push({videoLabel});
+ videoUrls.forEach(url => {
+ linkItems.push(
+ handleClickQuickVideo(url, isFullscreen, fullscreenRef)}
+ key={url}
+ />);
+ });
+ }
+
+ if (urls && urls.length ) {
+ if (videoUrls && videoUrls.length) {
+ linkItems.push();
+ }
+ linkItems.push({urlLabel});
+ urls.forEach(url => {
+ linkItems.push(
+ handleClickQuickUrl(url, isFullscreen, fullscreenRef)}
+ key={url}
+ />);
+ });
+ }
+
+ if (linkItems.length == 0) {
+ linkItems.push(
+ );
+ }
+ return(linkItems);
+}
+
+const QuickLinksDropdown = (props) => {
+ const { amIPresenter, intl, parseCurrentSlideContent, allowExternalVideo, screenSharingCheck, isFullscreen, fullscreenRef } = props;
+ //This is called twice (in actions-bar/quick-poll-dropdown/component.jsx as well),
+ // we could move this to upper component and pass via props in the future.
+ const parsedSlide = parseCurrentSlideContent(
+ intl.formatMessage(intlMessages.yesOptionLabel),
+ intl.formatMessage(intlMessages.noOptionLabel),
+ intl.formatMessage(intlMessages.abstentionOptionLabel),
+ intl.formatMessage(intlMessages.trueOptionLabel),
+ intl.formatMessage(intlMessages.falseOptionLabel),
+ );
+
+ const { slideId, videoUrls, urls } = parsedSlide;
+
+// This seems useless.
+// const shouldAllowScreensharing = screenSharingCheck
+// && !isMobileBrowser
+// && amIPresenter;
+
+ return amIPresenter ? (
+
+
+ null}
+ size="md"
+ aria-disabled={ (!videoUrls || (videoUrls && videoUrls.length == 0)) && (!urls || (urls && urls.length == 0)) ? true : false }
+ style={{ pointerEvents: (!videoUrls || (videoUrls && videoUrls.length == 0)) && (!urls || (urls && urls.length == 0)) ? 'none' : 'unset' }}
+ />
+
+
+
+ {getAvailableLinks(slideId, videoUrls, urls, intl.formatMessage(intlMessages.quickLinksVideoLabel), intl.formatMessage(intlMessages.quickLinksUrlLabel), isFullscreen, fullscreenRef, allowExternalVideo)}
+
+
+
+ ) : null;
+};
+
+QuickLinksDropdown.propTypes = propTypes;
+
+export default QuickLinksDropdown;
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js
index 9e4719052d70..f8d59d20bfa0 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js
@@ -29,7 +29,7 @@ const PresentationToolbarWrapper = styled.div`
width: 100%;
bottom: 0px;
display: grid;
- grid-template-columns: 1fr 1fr 1fr;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
padding: 2px;
select {
@@ -88,7 +88,35 @@ const PresentationSlideControls = styled.div`
padding: ${whiteboardToolbarPadding};
}
`;
+/*
+const detachWindowButton = styled(Button)`
+ border: none !important;
+
+ i {
+ font-size: 1.2rem;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ &:focus {
+ background-color: ${colorOffWhite};
+ border: 0;
+ }
+`;
+*/
const PrevSlideButton = styled(Button)`
i {
font-size: 1rem;
@@ -169,6 +197,36 @@ const PresentationZoomControls = styled.div`
}
`;
+const QuickLinksButton = styled(Button)`
+ border: none !important;
+
+ & > i {
+ font-size: 1.2rem;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+ margin-left: ${whiteboardToolbarMargin};
+ margin-right: ${whiteboardToolbarMargin};
+
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ &:focus {
+ background-color: ${colorOffWhite};
+ border: 0;
+ }
+`;
+
const FitToWidthButton = styled(Button)`
border: none !important;
@@ -281,8 +339,10 @@ export default {
NextSlideButton,
SkipSlideSelect,
PresentationZoomControls,
+ QuickLinksButton,
FitToWidthButton,
MultiUserTool,
WBAccessButton,
MUTPlaceholder,
+ //detachWindowButton,
};
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/service.js b/bigbluebutton-html5/imports/ui/components/presentation/service.js
index d693fab8daec..dada2c462f80 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/service.js
+++ b/bigbluebutton-html5/imports/ui/components/presentation/service.js
@@ -1,8 +1,10 @@
import Presentations from '/imports/api/presentations';
import { Slides, SlidePositions } from '/imports/api/slides';
+import ReactPlayer from 'react-player';
import PollService from '/imports/ui/components/poll/service';
import { safeMatch } from '/imports/utils/string-utils';
+const isUrlValid = url => ReactPlayer.canPlay(url);
const POLL_SETTINGS = Meteor.settings.public.poll;
const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom;
@@ -91,6 +93,18 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
content,
} = currentSlide;
+ const urlRegex = /((http|https):\/\/[a-zA-Z0-9\-.:]+(\/\S*)?)/g;
+ const optionsUrls = content.match(urlRegex) || [];
+ const videoUrls = optionsUrls.filter(value => isUrlValid(value));
+ const urls = optionsUrls.filter(i => videoUrls.indexOf(i) == -1);
+ content = content.replace(new RegExp(urlRegex), '');
+
+ const pollRegex = /\b(\d{1,2}|[A-Za-z])[.)].*/g; //from (#16622) + #16650
+ let optionsPoll = content.match(pollRegex) || [];
+ let optionsPollStrings = [];
+ if (optionsPoll) optionsPollStrings = optionsPoll.map(opt => `${opt.replace(/^[^.)]{1,2}[.)]/,'').replace(/^\s+/, '')}`);
+ if (optionsPoll) optionsPoll = optionsPoll.map(opt => `\r${opt.replace(/[.)].*/,'')}.`);
+
const questionRegex = /^[\s\S]+\?\s*$/gm;
const question = safeMatch(questionRegex, content, '');
@@ -110,23 +124,6 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
const trueFalsePatt = createPattern([trueValue, falseValue]);
const hasTF = safeMatch(trueFalsePatt, content, false);
- const pollRegex = /\b[1-9A-Ia-i][.)] .*/g;
- let optionsPoll = safeMatch(pollRegex, content, []);
- const optionsWithLabels = [];
-
- if (hasYN) {
- optionsPoll = ['yes', 'no'];
- }
-
- if (optionsPoll) {
- optionsPoll = optionsPoll.map((opt) => {
- const MAX_CHAR_LIMIT = 30;
- const formattedOpt = opt.substring(0, MAX_CHAR_LIMIT);
- optionsWithLabels.push(formattedOpt);
- return `\r${opt[0]}.`;
- });
- }
-
optionsPoll.reduce((acc, currentValue) => {
const lastElement = acc[acc.length - 1];
@@ -147,12 +144,22 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
const isCurrentValueInteger = !!parseInt(currentValue.charAt(1), 10);
if (isLastOptionInteger === isCurrentValueInteger) {
- if (currentValue.toLowerCase().charCodeAt(1) > lastOption.toLowerCase().charCodeAt(1)) {
- options.push(currentValue);
+ if (isCurrentValueInteger){
+ if (parseInt(currentValue.replace(/[\r.]g/,'')) == parseInt(lastOption.replace(/[\r.]g/,'')) + 1) {
+ options.push(currentValue);
+ } else {
+ acc.push({
+ options: [currentValue],
+ });
+ }
} else {
- acc.push({
- options: [currentValue],
- });
+ if (currentValue.toLowerCase().charCodeAt(1) == lastOption.toLowerCase().charCodeAt(1) + 1) {
+ options.push(currentValue);
+ } else {
+ acc.push({
+ options: [currentValue],
+ });
+ }
}
} else {
acc.push({
@@ -160,25 +167,21 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
});
}
return acc;
- }, []).filter(({
+ }, []).map(poll => {
+ for (let i = 0 ; i < poll.options.length ; i++) {
+ poll.options.shift();
+ poll.options.push(optionsPollStrings.shift());
+ }
+ return poll;
+ }).filter(({
options,
- }) => options.length > 1 && options.length < 10).forEach((p) => {
+ }) => options.length > 1 && options.length < MAX_CUSTOM_FIELDS).forEach((p) => {
const poll = p;
if (doubleQuestion) poll.multiResp = true;
- if (poll.options.length <= 5 || MAX_CUSTOM_FIELDS <= 5) {
- const maxAnswer = poll.options.length > MAX_CUSTOM_FIELDS
- ? MAX_CUSTOM_FIELDS
- : poll.options.length;
- quickPollOptions.push({
- type: `${pollTypes.Letter}${maxAnswer}`,
- poll,
- });
- } else {
quickPollOptions.push({
type: pollTypes.Custom,
poll,
});
- }
});
if (question.length > 0 && optionsPoll.length === 0 && !doubleQuestion && !hasYN && !hasTF) {
@@ -218,8 +221,9 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
return {
slideId: currentSlide.id,
quickPollOptions,
- optionsWithLabels,
pollQuestion,
+ videoUrls,
+ urls,
};
};
diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx
index 60d20e1d7010..35dc08bb3c96 100755
--- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx
@@ -22,7 +22,7 @@ const SUBSCRIPTIONS = [
'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'meeting-time-remaining',
'local-settings', 'users-typing', 'record-meetings', 'video-streams',
'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts', 'breakouts-history',
- 'pads', 'pads-sessions', 'pads-updates', 'notifications', 'audio-captions',
+ 'pads', 'pads-sessions', 'pads-updates', 'uploaded-file', 'notifications', 'audio-captions',
'layout-meetings',
];
const {
diff --git a/bigbluebutton-html5/imports/ui/components/upload/media/component.jsx b/bigbluebutton-html5/imports/ui/components/upload/media/component.jsx
new file mode 100644
index 000000000000..9db989c950ed
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/upload/media/component.jsx
@@ -0,0 +1,205 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import cx from 'classnames';
+import _ from 'lodash';
+//import Dropzone from 'react-dropzone';
+import Icon from '/imports/ui/components/common/icon/component';
+import { withModalMounter } from '/imports/ui/components/common/modal/service';
+//import Modal from '/imports/ui/components/common/modal/simple/component';
+import Button from '/imports/ui/components/common/button/component';
+import Service from './service';
+import UploadService from '../service';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.upload.media.title',
+ description: 'Media upload modal title',
+ },
+ note: {
+ id: 'app.upload.media.note',
+ description: 'Media upload modal note',
+ },
+ message: {
+ id: 'app.upload.media.message',
+ description: 'Media upload modal message',
+ },
+ filename: {
+ id: 'app.upload.media.filename',
+ description: 'Media upload modal media filename',
+ },
+ options: {
+ id: 'app.upload.media.options',
+ description: 'Media upload modal media options',
+ },
+ remove: {
+ id: 'app.upload.media.remove',
+ description: 'Media upload modal remove media',
+ },
+ upload: {
+ id: 'app.upload.media.upload',
+ description: 'Media upload modal upload button',
+ },
+ cancel: {
+ id: 'app.upload.media.cancel',
+ description: 'Media upload modal cancel button',
+ },
+});
+
+class MediaUpload extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = { files: [] };
+
+ this.source = Service.getSource();
+ this.maxSize = Service.getMaxSize();
+ this.validFiles = Service.getMediaValidFiles();
+
+ this.handleOnDrop = this.handleOnDrop.bind(this);
+ }
+
+ handleOnDrop(acceptedFiles, rejectedFiles) {
+ const filesToUpload = acceptedFiles.map(file => {
+ const id = _.uniqueId(file.name);
+
+ return {
+ file,
+ id,
+ filename: file.name,
+ }
+ });
+
+ this.setState(({ files }) => ({ files: files.concat(filesToUpload) }));
+ }
+
+ handleRemove(item) {
+ const { files } = this.state;
+ const index = files.indexOf(item);
+
+ files.splice(index, 1);
+
+ this.setState({ files });
+ }
+
+ handleUpload(files) {
+ const {
+ closeModal,
+ intl,
+ } = this.props;
+
+ UploadService.upload(this.source, files, intl);
+ closeModal();
+ }
+
+ renderItem(item) {
+ const { intl } = this.props;
+
+ return (
+
+ |
+
+ |
+
+ {item.filename}
+
+
+
+
+
+
+ );
+ }
+
+ renderFiles() {
+ const { intl } = this.props;
+ const { files } = this.state;
+
+ if (files.length === 0) return null;
+
+ return (
+
+
+
+
+
+ {intl.formatMessage(intlMessages.filename)}
+
+
+ {intl.formatMessage(intlMessages.options)}
+
+
+
+
+ {files.map(item => this.renderItem(item))}
+
+
+
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ closeModal,
+ } = this.props;
+
+ const { files } = this.state;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+
+ {intl.formatMessage(intlMessages.note)}
+ {this.renderFiles()}
+
+ type.extension)}
+ maxSize={this.maxSize}
+ disablepreview="true"
+ onDrop={this.handleOnDrop}
+ >
+
+
+ {intl.formatMessage(intlMessages.message)}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(withModalMounter(MediaUpload));
diff --git a/bigbluebutton-html5/imports/ui/components/upload/media/container.jsx b/bigbluebutton-html5/imports/ui/components/upload/media/container.jsx
new file mode 100644
index 000000000000..95d063a6ed26
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/upload/media/container.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { withModalMounter } from '/imports/ui/components/common/modal/service';
+import Service from './service';
+import MediaUpload from './component';
+
+const MediaUploadContainer = props => ;
+
+export default withModalMounter(withTracker(({ mountModal }) => ({
+ closeModal: () => mountModal(null),
+ isEnabled: Service.isEnabled(),
+}))(MediaUploadContainer));
diff --git a/bigbluebutton-html5/imports/ui/components/upload/media/service.js b/bigbluebutton-html5/imports/ui/components/upload/media/service.js
new file mode 100644
index 000000000000..d7c261d40410
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/upload/media/service.js
@@ -0,0 +1,44 @@
+import { UploadedFile } from '/imports/api/upload';
+import Auth from '/imports/ui/services/auth';
+import UploadService from '../service';
+
+const MEDIA_UPLOAD = Meteor.settings.public.upload.media;
+const DOWNLOAD = Meteor.settings.public.download;
+
+const isEnabled = () => {
+ return MEDIA_UPLOAD.enabled;
+};
+
+const getSource = () => {
+ return MEDIA_UPLOAD.source;
+};
+
+const getMaxSize = () => {
+ return MEDIA_UPLOAD.maxSize;
+};
+
+const getMediaValidFiles = () => {
+ return MEDIA_UPLOAD.validFiles;
+};
+
+const getMediaFiles = () => {
+ return UploadedFile.find({
+ meetingId: Auth.meetingID,
+ source: MEDIA_UPLOAD.source,
+ }).fetch()
+};
+
+const getDownloadURL = uploadId => {
+ const { source } = MEDIA_UPLOAD;
+
+ return UploadService.buildDownloadURL(source, uploadId);
+};
+
+export default {
+ isEnabled,
+ getSource,
+ getMaxSize,
+ getMediaValidFiles,
+ getMediaFiles,
+ getDownloadURL,
+};
diff --git a/bigbluebutton-html5/imports/ui/components/upload/media/styles.js b/bigbluebutton-html5/imports/ui/components/upload/media/styles.js
new file mode 100644
index 000000000000..530acf3979ec
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/upload/media/styles.js
@@ -0,0 +1,390 @@
+import styled from 'styled-components';
+import Modal from '/imports/ui/components/common/modal/simple/component';
+import Button from '/imports/ui/components/common/button/component';
+import Icon from '/imports/ui/components/common/icon/component';
+import Dropzone from 'react-dropzone';
+import {
+ fileLineWidth,
+ iconPaddingMd,
+ borderSizeLarge,
+ lgPaddingX,
+ statusIconSize,
+ toastMdMargin,
+ uploadListHeight,
+ smPaddingX,
+ smPaddingY,
+ borderSize,
+ borderRadius,
+ lgPaddingY,
+ mdPaddingY,
+ modalInnerWidth,
+ statusInfoHeight,
+ itemActionsWidth,
+ uploadIconSize,
+ iconLineHeight,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ headingsFontWeight,
+ fontSizeLarge,
+ modalTitleFw,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGrayLight,
+ colorGrayDark,
+ colorPrimary,
+ colorWhite,
+ colorDanger,
+ colorGray,
+ colorGrayLighter,
+ colorLink,
+ colorSuccess,
+ colorGrayLightest,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+
+const Header = styled.header`
+ margin: 0;
+ padding: 0;
+ border: none;
+ line-height: 2rem;
+`;
+
+const Content = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ padding: .5rem 0 2rem 0;
+ overflow: hidden;
+`;
+
+/*const Overlay = styled(Modal)`
+ @extend .overlay;
+`;*/
+
+const ModalStyle = styled(Modal)`
+ padding: 1.5rem;
+ min-height: 20rem;
+`;
+
+const UploaderModal = styled.div`
+ background-color: white;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1300;
+`;
+
+const ModalInner = styled.div`
+ margin-left: auto;
+ margin-right: auto;
+ width: ${modalInnerWidth};
+ max-height: 100%;
+ max-width: 100%;
+ padding-bottom: .75rem;
+ overflow-y: auto;
+
+ @media ${smallOnly} {
+ padding-left: ${statusInfoHeight};
+ padding-right: ${statusInfoHeight};
+ }
+`;
+
+const ModalHeader = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ border-bottom:${borderSize} solid ${colorGrayLighter};
+ margin-bottom: 2rem;
+ h1 {
+ font-weight: ${modalTitleFw};
+ }
+ div {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+`;
+
+const Title = styled.h3`
+ text-align: center;
+ font-weight: 400;
+ font-size: 1.3rem;
+ color: var(--color-background);
+ white-space: normal;
+
+ @include mq($small-only) {
+ font-size: 1rem;
+ padding: 0 1rem;
+ }
+`;
+
+const DropzoneWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ margin-top: calc(var(--lg-padding-y) * 5);
+`;
+
+const UploaderDropzone = styled(Dropzone)`
+ flex: auto;
+ border: ${borderSize} dashed ${colorGray};
+ color: ${colorGray};
+ border-radius: ${borderRadius};
+ padding: calc(${lgPaddingY} * 2.5) ${lgPaddingX};
+ text-align: center;
+ font-size: ${fontSizeLarge};
+ cursor: pointer;
+ & .dropzoneActive {
+ background-color: ${colorGrayLighter};
+ }
+`;
+
+const DropzoneIcon = styled(Icon)`
+ font-size: calc(${fontSizeLarge} * 3);
+`;
+
+const DropzoneMessage = styled.p`
+ margin: ${mdPaddingY} 0;
+`;
+
+const DropzoneLink = styled.span`
+ color: ${colorLink};
+ text-decoration: underline;
+ font-size: 80%;
+ display: block;
+`;
+
+/*.dropzoneActive {
+ background-color: var(--color-gray-lighter);
+}*/
+
+/*.dropzoneIcon {
+ font-size: calc(var(--font-size-large) * 3);
+}*/
+
+/*
+const DropzoneMessage = styled.p`
+ margin: var(--md-padding-y) 0;
+`;
+*/
+
+const Hidden = styled.th`
+ position: absolute;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ height: 1px; width: 1px;
+ margin: -1px; padding: 0; border: 0;
+`;
+
+const List = styled.div`
+ /*@include scrollbox-vertical();*/
+ overflow-y: auto;
+ max-height: 35vh;
+ width: 100%;
+ padding: .5rem 0;
+`;
+
+const Table = styled.table`
+ width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+
+ > thead {
+ }
+
+ > tbody {
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+
+ > tr {
+ border-bottom: 1px solid var(--color-gray-light);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: transparentize(#8B9AA8, .85);
+ }
+
+ th,
+ td {
+ padding: calc(var(--sm-padding-y) * 2) calc(var(--sm-padding-x) / 2);
+ white-space: nowrap;
+ }
+
+ th {
+ font-weight: bold;
+ color: var(--color-gray-dark);
+ }
+
+ td {
+ }
+ }
+ }
+`;
+
+const Actions = styled.td`
+ width: 1%;
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+`;
+
+/*.icon > i {
+ font-size: 1.35rem;
+}*/
+
+const Name = styled.th`
+ height: 1rem;
+ width: auto;
+ position: relative;
+
+ &:before {
+ content: "\00a0";
+ visibility: hidden;
+ }
+
+ > span {
+ /*@extend %text-elipsis;*/
+ /*text-overflow: ellipsis;*/
+ position: absolute;
+ left: 0;
+ right: 0;
+
+ [dir="rtl"] & {
+ right: 1rem;
+ }
+ }
+`;
+
+const Action = styled(Button)`
+ div > i {
+ margin-top: .25rem;
+ }
+ display: inline-block;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.35rem;
+ color: var(--color-gray-light);
+ padding: 0;
+
+ :global(.animationsEnabled) & {
+ transition: all .25s;
+ }
+
+ :hover, :focus {
+ padding: unset !important;
+ }
+
+ background-color: transparent;
+ border: 0 !important;
+
+ & > i:focus,
+ & > i:hover {
+ color: var(--color-danger) !important;
+ background-color: transparent;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .5;
+ box-shadow: none;
+ pointer-events: none;
+ }
+`;
+
+/*.action,
+.action > i {
+ display: inline-block;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.35rem;
+ color: var(--color-gray-light);
+ padding: 0;
+
+ :global(.animationsEnabled) & {
+ transition: all .25s;
+ }
+
+ :hover, :focus {
+ padding: unset !important;
+ }
+}
+
+.remove {
+ background-color: transparent;
+ border: 0 !important;
+
+ & > i:focus,
+ & > i:hover {
+ color: var(--color-danger) !important;
+ background-color: transparent;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .5;
+ box-shadow: none;
+ pointer-events: none;
+ }
+}*/
+
+const StyledButtons = styled.div`
+ margin-left: auto;
+ margin-right: 3px;
+
+
+ [dir="rtl"] & {
+ margin-right: auto;
+ margin-left: 3px;
+ }
+
+ :first-child {
+ margin-right: 3px;
+ /*margin-left: inherit;*/
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 3px;
+ }
+ }
+`;
+
+const Footer = styled.div`
+ display: flex;
+`;
+
+export default {
+ Header,
+ Content,
+ UploaderModal,
+ ModalStyle,
+ ModalInner,
+ ModalHeader,
+ Title,
+ DropzoneWrapper,
+ UploaderDropzone,
+ DropzoneIcon,
+ DropzoneMessage,
+ DropzoneLink,
+ Hidden,
+ List,
+ Table,
+ Actions,
+ Name,
+ Action,
+ StyledButtons,
+ Footer,
+};
diff --git a/bigbluebutton-html5/imports/ui/components/upload/service.js b/bigbluebutton-html5/imports/ui/components/upload/service.js
new file mode 100644
index 000000000000..0c5e543ac369
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/upload/service.js
@@ -0,0 +1,138 @@
+import { defineMessages, injectIntl } from 'react-intl';
+import axios from 'axios';
+import { UploadRequest } from '/imports/api/upload';
+import Auth from '/imports/ui/services/auth';
+import { notify } from '/imports/ui/services/notification';
+import { makeCall } from '/imports/ui/services/api';
+
+const UPLOAD = Meteor.settings.public.upload;
+const DOWNLOAD = Meteor.settings.public.download;
+
+const intlMessages = defineMessages({
+ uploading: {
+ id: 'app.upload.toast.uploading',
+ description: 'Upload toast file uploading',
+ },
+ completed: {
+ id: 'app.upload.toast.completed',
+ description: 'Upload toast file completed',
+ },
+ 401: {
+ id: 'app.upload.toast.error.401',
+ description: 'Upload toast error unauthorized',
+ },
+ 408: {
+ id: 'app.upload.toast.error.408',
+ description: 'Upload toast error request timeout',
+ },
+ header: {
+ id: 'app.upload.notification.header',
+ description: 'Uploaded file notification header',
+ },
+ disclaimer: {
+ id: 'app.upload.notification.disclaimer',
+ description: 'Uploaded file notification disclaimer',
+ },
+});
+
+const requestUpload = (source, filename) => {
+ return new Promise((resolve, reject) => {
+ const timestamp = new Date().getTime();
+ makeCall('requestUpload', source, filename, timestamp);
+
+ let comp;
+ const timeout = setTimeout(() => {
+ if (comp) comp.stop();
+ reject(408);
+ }, UPLOAD.timeout);
+
+ Tracker.autorun(computation => {
+ comp = computation;
+
+ const subscription = Meteor.subscribe('upload-request', Auth.credentials, source, filename);
+ if (!subscription.ready()) return;
+
+ const {
+ meetingId,
+ requesterUserId: userId,
+ } = Auth.credentials;
+
+ const request = UploadRequest.findOne({
+ source,
+ meetingId,
+ userId,
+ filename,
+ timestamp,
+ });
+
+ if (!request) return;
+ clearTimeout(timeout);
+ computation.stop();
+
+ if (!request.success) {
+ reject(401);
+ } else {
+ resolve(request.token);
+ }
+ });
+ });
+};
+
+const upload = (source, files, intl) => {
+ files.forEach(file => {
+ requestUpload(source, file.filename).then(token => {
+ notify(intl.formatMessage(intlMessages.uploading, ({ 0: file.filename })), 'info', 'upload');
+ post(source, file, token, intl);
+ }).catch(code => {
+ notify(intl.formatMessage(intlMessages[code], ({ 0: file.filename })), 'error', 'upload');
+ });
+ });
+};
+
+const post = (source, file, token, intl) => {
+ const {
+ meetingId,
+ requesterUserId: userId,
+ } = Auth.credentials;
+
+ const url = `${UPLOAD.endpoint}/${source}/${token}`;
+
+ const data = new FormData();
+ data.append('file', file.file);
+
+ const config = {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ 'X-Meeting-ID': meetingId,
+ 'X-User-ID': userId,
+ 'X-Filename': encodeURI(file.filename),
+ },
+ };
+
+ axios.post(url, data, config).then(resp => {
+ notify(intl.formatMessage(intlMessages.completed, ({ 0: file.filename })), 'info', 'upload');
+ }).catch(error => {
+ console.log("Upload error", error.message);
+ notify('Upload error' + error.message, 'error', 'upload');
+ });
+};
+
+const buildDownloadURL = (source, uploadId) => {
+ return Auth.authenticateURL(`${DOWNLOAD.endpoint}/${source}/${uploadId}`);
+};
+
+const getNotification = ({ source, uploadId, filename }, intl) => {
+ const downloadURL = buildDownloadURL(source, uploadId);
+
+ const header = `${intl.formatMessage(intlMessages.header)}:`;
+ const link = `${filename}`;
+ const disclaimer = `${intl.formatMessage(intlMessages.disclaimer)}`;
+
+ return `${header}
${link}
${disclaimer}`;
+};
+
+export default {
+ upload,
+ buildDownloadURL,
+ getNotification,
+};
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx
index c900c8f9754c..aea9638fd595 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx
@@ -56,6 +56,8 @@ export default function Whiteboard(props) {
maxStickyNoteLength,
fontFamily,
hasShapeAccess,
+ isPresentationDetached,
+ presentationWindow,
presentationAreaHeight,
presentationAreaWidth,
maxNumberOfAnnotations,
@@ -124,7 +126,7 @@ export default function Whiteboard(props) {
setIsToolLocked(false);
- const panButton = document.querySelector('[data-test="panButton"]');
+ const panButton = presentationWindow.document.querySelector('[data-test="panButton"]');
if (panBtnClicked) {
const dataZoom = panButton.getAttribute('data-zoom');
if ((dataZoom <= HUNDRED_PERCENT && !fitToWidth)) {
@@ -141,7 +143,7 @@ export default function Whiteboard(props) {
};
React.useEffect(() => {
- const toolbar = document.getElementById('TD-PrimaryTools');
+ const toolbar = presentationWindow.document.getElementById('TD-PrimaryTools');
const handleClick = (evt) => {
toggleOffCheck(evt);
};
@@ -168,9 +170,9 @@ export default function Whiteboard(props) {
React.useEffect(() => {
if (whiteboardToolbarAutoHide) {
- toggleToolsAnimations('fade-in', 'fade-out', animations ? '3s' : '0s');
+ toggleToolsAnimations('fade-in', 'fade-out', animations ? '3s' : '0s', presentationWindow);
} else {
- toggleToolsAnimations('fade-out', 'fade-in', animations ? '.3s' : '0s');
+ toggleToolsAnimations('fade-out', 'fade-in', animations ? '.3s' : '0s', presentationWindow);
}
}, [whiteboardToolbarAutoHide]);
@@ -189,9 +191,9 @@ export default function Whiteboard(props) {
const checkClientBounds = (e) => {
if (
- e.clientX > document.documentElement.clientWidth
+ e.clientX > presentationWindow.document.documentElement.clientWidth
|| e.clientX < 0
- || e.clientY > document.documentElement.clientHeight
+ || e.clientY > presentationWindow.document.documentElement.clientHeight
|| e.clientY < 0
) {
if (tldrawAPI?.session) {
@@ -201,7 +203,7 @@ export default function Whiteboard(props) {
};
const checkVisibility = () => {
- if (document.visibilityState === 'hidden' && tldrawAPI?.session) {
+ if (presentationWindow.document.visibilityState === 'hidden' && tldrawAPI?.session) {
tldrawAPI?.completeSession?.();
}
};
@@ -227,15 +229,30 @@ export default function Whiteboard(props) {
}
React.useEffect(() => {
- document.addEventListener('mouseup', checkClientBounds);
- document.addEventListener('visibilitychange', checkVisibility);
-
+ if (tldrawAPI && !isPresentationDetached) {
+ // to 'touch' the CSS of side position of the dock
+ tldrawAPI.setSetting('dockPosition', isRTL ? 'left' : 'right');
+ }
+ if (isPresentationDetached && slidePosition) {
+ const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ if (fitToWidth) {
+ setTimeout(() => {
+ tldrawAPI.setCamera([slidePosition.x, slidePosition.y], newZoom, 'zoomed');
+ }, 50);
+ } else {
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom);
+ }
+ }
+ presentationWindow.document.addEventListener('mouseup', checkClientBounds);
+ presentationWindow.document.addEventListener('visibilitychange', checkVisibility);
return () => {
- document.removeEventListener('mouseup', checkClientBounds);
- document.removeEventListener('visibilitychange', checkVisibility);
- const canvas = document.getElementById('canvas');
- if (canvas) {
- canvas.removeEventListener('wheel', handleWheelEvent);
+ presentationWindow.document.removeEventListener('mouseup', checkClientBounds);
+ presentationWindow.document.removeEventListener('visibilitychange', checkVisibility);
+ if (!isPresentationDetached) {
+ const canvas = document.getElementById('canvas');
+ if (canvas) {
+ canvas.removeEventListener('wheel', handleWheelEvent);
+ }
}
};
}, [tldrawAPI]);
@@ -405,7 +422,7 @@ export default function Whiteboard(props) {
}
}
}
- }, [presentationWidth, presentationHeight, curPageId, document?.documentElement?.dir]);
+ }, [presentationWidth, presentationHeight, curPageId, isPresentationDetached ? presentationWindow.document?.documentElement?.dir : document?.documentElement?.dir]);
React.useEffect(() => {
if (presentationWidth > 0 && presentationHeight > 0 && slidePosition) {
@@ -550,9 +567,11 @@ export default function Whiteboard(props) {
fullscreenAction,
fullscreenRef,
handleToggleFullScreen,
+ isPresentationDetached,
+ presentationWindow,
} = props;
- handleToggleFullScreen(fullscreenRef);
+ handleToggleFullScreen(isPresentationDetached ? presentationWindow.document.documentElement : fullscreenRef)
const newElement = isFullscreen ? '' : fullscreenElementId;
layoutContextDispatch({
@@ -605,18 +624,22 @@ export default function Whiteboard(props) {
};
const onMount = (app) => {
- const menu = document.getElementById('TD-Styles')?.parentElement;
- const canvas = document.getElementById('canvas');
- if (canvas) {
- canvas.addEventListener('wheel', handleWheelEvent, { capture: true });
- }
+ const menu = presentationWindow.document.getElementById('TD-Styles')?.parentElement;
+ if (!isPresentationDetached) {
+ //when the presentation is detached, the canvas will be found hidden on the top-left corner of the main panel,
+ // instead of the detached window. So we don't use below.
+ const canvas = document.getElementById('canvas');
+ if (canvas) {
+ canvas.addEventListener('wheel', handleWheelEvent, { capture: true });
+ }
+ }
if (menu) {
const MENU_OFFSET = '48px';
menu.style.position = 'relative';
menu.style.height = presentationMenuHeight;
menu.setAttribute('id', 'TD-Styles-Parent');
- if (isRTL) {
+ if (isRTL && !isPresentationDetached) { //a workaround for now..
menu.style.left = MENU_OFFSET;
} else {
menu.style.right = MENU_OFFSET;
@@ -920,7 +943,7 @@ export default function Whiteboard(props) {
}
if (whiteboardToolbarAutoHide && command && command.id === "change_page") {
- toggleToolsAnimations('fade-in', 'fade-out', '0s');
+ toggleToolsAnimations('fade-in', 'fade-out', '0s', presentationWindow);
}
if (command?.id?.includes('style')) {
@@ -1051,6 +1074,21 @@ export default function Whiteboard(props) {
const menuOffset = menuOffsetValues[isRTL][isIphone];
+ if (isPresentationDetached) {
+ // inject styles to the detached window as styled component is not inherited..?
+ const styleId = "supplementedTldrawStyle";
+ const tldgsarg = {hasWBAccess, isPresenter, hideContextMenu: !hasWBAccess && !isPresenter, size, isRTL, darkTheme, menuOffset, panSelected};
+ const tldgs = Styled.TldrawGlobalStyleText(tldgsarg);
+ const oldElement = presentationWindow.document.getElementById(styleId);
+ if (oldElement) {
+ presentationWindow.document.head.removeChild(oldElement);
+ }
+ const suppStyle = presentationWindow.document.createElement('style');
+ suppStyle.id = styleId;
+ suppStyle.appendChild(presentationWindow.document.createTextNode(tldgs));
+ presentationWindow.document.head.appendChild(suppStyle);
+ }
+
return (
@@ -1091,6 +1131,7 @@ export default function Whiteboard(props) {
panSelected,
setPanSelected,
currentTool,
+ presentationWindow,
}}
formatMessage={intl?.formatMessage}
/>
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx
index a8c3ab8d898f..36e01138224a 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx
@@ -35,8 +35,8 @@ const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
const WhiteboardContainer = (props) => {
const usingUsersContext = useContext(UsersContext);
const isRTL = layoutSelect((i) => i.isRTL);
- const width = layoutSelect((i) => i?.output?.presentation?.width);
- const height = layoutSelect((i) => i?.output?.presentation?.height);
+ let width = layoutSelect((i) => i?.output?.presentation?.width);
+ let height = layoutSelect((i) => i?.output?.presentation?.height);
const sidebarNavigationWidth = layoutSelect((i) => i?.output?.sidebarNavigation?.width);
const { users } = usingUsersContext;
const currentUser = users[Auth.meetingID][Auth.userID];
@@ -46,6 +46,11 @@ const WhiteboardContainer = (props) => {
const fontFamily = WHITEBOARD_CONFIG.styles.text.family;
const handleToggleFullScreen = (ref) => FullscreenService.toggleFullScreen(ref);
+ if (props.isPresentationDetached) {
+ width = props.presentationWindow.document.documentElement.clientWidth;
+ height = props.presentationWindow.document.documentElement.clientHeight;
+ }
+
const { shapes } = props;
const hasShapeAccess = (id) => {
const owner = shapes[id]?.userId;
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/component.jsx
index 6433b939b699..f5c112755ab6 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/component.jsx
@@ -10,7 +10,9 @@ const XL_OFFSET = 85;
const BOTTOM_CAM_HANDLE_HEIGHT = 10;
const PRES_TOOLBAR_HEIGHT = 35;
-const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
+const { cursorInterval: CURSOR_INTERVAL } = Meteor.settings.public.whiteboard;
+const hostUri = `https://${window.document.location.hostname}`;
+const baseName = hostUri + Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
const makeCursorUrl = (filename) => `${baseName}/resources/images/whiteboard-cursor/${filename}`;
const TOOL_CURSORS = {
@@ -46,6 +48,8 @@ const Cursors = (props) => {
isPanning,
isMoving,
currentTool,
+ isPresentationDetached,
+ presentationWindow,
toggleToolsAnimations,
whiteboardToolbarAutoHide,
application,
@@ -62,7 +66,7 @@ const Cursors = (props) => {
if (hasTlPartial) {
event?.preventDefault();
}
- if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-out', 'fade-in', application?.animations ? '.3s' : '0s');
+ if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-out', 'fade-in', application?.animations ? '.3s' : '0s', presentationWindow);
setActive(true);
};
const handleGrabbing = () => setPanGrabbing(true);
@@ -77,7 +81,7 @@ const Cursors = (props) => {
whiteboardId,
});
}
- if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-in', 'fade-out', application?.animations ? '3s' : '0s');
+ if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-in', 'fade-out', application?.animations ? '3s' : '0s', presentationWindow);
setActive(false);
};
@@ -96,6 +100,7 @@ const Cursors = (props) => {
const camPosition = document.getElementById('layout')?.getAttribute('data-cam-position') || null;
const sl = document.getElementById('layout')?.getAttribute('data-layout');
const presentationContainer = document.querySelector('[data-test="presentationContainer"]');
+ //Only this one needs to be obtained from presentationWindow, but when the presentation is re-attached, this will become null.. so stay without presentationWindow. Anyway only style.height/width values are used at calcPresOffset
const presentation = document.getElementById('currentSlideText')?.parentElement;
const banners = document.querySelectorAll('[data-test="notificationBannerBar"]');
let yOffset = 0;
@@ -113,6 +118,7 @@ const Cursors = (props) => {
};
// If the presentation container is the full screen element we don't
// need any offsets
+ // Does not need to be presentationWindow.document, because when isPresentationDetached, the offsets will be anyway ignored.
const { webkitFullscreenElement, fullscreenElement } = document;
const fsEl = webkitFullscreenElement || fullscreenElement;
if (fsEl?.getAttribute('data-test') === 'presentationContainer') {
@@ -124,7 +130,7 @@ const Cursors = (props) => {
if (subPanel) xOffset += parseFloat(subPanel?.style?.width);
// offset native tldraw eraser animation container
- const overlay = document.getElementsByClassName('tl-overlay')[0];
+ const overlay = presentationWindow.document.getElementsByClassName('tl-overlay')[0];
if (overlay) overlay.style.left = '0px';
if (type === 'touchmove') {
@@ -137,6 +143,7 @@ const Cursors = (props) => {
return setPos({ x: newX, y: newY });
}
+ //dir element cannot be obtained from the detached window
if (document?.documentElement?.dir === 'rtl') {
xOffset = 0;
if (presentationContainer && presentation) {
@@ -214,8 +221,12 @@ const Cursors = (props) => {
yOffset += parseFloat(window.getComputedStyle(el).height);
});
}
-
- return setPos({ x: event.x - xOffset, y: event.y - yOffset });
+
+ if (isPresentationDetached) {
+ return setPos({ x: event.x, y: event.y });
+ } else {
+ return setPos({ x: event.x - xOffset, y: event.y - yOffset });
+ }
};
React.useEffect(() => {
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/pan-tool-injector/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
index 002411860dcb..4da10c28072b 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
@@ -47,13 +47,14 @@ class PanToolInjector extends React.Component {
tldrawAPI,
panSelected,
setPanSelected,
+ presentationWindow,
} = this.props;
if (panSelected) {
tldrawAPI?.selectTool('select');
}
- const tools = document.querySelectorAll('[id*="TD-PrimaryTools-"]');
+ const tools = presentationWindow.document.querySelectorAll('[id*="TD-PrimaryTools-"]');
tools.forEach((tool) => {
const { classList } = tool.firstElementChild;
if (panSelected) {
@@ -63,7 +64,7 @@ class PanToolInjector extends React.Component {
}
});
- const parentElement = document.getElementById('TD-PrimaryTools');
+ const parentElement = presentationWindow.document.getElementById('TD-PrimaryTools');
if (!parentElement) return;
if (parentElement.childElementCount === DEFAULT_TOOL_COUNT) {
@@ -75,7 +76,7 @@ class PanToolInjector extends React.Component {
id: 'app.whiteboard.toolbar.tools.hand',
description: 'presentation toolbar pan label',
});
- const container = document.createElement('span');
+ const container = presentationWindow.document.createElement('span');
parentElement.appendChild(container);
ReactDOM.render(
{
if (intl) notify(intl.formatMessage(intlMessages.shapeNumberExceeded, { 0: limit }), 'warning', 'whiteboard');
};
-const toggleToolsAnimations = (activeAnim, anim, time) => {
- const tdTools = document.querySelector("#TD-Tools");
- const topToolbar = document.getElementById("TD-Styles")?.parentElement;
+const toggleToolsAnimations = (activeAnim, anim, time, presentationWindow) => {
+ const tdTools = presentationWindow.document.querySelector("#TD-Tools");
+ const topToolbar = presentationWindow.document.getElementById("TD-Styles")?.parentElement;
if (tdTools && topToolbar) {
tdTools.classList.remove(activeAnim);
topToolbar.classList.remove(activeAnim);
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js
index d454a91c6319..70dda2f94dbc 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js
@@ -108,6 +108,205 @@ const TldrawGlobalStyle = createGlobalStyle`
`}
`;
+const TldrawGlobalStyleText = (arg) => {
+ const styleText = `
+ ${ arg.hideContextMenu ? `
+ #TD-ContextMenu {
+ display: none;
+ }
+ ` : ''}
+ #TD-StylesMenu {
+ position: relative;
+ right: ${arg.menuOffset};
+ }
+ #TD-PrimaryTools-Image {
+ display: none;
+ }
+ #slide-background-shape div {
+ pointer-events: none;
+ user-select: none;
+ }
+ div[dir*="ltr"]:has(button[aria-expanded*="false"][aria-controls*="radix-"]) {
+ pointer-events: none;
+ }
+ [aria-expanded*="false"][aria-controls*="radix-"] {
+ display: none;
+ }
+ [class$="-side-right"] {
+ top: -1px;
+ }
+ ${ (arg.hasWBAccess || arg.isPresenter) ? `
+ #TD-Tools-Dots {
+ height: ${arg.size}px;
+ width: ${arg.size}px;
+ }
+ #TD-Delete button {
+ height: ${arg.size}px;
+ width: ${arg.size}px;
+ }
+ #TD-PrimaryTools button {
+ height: ${arg.size}px;
+ width: ${arg.size}px;
+ }
+ #TD-Styles {
+ border-width: ${borderSize};
+ }
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo,
+ #TD-Styles {
+ height: 92%;
+ border-radius: 7px;
+ }
+ #TD-TopPanel-Undo:hover,
+ #TD-TopPanel-Redo:hover,
+ #TD-Styles:hover {
+ border: solid ${borderSize} #ECECEC;
+ background-color: #ECECEC;
+ }
+ #TD-TopPanel-Undo > div:hover,
+ #TD-TopPanel-Redo > div:hover,
+ #TD-Styles > div:hover {
+ background-color: var(--colors-hover);
+ }
+ #TD-Styles:focus {
+ border: solid ${borderSize} ${colorBlack};
+ }
+ #TD-Styles,
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo {
+ margin: ${borderSize} ${borderSizeLarge} 0px ${borderSizeLarge};
+ }
+
+ /* For manually supplementing the style of the TD-Tools-Dots */
+ div[style*="--radix-popper-transform-origin"] > div {
+ display: flex;
+ }
+ /* stop propagate mouse click so that the browser's context menu won't appear everytime you open the tldraw context menu */
+ div:has(#TD-ContextMenu) {
+ pointer-events: none;
+ }
+
+ /* For tldraw tooltips; for an edge case where the user enters the detached mode without showing any tooltip */
+ div[style*="--radix-tooltip-content-transform-origin"] {
+ border-radius: 3px;
+ padding: var(--space-3) var(--space-3) var(--space-3) var(--space-3);
+ font-size: var(--fontSizes-1);
+ background-color: var(--colors-tooltip);
+ color: var(--colors-tooltipContrast);
+ box-shadow: var(--shadows-3);
+ display: flex;
+ align-items: center;
+ font-family: var(--fonts-ui);
+ user-select: none;
+ }
+
+ /* for sticky notes */
+ div[data-shape="sticky"] > div > div > div > div {
+ text-align: ${ arg.isRTL ? `right` : `left` } ;
+ }
+
+ div[data-shape="sticky"] textarea {
+ width: 100%;
+ height: 100%;
+ border: none;
+ overflow: hidden;
+ background: none;
+ outline: none;
+ textAlign: left;
+ font: inherit;
+ padding: 0;
+ color: transparent;
+ verticalAlign: top;
+ resize: none;
+ caretColor: black;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ letter-spacing: -0.03em;
+ }
+ /* for text */
+ div[data-shape="text"] textarea {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ border: none;
+ padding: 4px;
+ resize: none;
+ text-align: inherit;
+ min-height: inherit;
+ min-width: inherit;
+ line-height: inherit;
+ letter-spacing: inherit;
+ outline: 0px;
+ font-weight: inherit;
+ overflow: hidden;
+ backface-visibility: hidden;
+ display: inline-block;
+ pointer-events: all;
+ background: var(--colors-boundsBg);
+ user-select: text;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ }
+ ` : ''}
+
+ ${ ((arg.hasWBAccess || arg.isPresenter) && arg.panSelected) ? `
+ [id^="TD-PrimaryTools-"]:hover > div {
+ background-color: var(--colors-hover) !important;
+ }
+ [id^="TD-PrimaryTools-"]:focus > div {
+ background-color: var(--colors-hover) !important;
+ }
+ ` : ''}
+
+ ${ (arg.darkTheme) ? `
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo,
+ #TD-Styles:focus {
+ border: solid ${borderSize} ${colorWhite} !important;
+ }
+ ` : ''}
+
+ ${ !(arg.isPresenter) ? `
+ #presentationInnerWrapper div{
+ cursor: default !important;
+ }
+ ` : ''}
+
+ button[data-test="panButton"] {
+ border: none !important;
+ padding: 0;
+ margin: 0;
+ border-radius: 7px;
+ background-color: ${colorWhite};
+ color: ${toolbarButtonColor};
+ }
+ button[data-test="panButton"] > i {
+ font-size: ${fontSizeLarger} !important;
+ ${ arg.isRTL ? `
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ ` : '' }
+ }
+ ${ !(arg.panSelected) ? `
+ button[data-test="panButton"]:hover {
+ background-color: var(--colors-hover) !important;
+ }
+ button[data-test="panButton"]:focus {
+ background-color: var(--colors-hover) !important;
+ }
+ ` : '' }
+
+ `;
+
+ return styleText;
+};
+
const EditableWBWrapper = styled.div`
&, & > :first-child {
cursor: inherit !important;
@@ -142,6 +341,7 @@ const PanTool = styled(Button)`
export default {
TldrawGlobalStyle,
+ TldrawGlobalStyleText,
EditableWBWrapper,
PanTool,
};
diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml
index 9686ba3b7192..126d54c0c25a 100755
--- a/bigbluebutton-html5/private/config/settings.yml
+++ b/bigbluebutton-html5/private/config/settings.yml
@@ -498,13 +498,16 @@ public:
poll:
enabled: true
allowCustomResponseInput: true
- maxCustom: 5
+ maxCustom: 99
maxTypedAnswerLength: 45
chatMessage: true
captions:
enabled: true
id: captions
dictation: false
+ enableAutomaticTranslation: false
+ googleTranslateUrl: https://script.google.com/macros/s/YOUR_ID
+ #deeplTranslateUrl: https://api-free.deepl.com/v2/translate?auth_key=YOUR_KEY
background: '#000000'
font:
color: '#ffffff'
@@ -698,6 +701,25 @@ public:
- danger
- critical
help: STATS_HELP_URL
+ upload:
+ endpoint: /bigbluebutton/upload
+ timeout: 5000
+ notification: false
+ media:
+ enabled: true
+ maxSize: 900000000
+ source: media
+ validFiles:
+ - extension: .mp3
+ type: audio/mp3
+ - extension: .ogg
+ type: audio/ogg
+ - extension: .mp4
+ type: video/mp4
+ - extension: .webm
+ type: video/webm
+ download:
+ endpoint: /bigbluebutton/download
presentation:
allowDownloadable: true
panZoomThrottle: 32
diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json
index cb8e447f392d..42413283c81f 100755
--- a/bigbluebutton-html5/public/locales/en.json
+++ b/bigbluebutton-html5/public/locales/en.json
@@ -80,6 +80,20 @@
"app.confirmation.virtualBackground.title": "Start new virtual background",
"app.confirmation.virtualBackground.description": "{0} will be added as virtual background. Continue?",
"app.confirmationModal.yesLabel": "Yes",
+ "app.upload.toast.uploading": "Uploading {0}",
+ "app.upload.toast.completed": "Completed {0}",
+ "app.upload.toast.error.401": "Unauthorized {0}",
+ "app.upload.toast.error.408": "Request timeout {0}",
+ "app.upload.notification.header": "A file was uploaded",
+ "app.upload.notification.disclaimer": "Note that your browser might not support this type of file.",
+ "app.upload.media.title": "Media upload",
+ "app.upload.media.note": "Uploaded media can be shared from the external video sharing modal",
+ "app.upload.media.message": "Drag or select media files to be uploaded",
+ "app.upload.media.filename": "Media filename",
+ "app.upload.media.options": "Media options",
+ "app.upload.media.remove": "Remove",
+ "app.upload.media.upload": "Upload",
+ "app.upload.media.cancel": "Cancel",
"app.textInput.sendLabel": "Send",
"app.title.defaultViewLabel": "Default presentation view",
"app.notes.title": "Shared Notes",
@@ -237,6 +251,8 @@
"app.presentation.presentationToolbar.goToSlide": "Slide {0}",
"app.presentation.presentationToolbar.hideToolsDesc": "Hide Toolbars",
"app.presentation.presentationToolbar.showToolsDesc": "Show Toolbars",
+ "app.presentation.presentationToolbar.splitPresentationDesc": "Split presentation window",
+ "app.presentation.presentationToolbar.mergePresentationDesc": "Merge presentation window",
"app.presentation.placeholder": "There is no currently active presentation",
"app.presentationUploder.title": "Presentation",
"app.presentationUploder.message": "As a presenter you have the ability to upload any Office document or PDF file. We recommend PDF file for best results. Please ensure that a presentation is selected using the circle checkbox on the left hand side.",
@@ -574,6 +590,8 @@
"app.actionsBar.actionsDropdown.desktopShareLabel": "Share your screen",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "Stop sharing your screen",
"app.actionsBar.actionsDropdown.presentationDesc": "Upload your presentation",
+ "app.actionsBar.actionsDropdown.uploadMediaLabel": "Upload a movie/audio file temporarily",
+ "app.actionsBar.actionsDropdown.uploadMediaDesc": "Upload your local media file",
"app.actionsBar.actionsDropdown.initPollDesc": "Initiate a poll",
"app.actionsBar.actionsDropdown.desktopShareDesc": "Share your screen with others",
"app.actionsBar.actionsDropdown.stopDesktopShareDesc": "Stop sharing your screen with",
@@ -714,6 +732,7 @@
"app.audio.captions.button.stop": "Stop closed captions",
"app.audio.captions.button.language": "Language",
"app.audio.captions.button.transcription": "Transcription",
+ "app.audio.captions.button.translation": "Translation",
"app.audio.captions.button.transcriptionSettings": "Transcription settings",
"app.audio.captions.speech.title": "Automatic transcription",
"app.audio.captions.speech.disabled": "Disabled",
@@ -1140,6 +1159,9 @@
"app.createBreakoutRoom.modalDesc": "Tip: You can drag-and-drop a user's name to assign them to a specific breakout room.",
"app.createBreakoutRoom.roomTime": "{0} minutes",
"app.createBreakoutRoom.numberOfRoomsError": "The number of rooms is invalid.",
+ "app.externalLinks.title": "External links within slide",
+ "app.externalLinks.videotitle": "Video",
+ "app.externalLinks.urltitle": "Website",
"app.createBreakoutRoom.duplicatedRoomNameError": "Room name can't be duplicated.",
"app.createBreakoutRoom.emptyRoomNameError": "Room name can't be empty.",
"app.createBreakoutRoom.setTimeInMinutes": "Set duration to (minutes)",
@@ -1161,6 +1183,7 @@
"app.externalVideo.urlInput": "Add Video URL",
"app.externalVideo.urlError": "This video URL isn't supported",
"app.externalVideo.close": "Close",
+ "app.externalVideo.filename": "Media filename",
"app.externalVideo.autoPlayWarning": "Play the video to enable media synchronization",
"app.externalVideo.refreshLabel": "Refresh Video Player",
"app.externalVideo.fullscreenLabel": "Video Player",
diff --git a/bigbluebutton-html5/public/locales/ja.json b/bigbluebutton-html5/public/locales/ja.json
index 6fadc0153d1b..91e049704e47 100644
--- a/bigbluebutton-html5/public/locales/ja.json
+++ b/bigbluebutton-html5/public/locales/ja.json
@@ -80,6 +80,20 @@
"app.confirmation.virtualBackground.title": "新しいバーチャル背景を設定",
"app.confirmation.virtualBackground.description": "{0}個がバーチャル背景として追加されます。続けますか?",
"app.confirmationModal.yesLabel": "はい",
+ "app.upload.toast.uploading": "アップロード中 {0}",
+ "app.upload.toast.completed": "完了 {0}",
+ "app.upload.toast.error.401": "許可がありません {0}",
+ "app.upload.toast.error.408": "要求がタイムアウトしました {0}",
+ "app.upload.notification.header": "ファイルがアップロードされました",
+ "app.upload.notification.disclaimer": "ブラウザが、このファイルタイプをサポートしていない可能性もあります。注意してください",
+ "app.upload.media.title": "メディアアップロード",
+ "app.upload.media.note": "mp4、mp3、ogg、webm形式の動画、音声ファイルを1000MBまでアップロード可能です。アップロードされたファイルは「インターネット上の動画を共有」から共有できます。",
+ "app.upload.media.message": "アップロードするメディアファイルをドラッグまたは選択してください",
+ "app.upload.media.filename": "メディアファイル名",
+ "app.upload.media.options": "メディアオプション",
+ "app.upload.media.remove": "削除",
+ "app.upload.media.upload": "アップロード",
+ "app.upload.media.cancel": "キャンセル",
"app.textInput.sendLabel": "送る",
"app.title.defaultViewLabel": "既定のプレゼンテーションビュー",
"app.notes.title": "共有ノート",
@@ -235,6 +249,8 @@
"app.presentation.presentationToolbar.fitToWidth": "幅に合わせる",
"app.presentation.presentationToolbar.fitToPage": "ページに合わせる",
"app.presentation.presentationToolbar.goToSlide": "スライド {0}",
+ "app.presentation.presentationToolbar.splitPresentationDesc": "プレゼンウィンドウを分離",
+ "app.presentation.presentationToolbar.mergePresentationDesc": "プレゼンウィンドウを統合",
"app.presentation.presentationToolbar.hideToolsDesc": "ツールバーを隠す",
"app.presentation.presentationToolbar.showToolsDesc": "ツールバーを表示",
"app.presentation.placeholder": "現在アクティブなプレゼンテーションはありません",
@@ -574,6 +590,8 @@
"app.actionsBar.actionsDropdown.desktopShareLabel": "画面を共有",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "画面共有をやめる",
"app.actionsBar.actionsDropdown.presentationDesc": "プレゼンテーションをアップロード",
+ "app.actionsBar.actionsDropdown.uploadMediaLabel": "動画や音声ファイルを一時的にアップロード",
+ "app.actionsBar.actionsDropdown.uploadMediaDesc": "ローカルのメディアファイルをアップロード",
"app.actionsBar.actionsDropdown.initPollDesc": "投票を初期化",
"app.actionsBar.actionsDropdown.desktopShareDesc": "他の人と画面を共有する",
"app.actionsBar.actionsDropdown.stopDesktopShareDesc": "画面共有をやめる:",
@@ -714,6 +732,7 @@
"app.audio.captions.button.stop": "字幕を停止",
"app.audio.captions.button.language": "言語",
"app.audio.captions.button.transcription": "文字起こし",
+ "app.audio.captions.button.translation": "翻訳",
"app.audio.captions.button.transcriptionSettings": "文字起こし設定",
"app.audio.captions.speech.title": "自動文字起こし",
"app.audio.captions.speech.disabled": "無効",
@@ -1140,6 +1159,9 @@
"app.createBreakoutRoom.modalDesc": "やり方:ユーザーの名前をドラッグ&ドロップして、特定の小会議室に割りふってください。",
"app.createBreakoutRoom.roomTime": "{0} 分",
"app.createBreakoutRoom.numberOfRoomsError": "会議室の数が正しく設定されていません。",
+ "app.externalLinks.title": "スライド内の外部リンク",
+ "app.externalLinks.videotitle": "ビデオ",
+ "app.externalLinks.urltitle": "Webサイト",
"app.createBreakoutRoom.duplicatedRoomNameError": "会議室名を重複してつけることはできません。",
"app.createBreakoutRoom.emptyRoomNameError": "会議室名を空白にはできません。",
"app.createBreakoutRoom.setTimeInMinutes": "会議時間を(minutes)分にセットする",
@@ -1161,6 +1183,7 @@
"app.externalVideo.urlInput": "動画URLを追加",
"app.externalVideo.urlError": "この動画URLは再生できませんでした",
"app.externalVideo.close": "閉じる",
+ "app.externalVideo.filename": "メディアファイル名",
"app.externalVideo.autoPlayWarning": "音声同期するには動画を再生してください",
"app.externalVideo.refreshLabel": "ビデオプレイヤーをリフレッシュ",
"app.externalVideo.fullscreenLabel": "ビデオプレイヤー",
diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js
index 770e85d499a6..0201803f7f03 100755
--- a/bigbluebutton-html5/server/main.js
+++ b/bigbluebutton-html5/server/main.js
@@ -24,6 +24,7 @@ import '/imports/api/users-infos/server';
import '/imports/api/users-persistent-data/server';
import '/imports/api/connection-status/server';
import '/imports/api/audio-captions/server';
+import '/imports/api/upload/server';
import '/imports/api/external-videos/server';
import '/imports/api/pads/server';
import '/imports/api/guest-users/server';
diff --git a/bigbluebutton-web/bbb-web.nginx b/bigbluebutton-web/bbb-web.nginx
index 5db9ea4b668f..1269454d3d16 100755
--- a/bigbluebutton-web/bbb-web.nginx
+++ b/bigbluebutton-web/bbb-web.nginx
@@ -148,7 +148,75 @@
proxy_set_header X-textTrack-track $textTrack;
proxy_set_header X-Original-URI $request_uri;
}
+
+ # For the implementation, you need to add these directly to /etc/bigbluebutton/nginx/web(.nginx) or to /usr/share/bigbluebutton/nginx/web.nginx
+ location ~ "^\/bigbluebutton\/upload\/(?[a-z]+)\/(?[a-zA-Z0-9]{32})$" {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_redirect default;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Source $source;
+
+ # Allow 30M uploaded files
+ client_max_body_size 900m;
+ client_body_buffer_size 128k;
+
+ proxy_connect_timeout 90;
+ proxy_send_timeout 90;
+ proxy_read_timeout 90;
+
+ proxy_buffer_size 4k;
+ proxy_buffers 4 32k;
+ proxy_busy_buffers_size 64k;
+ proxy_temp_file_write_size 64k;
+
+ include fastcgi_params;
+
+ proxy_request_buffering off;
+
+ # Send a sub-request bbb-web to refuse before loading
+ auth_request /bigbluebutton/upload/check;
+ }
+ location = /bigbluebutton/upload/check {
+ internal;
+ proxy_pass http://127.0.0.1:8090;
+ proxy_redirect default;
+ proxy_set_header Content-Length "";
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Original-URI $request_uri;
+ proxy_set_header X-Content-Length $http_content_length;
+ proxy_set_header X-Source $source;
+ proxy_set_header X-Token $token;
+
+ # Allow 30M uploaded files
+ client_max_body_size 900m;
+ client_body_buffer_size 128k;
+
+ proxy_pass_request_body off;
+ proxy_request_buffering off;
+ }
+
+ location ~ "^\/bigbluebutton\/download\/(?[a-z]+)\/(?[a-zA-Z0-9-]+)$" {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_redirect default;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Original-URI $request_uri;
+ proxy_set_header X-Source $source;
+ proxy_set_header X-Upload-ID $id;
+
+ # Send a sub-request bbb-web to refuse before loading
+ auth_request /bigbluebutton/download/check;
+ }
+
+ location = /bigbluebutton/download/check {
+ internal;
+ proxy_pass http://127.0.0.1:8090;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ proxy_set_header X-Original-URI $request_uri;
+ proxy_set_header X-Source $source;
+ proxy_set_header X-Upload-ID $id;
+ }
}
location @error403 {
diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
index 3118079c6ad1..b60c3a556dd0 100644
--- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
+++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
@@ -63,6 +63,13 @@ maxNumPages=200
#----------------------------------------------------
# Maximum file size for an uploaded presentation (default 30MB).
maxFileSizeUpload=30000000
+#----------------------------------------------------
+# Maximum file size for an generic upload (default 30MB).
+maxUploadSize=900000000
+
+#----------------------------------------------------
+# Directory where BigBlueButton stores uploaded files.
+uploadDir=/var/bigbluebutton
#----------------------------------------------------
# Maximum allowed number of place object tags in generated svg, if exceeded the conversion will fallback to full BMP (default 800)
diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml
index a6de7921f729..f0d24ff65c33 100755
--- a/bigbluebutton-web/grails-app/conf/spring/resources.xml
+++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml
@@ -179,6 +179,8 @@ with BigBlueButton; if not, see .
+
+
diff --git a/bigbluebutton-web/grails-app/conf/spring/turn-stun-servers.xml b/bigbluebutton-web/grails-app/conf/spring/turn-stun-servers.xml
index 90913545e05c..06d4fc7b416c 100755
--- a/bigbluebutton-web/grails-app/conf/spring/turn-stun-servers.xml
+++ b/bigbluebutton-web/grails-app/conf/spring/turn-stun-servers.xml
@@ -1,5 +1,6 @@