diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 6eedd8f1163a..3a2450a4b616 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1685,7 +1685,10 @@ SELECT pres_presentation."meetingId", pres_page."infiniteWhiteboard", pres_page."fitToWidth", ( - select array_agg("nextPages"."urlsJson"->>'svg') + select array_agg( + "nextPages"."urlsJson"->>'svg' + ORDER BY "nextPages".num + ) from pres_page "nextPages" where "nextPages"."presentationId" = pres_page."presentationId" and "nextPages".num > pres_page."num" diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx index 0fe1ae5a29a2..e411fc54043a 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx @@ -159,6 +159,7 @@ class ActionsBar extends PureComponent { showScreenshareQuickSwapButton, isReactionsButtonEnabled, isRaiseHandEnabled, + isPresentationDetached, } = this.props; const Settings = getSettingsSingletonInstance(); @@ -256,6 +257,7 @@ class ActionsBar extends PureComponent { hasPinnedSharedNotes={isSharedNotesPinned} hasGenericContent={hasGenericContent} hasCameraAsContent={hasCameraAsContent} + isPresentationDetached={isPresentationDetached} /> ) : null} diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/presentation-options/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/presentation-options/component.jsx index 737fb24d568d..a6dd5b31ffe4 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/presentation-options/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/presentation-options/component.jsx @@ -45,6 +45,7 @@ const PresentationOptionsContainer = ({ hasPinnedSharedNotes, hasGenericContent, hasCameraAsContent, + isPresentationDetached, }) => { let buttonType = 'presentation'; if (hasExternalVideo) { @@ -100,7 +101,7 @@ const PresentationOptionsContainer = ({ } }} id="restore-presentation" - disabled={!isThereCurrentPresentation} + disabled={!isThereCurrentPresentation || isPresentationDetached} data-test={!presentationIsOpen ? 'restorePresentation' : 'minimizePresentation'} /> ); diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index f657190f4a23..87739ccf4950 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -285,6 +285,7 @@ class App extends Component { const { hideActionsBar, presentationIsOpen, + isPresentationDetached, } = this.props; if (hideActionsBar) return null; @@ -293,6 +294,7 @@ class App extends Component { ); } @@ -337,6 +339,9 @@ class App extends Component { isNotificationEnabled, isNonMediaLayout, isRaiseHandEnabled, + popupWindow, + isPresentationDetached, + toggleDetachPresentation, currentUserHasVoice, } = this.props; @@ -388,6 +393,9 @@ class App extends Component { fitToWidth={presentationFitToWidth} darkTheme={darkTheme} presentationIsOpen={presentationIsOpen} + popupWindow={popupWindow} + isPresentationDetached={isPresentationDetached} + toggleDetachPresentation={toggleDetachPresentation} /> ) : null diff --git a/bigbluebutton-html5/imports/ui/components/app/container.jsx b/bigbluebutton-html5/imports/ui/components/app/container.jsx index 98c847692d8a..82c8020b0117 100755 --- a/bigbluebutton-html5/imports/ui/components/app/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/container.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useMutation, useReactiveVar } from '@apollo/client'; import AudioCaptionsLiveContainer from '/imports/ui/components/audio/audio-graphql/audio-captions/live/component'; import getFromUserSettings from '/imports/ui/services/users-settings'; @@ -122,9 +122,24 @@ const AppContainer = (props) => { const shouldShowScreenshare = (viewScreenshare || isPresenter) && (currentMeeting?.componentsFlags?.hasScreenshare || currentMeeting?.componentsFlags?.hasCameraAsContent) && showScreenshare; - const shouldShowPresentation = (!shouldShowScreenshare && !isSharedNotesPinned - && !shouldShowExternalVideo && !shouldShowGenericMainContent - && (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled; + + const [popupWindow, setPopupWindow] = useState(null); + const [isPresentationDetached, setIsPresentationDetached] = useState(false); + + const toggleDetachPresentation = (popup) => { + setPopupWindow(popup); + setIsPresentationDetached(Boolean(popup)); + }; + + const hasPresentationContent = + (presentationIsOpen || presentationRestoreOnUpdate) && isPresentationEnabled; + const noOtherMainContent = + !shouldShowScreenshare && !isSharedNotesPinned && + !shouldShowExternalVideo && !shouldShowGenericMainContent; + const shouldShowPresentation = isPresentationDetached + ? hasPresentationContent + : noOtherMainContent && hasPresentationContent; + const currentPageInfoData = currentPageInfo?.pres_page_curr[0] ?? {}; const fitToWidth = currentPageInfoData?.fitToWidth ?? false; const pageId = currentPageInfoData?.pageId ?? ''; @@ -179,6 +194,9 @@ const AppContainer = (props) => { isBreakout: currentMeeting?.isBreakout ?? false, meetingName: currentMeeting?.name ?? '', meetingId: currentMeeting?.meetingId ?? '', + isPresentationDetached, + popupWindow, + toggleDetachPresentation, }} {...props} /> diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx index 3deed616255d..f9199fd8f136 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/component.tsx @@ -38,6 +38,8 @@ import { CHAT_DELETE_REACTION_MUTATION, CHAT_SEND_REACTION_MUTATION } from './pa import logger from '/imports/startup/client/logger'; import { ChatLoading } from '../component'; import Storage from '/imports/ui/services/storage/in-memory'; +import { originalHTMLElement } from '/imports/utils/HTMLElementBackup'; +import { originalRAF } from '/imports/utils/animationFrameBackup'; const PAGE_SIZE = 50; const CLEANUP_TIMEOUT = 3000; @@ -69,7 +71,7 @@ interface ChatListProps { } const isElement = (el: unknown): el is HTMLElement => { - return el instanceof HTMLElement; + return el instanceof originalHTMLElement; }; const isMap = (map: unknown): map is Map => { @@ -439,7 +441,7 @@ const ChatMessageList: React.FC = ({ const value = (timestamp - initialTimestamp) / 300; if (value <= 1) { container.scrollTop = initialPosition + (value * scrollPositionDiff); - requestAnimationFrame(animateScrollPosition); + originalRAF(animateScrollPosition); } else { container.scrollTop = container.scrollHeight - container.offsetHeight; setIsScrollingDisabled(false); @@ -456,10 +458,10 @@ const ChatMessageList: React.FC = ({ initialTimestamp = timestamp; initialPosition = scrollTop; scrollPositionDiff = scrollHeight - offsetHeight - scrollTop; - requestAnimationFrame(animateScrollPosition); + originalRAF(animateScrollPosition); }; - requestAnimationFrame(startScrollAnimation); + originalRAF(startScrollAnimation); }, []); const renderUnreadNotification = useMemo(() => { diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx index a186e919b472..0542694ee40c 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx @@ -54,6 +54,7 @@ import { isMobile } from '/imports/utils/deviceInfo'; import { layoutSelect } from '/imports/ui/components/layout/context'; import { Layout } from '/imports/ui/components/layout/layoutTypes'; import { useModalRegistration } from '/imports/ui/core/singletons/modalController'; +import { originalRAF, originalCAF } from '/imports/utils/animationFrameBackup'; interface ChatMessageProps { message: Message; @@ -247,7 +248,7 @@ const ChatMessage = React.forwardRef(({ useImperativeHandle(ref, () => ({ requestFocus() { setTimeout(() => { - requestAnimationFrame(startScrollAnimation); + originalRAF(startScrollAnimation); }, 0); }, sequence: message.messageSequence, @@ -255,20 +256,20 @@ const ChatMessage = React.forwardRef(({ const startScrollAnimation = (timestamp: number) => { if ((containerRef.current?.offsetTop || 0) > (scrollRef.current?.scrollTop || 0)) { - requestAnimationFrame(startBackgroundAnimation); + originalRAF(startBackgroundAnimation); return; } animationInitialScrollPosition.current = scrollRef.current?.scrollTop || 0; animationScrollPositionDiff.current = (scrollRef.current?.scrollTop || 0) - ((containerRef.current?.offsetTop || 0) - ((scrollRef.current?.offsetHeight || 0) / 2)); animationInitialTimestamp.current = timestamp; - requestAnimationFrame(animateScrollPosition); + originalRAF(animateScrollPosition); }; const startBackgroundAnimation = (timestamp: number) => { animationInitialTimestamp.current = timestamp; animationInitialBgColor.current = containerRef.current?.style.backgroundColor ?? ''; - requestAnimationFrame(animateBackgroundColor); + originalRAF(animateBackgroundColor); }; const animateScrollPosition = (timestamp: number) => { @@ -280,10 +281,10 @@ const ChatMessage = React.forwardRef(({ if (!scrollContainer || !messageContainer) return; if (value <= 1) { scrollContainer.scrollTop = initialPosition - (value * diff); - requestAnimationFrame(animateScrollPosition); + originalRAF(animateScrollPosition); } else { scrollContainer.scrollTop = initialPosition - diff; - requestAnimationFrame(startBackgroundAnimation); + originalRAF(startBackgroundAnimation); } }; @@ -292,7 +293,7 @@ const ChatMessage = React.forwardRef(({ const value = (timestamp - animationInitialTimestamp.current) / ANIMATION_DURATION; if (value < 1) { chatMessageContentWrapperRef.current.style.backgroundColor = `rgb(${colorBlueLighterChannel} / ${1 - value})`; - requestAnimationFrame(animateBackgroundColor); + originalRAF(animateBackgroundColor); } else { chatMessageContentWrapperRef.current.style.backgroundColor = animationInitialBgColor.current; } @@ -323,7 +324,7 @@ const ChatMessage = React.forwardRef(({ }, ) => { if (currentFrame < stabilityFrames) { - const frameId = requestAnimationFrame(() => { + const frameId = originalRAF(() => { pollScrollEndEvent(setFrameId, { stabilityFrames, currentFrame: currentFrame + 1, @@ -340,10 +341,10 @@ const ChatMessage = React.forwardRef(({ const startScrollEndEventPolling = useCallback(() => { if (scrollEndFrameRef.current != null) { - cancelAnimationFrame(scrollEndFrameRef.current); + originalCAF(scrollEndFrameRef.current); scrollEndFrameRef.current = undefined; } - scrollEndFrameRef.current = requestAnimationFrame(() => { + scrollEndFrameRef.current = originalRAF(() => { pollScrollEndEvent((frameId) => { scrollEndFrameRef.current = frameId; }); @@ -364,7 +365,7 @@ const ChatMessage = React.forwardRef(({ return () => { scrollRef?.current?.removeEventListener('scroll', callbackFunction); if (scrollEndFrameRef.current !== undefined) { - cancelAnimationFrame(scrollEndFrameRef.current); + originalCAF(scrollEndFrameRef.current); scrollEndFrameRef.current = undefined; } }; diff --git a/bigbluebutton-html5/imports/ui/components/common/fullscreen-button/service.js b/bigbluebutton-html5/imports/ui/components/common/fullscreen-button/service.js index 550df065a10c..964139b8a894 100644 --- a/bigbluebutton-html5/imports/ui/components/common/fullscreen-button/service.js +++ b/bigbluebutton-html5/imports/ui/components/common/fullscreen-button/service.js @@ -1,25 +1,25 @@ -function getFullscreenElement() { - if (document.fullscreenElement) return document.fullscreenElement; - if (document.webkitFullscreenElement) return document.webkitFullscreenElement; - if (document.mozFullScreenElement) return document.mozFullScreenElement; - if (document.msFullscreenElement) return document.msFullscreenElement; +function getFullscreenElement(d = document) { + if (d.fullscreenElement) return d.fullscreenElement; + if (d.webkitFullscreenElement) return d.webkitFullscreenElement; + if (d.mozFullScreenElement) return d.mozFullScreenElement; + if (d.msFullscreenElement) return d.msFullscreenElement; return null; } -const isFullScreen = (element) => { - if (getFullscreenElement() && getFullscreenElement() === element) { +const isFullScreen = (element, doc = document) => { + if (getFullscreenElement(doc) && getFullscreenElement(doc) === element) { return true; } return false; }; -function cancelFullScreen() { - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.mozCancelFullScreen) { - document.mozCancelFullScreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); +function cancelFullScreen(doc = document) { + if (doc.exitFullscreen) { + doc.exitFullscreen(); + } else if (doc.mozCancelFullScreen) { + doc.mozCancelFullScreen(); + } else if (doc.webkitExitFullscreen) { + doc.webkitExitFullscreen(); } } @@ -39,13 +39,20 @@ function fullscreenRequest(element) { element.focus(); } -const toggleFullScreen = (ref = null) => { - const element = ref || document.documentElement; - - if (isFullScreen(element)) { - cancelFullScreen(); +const toggleFullScreen = (ref = null, isDetached = false, p) => { + const element = isDetached ? p.document.documentElement : (ref || document.documentElement); + if (isDetached) { + if (isFullScreen(element, p.document)) { + cancelFullScreen(p.document); + } else { + fullscreenRequest(element); + } } else { - fullscreenRequest(element); + if (isFullScreen(element)) { + cancelFullScreen(); + } else { + fullscreenRequest(element); + } } }; diff --git a/bigbluebutton-html5/imports/ui/components/emoji-rain/component.jsx b/bigbluebutton-html5/imports/ui/components/emoji-rain/component.jsx index 605c648796f0..b2d08f92e3f6 100644 --- a/bigbluebutton-html5/imports/ui/components/emoji-rain/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/emoji-rain/component.jsx @@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { getSettingsSingletonInstance } from '/imports/ui/services/settings'; import Service from './service'; import logger from '/imports/startup/client/logger'; +//import { originalRAF } from '/imports/utils/animationFrameBackup'; const EmojiRain = ({ reactions }) => { const Settings = getSettingsSingletonInstance(); @@ -57,6 +58,8 @@ const EmojiRain = ({ reactions }) => { } requestAnimationFrame(() => setTimeout(() => flyingEmojis.forEach((emoji) => { + // No effect observed (emoji rain works without using originalRAF). So removed. + //originalRAF(() => setTimeout(() => flyingEmojis.forEach((emoji) => { const { shapeElement, endPosition } = emoji; shapeElement.style.left = `${endPosition.x}px`; shapeElement.style.top = `${endPosition.y}px`; diff --git a/bigbluebutton-html5/imports/ui/components/layout/layout-manager/customLayout.jsx b/bigbluebutton-html5/imports/ui/components/layout/layout-manager/customLayout.jsx index 91cc6a564f56..72a23a287f71 100644 --- a/bigbluebutton-html5/imports/ui/components/layout/layout-manager/customLayout.jsx +++ b/bigbluebutton-html5/imports/ui/components/layout/layout-manager/customLayout.jsx @@ -461,10 +461,13 @@ const CustomLayout = (props) => { } if ( - fullscreenElement === 'Presentation' || - fullscreenElement === 'Screenshare' || - fullscreenElement === 'ExternalVideo' || - fullscreenElement === 'GenericContent' + (fullscreenElement === 'Presentation' || + fullscreenElement === 'Screenshare' || + fullscreenElement === 'ExternalVideo' || + fullscreenElement === 'GenericContent') && + // this is indispensable for showing a normal-sized operatable external video + // when popup is fullscreen within the sub-monitor + document.getElementById('presentationInnerWrapper') ) { mediaBounds.width = windowWidth(); mediaBounds.height = windowHeight(); @@ -803,4 +806,4 @@ const CustomLayout = (props) => { return null; }; -export default CustomLayout; \ No newline at end of file +export default CustomLayout; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx index 9337a7c6145e..bdbb1fcbf7f0 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx @@ -1,4 +1,5 @@ import React, { PureComponent } from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import WhiteboardContainer from '/imports/ui/components/whiteboard/container'; import { HUNDRED_PERCENT, MAX_PERCENT, MIN_PERCENT } from '/imports/utils/slideCalcUtils'; @@ -6,6 +7,7 @@ import { SPACE } from '/imports/utils/keyCodes'; import { defineMessages, injectIntl } from 'react-intl'; import Session from '/imports/ui/services/storage/in-memory'; import PresentationToolbarContainer from './presentation-toolbar/container'; +import PresentationMenuContainer from './presentation-menu/container'; import PresentationMenu from './presentation-menu/container'; import DownloadPresentationButton from './download-presentation-button/component'; import Styled from './styles'; @@ -18,9 +20,11 @@ import browserInfo from '/imports/utils/browserInfo'; import { addAlert } from '../screenreader-alert/service'; import { debounce } from '/imports/utils/debounce'; import { throttle } from '/imports/utils/throttle'; +import { originalRAF, originalCAF } from '/imports/utils/animationFrameBackup'; import LocatedErrorBoundary from '/imports/ui/components/common/error-boundary/located-error-boundary/component'; import FallbackView from '/imports/ui/components/common/fallback-errors/fallback-view/component'; import TooltipContainer from '/imports/ui/components/common/tooltip/container'; +import { StyleSheetManager } from 'styled-components'; const intlMessages = defineMessages({ presentationLabel: { @@ -47,6 +51,26 @@ const intlMessages = defineMessages({ id: 'app.presentation.emptySlideContent', description: 'No content available for slide', }, + currentSlide: { + id: 'app.presentation.currentSlide', + description: 'Label for current slide', + }, + nextSlide: { + id: 'app.presentation.nextSlide', + description: 'Label for next slide', + }, + noCurrentSlide: { + id: 'app.presentation.noCurrentSlide', + description: 'Message shown when current slide is unavailable', + }, + noNextSlide: { + id: 'app.presentation.noNextSlide', + description: 'Message shown when next slide is unavailable', + }, + noNotes: { + id: 'app.presentation.noNotes', + description: 'Message shown when slide notes are unavailable', + }, presentationHeader: { id: 'playback.player.presentation.wrapper.aria', description: 'Aria label for header navigation', @@ -58,9 +82,9 @@ const FULLSCREEN_CHANGE_EVENT = isSafari ? 'webkitfullscreenchange' : 'fullscreenchange'; -const getToolbarHeight = () => { +const getToolbarHeight = (doc = document) => { let height = 0; - const toolbarEl = document.getElementById('presentationToolbarWrapper'); + const toolbarEl = doc.getElementById('presentationToolbarWrapper'); if (toolbarEl) { const { clientHeight } = toolbarEl; height = clientHeight; @@ -68,6 +92,101 @@ const getToolbarHeight = () => { return height; }; +const getStyleSheetCssText = (style) => { + try { + const rules = Array.from(style.sheet?.cssRules ?? []); + + if (rules.length > 0) { + return rules.map((rule) => rule.cssText).join('\n'); + } + } catch (error) { + console.warn( + 'Failed to read a DarkReader CSS rule for the presentation popup', + error, + ); + } + + return style.textContent || ''; +}; + +const syncDarkReaderStylesToPopup = (sourceDocument, targetDocument) => { + // Remove the previous DarkReader styles from the popup. + targetDocument + .querySelectorAll( + 'style.darkreader, style[data-popup-darkreader-mirror]', + ) + .forEach((style) => style.remove()); + + // Synchronize DarkReader attributes on . + Array.from(targetDocument.documentElement.attributes) + .filter((attr) => attr.name.startsWith('data-darkreader')) + .forEach((attr) => { + targetDocument.documentElement.removeAttribute(attr.name); + }); + + Array.from(sourceDocument.documentElement.attributes) + .filter((attr) => attr.name.startsWith('data-darkreader')) + .forEach((attr) => { + targetDocument.documentElement.setAttribute( + attr.name, + attr.value, + ); + }); + + const darkReaderStyles = Array.from( + sourceDocument.querySelectorAll('style.darkreader'), + ); + + // DarkReader is disabled. + if (darkReaderStyles.length === 0) return; + + const syncStyles = darkReaderStyles.filter( + (style) => style.classList.contains('darkreader--sync'), + ); + + const nonSyncStyles = darkReaderStyles.filter( + (style) => !style.classList.contains('darkreader--sync'), + ); + + // Copy the non-sync DarkReader styles. + nonSyncStyles.forEach((sourceStyle) => { + const targetStyle = targetDocument.createElement('style'); + + Array.from(sourceStyle.attributes).forEach((attr) => { + targetStyle.setAttribute(attr.name, attr.value); + }); + + targetStyle.textContent = getStyleSheetCssText(sourceStyle); + + targetDocument.head.appendChild(targetStyle); + }); + + // Collect all darkreader--sync rules into one stylesheet. + const syncCssText = syncStyles + .flatMap((style) => { + try { + return Array.from(style.sheet?.cssRules ?? []) + .map((rule) => rule.cssText); + } catch (error) { + console.warn( + 'Failed to read a DarkReader sync rule for the presentation popup', + error, + ); + return []; + } + }) + .join('\n'); + + if (syncCssText === '') return; + + const mirror = targetDocument.createElement('style'); + mirror.setAttribute('data-popup-darkreader-mirror', ''); + mirror.textContent = syncCssText; + + // Keep DarkReader overrides at the end of . + targetDocument.head.appendChild(mirror); +}; + const IGNORE_PRESENTATION_RESTORATION_TIMEOUT = 5000; class Presentation extends PureComponent { @@ -85,6 +204,9 @@ class Presentation extends PureComponent { isToolbarVisible: true, hadPresentation: false, ignorePresentationRestoring: true, + currentSlideNote: '', + presenterView: null, + presenterAnnotations: null, }; const PAN_ZOOM_INTERV = window.meetingClientSettings.public.presentation.panZoomInterval || 200; @@ -102,10 +224,16 @@ class Presentation extends PureComponent { this.setIsToolbarVisible = this.setIsToolbarVisible.bind(this); this.handlePanShortcut = this.handlePanShortcut.bind(this); this.renderPresentationMenu = this.renderPresentationMenu.bind(this); + this.renderPresentationContents = this.renderPresentationContents.bind(this); + this.detachPresentation = this.detachPresentation.bind(this); this.onResize = () => setTimeout(this.handleResize.bind(this), 0); this.setPresentationRef = this.setPresentationRef.bind(this); this.setTldrawIsMounting = this.setTldrawIsMounting.bind(this); + this.loadCurrentSlideNote = this.loadCurrentSlideNote.bind(this); + this.handlePresentationNotesUpdated = this.handlePresentationNotesUpdated.bind(this); + this.handlePresenterViewChange = this.handlePresenterViewChange.bind(this); + this.presenterAnnotationsObjectUrl = null; Session.setItem('componentPresentationWillUnmount', false); } @@ -195,6 +323,13 @@ class Presentation extends PureComponent { }); } + window.addEventListener( + 'presentationNotesUpdated', + this.handlePresentationNotesUpdated, + ); + + this.loadCurrentSlideNote(); + setTimeout(() => { this.setState({ ignorePresentationRestoring: false }); }, IGNORE_PRESENTATION_RESTORATION_TIMEOUT); @@ -209,6 +344,7 @@ class Presentation extends PureComponent { restoreOnUpdate, layoutContextDispatch, userIsPresenter, + isPresentationDetached, presentationBounds, numCameras, intl, @@ -232,7 +368,38 @@ class Presentation extends PureComponent { presentationBounds: prevPresentationBounds, } = prevProps; - if (numCameras !== prevNumCameras) { + const presenterRoleLost = ( + prevProps.userIsPresenter + && !userIsPresenter + ); + + const presenterToolClosed = ( + prevProps.isPresentationDetached + && !isPresentationDetached + ); + + const presenterToolOpened = ( + !prevProps.isPresentationDetached + && isPresentationDetached + ); + + const presenterAnnotationSlideChanged = ( + prevProps.currentSlide?.id !== currentSlide?.id + || prevProps.currentPresentationId !== currentPresentationId + ); + + if ( + presenterToolClosed + || presenterAnnotationSlideChanged + ) { + this.clearPresenterAnnotations(); + } + + if (presenterToolClosed) { + this.setState({ currentSlideNote: '' }); + } + + if (numCameras !== prevNumCameras && !isPresentationDetached) { this.onResize(); } @@ -316,23 +483,50 @@ class Presentation extends PureComponent { }); } + if (presenterRoleLost && isPresentationDetached) { + this.detachPresentation(); + } + if ( (zoom <= HUNDRED_PERCENT && isPanning && !fitToWidth) - || (!userIsPresenter && prevProps.userIsPresenter) + || presenterRoleLost ) { this.setIsPanning(); } - if (!userIsPresenter && prevProps.userIsPresenter && fitToWidth) { + if (presenterRoleLost && fitToWidth) { setPresentationFitToWidth(false); } + + const slideChanged = ( + currentSlide?.svgUri !== prevProps.currentSlide?.svgUri + ); + + if ( + presenterToolOpened + || (isPresentationDetached && slideChanged) + ) { + this.loadCurrentSlideNote(); + } } componentWillUnmount() { Session.setItem('componentPresentationWillUnmount', true); - const { fullscreenContext, layoutContextDispatch } = this.props; + const { + fullscreenContext, + layoutContextDispatch, + isPresentationDetached, + popupWindow, + } = this.props; + + const targetWin = isPresentationDetached && popupWindow ? popupWindow : window; + targetWin.removeEventListener('resize', this.onResize, false); + + window.removeEventListener( + 'presentationNotesUpdated', + this.handlePresentationNotesUpdated, + ); - window.removeEventListener('resize', this.onResize, false); if (this.refPresentationContainer) { this.refPresentationContainer.removeEventListener( FULLSCREEN_CHANGE_EVENT, @@ -348,6 +542,11 @@ class Presentation extends PureComponent { ); } + if (this.presenterAnnotationsObjectUrl) { + URL.revokeObjectURL(this.presenterAnnotationsObjectUrl); + this.presenterAnnotationsObjectUrl = null; + } + if (fullscreenContext) { layoutContextDispatch({ type: ACTIONS.SET_FULLSCREEN_ELEMENT, @@ -359,6 +558,306 @@ class Presentation extends PureComponent { } } + detachPresentation() { + const { + slidePosition, + isPresentationDetached, + popupWindow, + toggleDetachPresentation, + } = this.props; + + + if (!isPresentationDetached) { + // Quit fullscreen first when detach fullscreen presentation + // This will however keep the popup window size same as fullscreen. + if (window.document.fullscreenElement != null) { + this.onFullscreenChange(); + } + + const svgDimensions = this.calculateSize(slidePosition); + const toolbarHeight = getToolbarHeight(); + const popup = window.open('', '_blank', + `innerwidth=${svgDimensions.width},innerheight=${svgDimensions.height + toolbarHeight},resizable,scrollbars`); + if (!popup) return; + popup.document.title = 'BigBlueButton Portal Window'; + // unnecessary div + //const container = popup.document.createElement('div'); + //popup.document.body.appendChild(container); + + // Copying the attributes of , so that the bbb-icons font looks a bit smaller + const mainHtml = document.documentElement; // メインウィンドウの + const popupHtml = popup.document.documentElement; + // class + popupHtml.className = mainHtml.className; + // style, which includes font-size: 14px + popupHtml.style.cssText = mainHtml.style.cssText; + + //// dir + //if (mainHtml.hasAttribute('dir')) { + // popupHtml.setAttribute('dir', mainHtml.getAttribute('dir')); + //} else { + // popupHtml.removeAttribute('dir'); + //} + //// lang + //if (mainHtml.hasAttribute('lang')) { + // popupHtml.setAttribute('lang', mainHtml.getAttribute('lang')); + //} else { + // popupHtml.removeAttribute('lang'); + //} + // Copy all attributes ( extensions including DarkReader, which may not work anyway) + for (const attr of mainHtml.attributes) { + popupHtml.setAttribute(attr.name, attr.value); + } + + // headの中身をコピー + const headElements = document.head.cloneNode(true).childNodes; + headElements.forEach((node) => { + // script要素など重複実行したくないものを除外する + if (node.nodeName !== 'SCRIPT') { + popup.document.head.appendChild(node.cloneNode(true)); + } + }); + + // Firefox specific configuration + const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); + if (isFirefox) { + // Add base URL (perhaps only necessary for Firefox to show tldraw icons + const base = popup.document.createElement('base'); + base.href = window.location.origin + '/'; + popup.document.head.appendChild(base); + + // Explicitely copy bbb-icons.css to show bbb-icons + fetch('stylesheets/bbb-icons.css') + .then(res => res.text()) + .then(css => { + const style = popup.document.createElement('style'); + style.textContent = css; + popup.document.head.appendChild(style); + }); + // Explicitly set FontFace to show bbb-icons + const fonts = [ + { name: 'bbb-icons', url: '/html5client/fonts/BbbIcons/bbb-icons.woff2' }, + ]; + fonts.forEach(({ name, url }) => { + const font = new FontFace(name, `url(${window.location.origin}${url})`); + font.load().then(loaded => popup.document.fonts.add(loaded)); + }); + } + + // 追加: document.styleSheets からすべての stylesheet を popup に複製 + //Array.from(document.styleSheets).forEach((styleSheet) => { + // try { + // if (styleSheet.href) { + // // 形式 + // const link = popup.document.createElement('link'); + // link.rel = 'stylesheet'; + // link.href = styleSheet.href; + // popup.document.head.appendChild(link); + // } else if (styleSheet.cssRules) { + // //