diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/whiteboard/SendCursorPositionPubMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/whiteboard/SendCursorPositionPubMsgHdlr.scala index 373b5926112c..69dfd8211e51 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/whiteboard/SendCursorPositionPubMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/whiteboard/SendCursorPositionPubMsgHdlr.scala @@ -17,7 +17,7 @@ trait SendCursorPositionPubMsgHdlr extends RightsManagementTrait { val envelope = BbbCoreEnvelope(SendCursorPositionEvtMsg.NAME, routing) val header = BbbClientMsgHeader(SendCursorPositionEvtMsg.NAME, liveMeeting.props.meetingProp.intId, msg.header.userId) - val body = SendCursorPositionEvtMsgBody(msg.body.whiteboardId, userIsViewer, msg.body.xPercent, msg.body.yPercent) + val body = SendCursorPositionEvtMsgBody(msg.body.whiteboardId, userIsViewer, msg.body.xPercent, msg.body.yPercent, msg.body.laserType) val event = SendCursorPositionEvtMsg(header, body) val msgEvent = BbbCommonEnvCoreMsg(envelope, event) bus.outGW.send(msgEvent) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PresPageCursorDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PresPageCursorDAO.scala index 2a6a19a03397..43fbf990d386 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PresPageCursorDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PresPageCursorDAO.scala @@ -8,24 +8,26 @@ case class PresPageCursorDbModel( userId: String, xPercent: Double, yPercent: Double, + laserType: String, lastUpdatedAt: java.sql.Timestamp = new java.sql.Timestamp(System.currentTimeMillis()) ) class PresPageCursorDbTableDef(tag: Tag) extends Table[PresPageCursorDbModel](tag, None, "pres_page_cursor") { override def * = ( - pageId, meetingId, userId, xPercent, yPercent, lastUpdatedAt + pageId, meetingId, userId, xPercent, yPercent, laserType, lastUpdatedAt ) <> (PresPageCursorDbModel.tupled, PresPageCursorDbModel.unapply) val pageId = column[String]("pageId", O.PrimaryKey) val meetingId = column[String]("meetingId", O.PrimaryKey) val userId = column[String]("userId", O.PrimaryKey) val xPercent = column[Double]("xPercent") val yPercent = column[Double]("yPercent") + val laserType = column[String]("laserType") val lastUpdatedAt = column[java.sql.Timestamp]("lastUpdatedAt") } object PresPageCursorDAO { - def insertOrUpdate(pageId: String, meetingId: String, userId: String, xPercent: Double, yPercent: Double) = { + def insertOrUpdate(pageId: String, meetingId: String, userId: String, xPercent: Double, yPercent: Double, laserType: String) = { DatabaseConnection.enqueue( TableQuery[PresPageCursorDbTableDef].insertOrUpdate( PresPageCursorDbModel( @@ -34,6 +36,7 @@ object PresPageCursorDAO { userId = userId, xPercent = xPercent, yPercent = yPercent, + laserType = laserType, lastUpdatedAt = new java.sql.Timestamp(System.currentTimeMillis()), ) ) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/WhiteboardCursorMoveRecordEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/WhiteboardCursorMoveRecordEvent.scala index 1dc4dc7fe095..94bab50c88d6 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/WhiteboardCursorMoveRecordEvent.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/WhiteboardCursorMoveRecordEvent.scala @@ -35,10 +35,15 @@ class WhiteboardCursorMoveRecordEvent extends AbstractWhiteboardRecordEvent { def setYPercent(percent: Double) { eventMap.put(Y_OFFSET, percent.toString) } + + def setLaserType(laserType: String) { + eventMap.put(LASER_TYPE, laserType) + } } object WhiteboardCursorMoveRecordEvent { protected final val USER_ID = "userId" protected final val X_OFFSET = "xOffset" protected final val Y_OFFSET = "yOffset" -} \ No newline at end of file + protected final val LASER_TYPE = "laserType" +} diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala index 63e238880c62..a64f2bca765e 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala @@ -377,6 +377,7 @@ class RedisRecorderActor( ev.setUserId(msg.header.userId) ev.setXPercent(msg.body.xPercent) ev.setYPercent(msg.body.yPercent) + ev.setLaserType(msg.body.laserType) record(msg.header.meetingId, ev.toMap.asJava) } diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala index 107c04640207..0d75e73adead 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala @@ -40,7 +40,7 @@ case class GetWhiteboardAnnotationsReqMsgBody(whiteboardId: String) object SendCursorPositionPubMsg { val NAME = "SendCursorPositionPubMsg" } case class SendCursorPositionPubMsg(header: BbbClientMsgHeader, body: SendCursorPositionPubMsgBody) extends StandardMsg -case class SendCursorPositionPubMsgBody(whiteboardId: String, xPercent: Double, yPercent: Double) +case class SendCursorPositionPubMsgBody(whiteboardId: String, xPercent: Double, yPercent: Double, laserType: String) object SendWhiteboardAnnotationsPubMsg { val NAME = "SendWhiteboardAnnotationsPubMsg" } case class SendWhiteboardAnnotationsPubMsg(header: BbbClientMsgHeader, body: SendWhiteboardAnnotationsPubMsgBody) extends StandardMsg @@ -70,7 +70,7 @@ case class GetWhiteboardAnnotationsRespMsgBody(whiteboardId: String, annotations object SendCursorPositionEvtMsg { val NAME = "SendCursorPositionEvtMsg" } case class SendCursorPositionEvtMsg(header: BbbClientMsgHeader, body: SendCursorPositionEvtMsgBody) extends BbbCoreMsg -case class SendCursorPositionEvtMsgBody(whiteboardId: String, userIsViewer: Boolean, xPercent: Double, yPercent: Double) +case class SendCursorPositionEvtMsgBody(whiteboardId: String, userIsViewer: Boolean, xPercent: Double, yPercent: Double, laserType: String) object SendWhiteboardAnnotationsEvtMsg { val NAME = "SendWhiteboardAnnotationsEvtMsg" } case class SendWhiteboardAnnotationsEvtMsg(header: BbbClientMsgHeader, body: SendWhiteboardAnnotationsEvtMsgBody) extends BbbCoreMsg diff --git a/bbb-graphql-actions/src/actions/presentationPublishCursor.ts b/bbb-graphql-actions/src/actions/presentationPublishCursor.ts index e8a07cf5223b..1c468717c30e 100644 --- a/bbb-graphql-actions/src/actions/presentationPublishCursor.ts +++ b/bbb-graphql-actions/src/actions/presentationPublishCursor.ts @@ -7,6 +7,7 @@ export default function buildRedisMessage(sessionVariables: Record { isInfiniteWhiteboard, whiteboardWriters, isPhone, + isMobile, setEditor, lockToolbarTools, layoutChanged, pointerDiameter = 5, + laserRadiusSmall, + laserRadiusLarge, + laserColors, } = props; const allowInfiniteWhiteboardPanForViewers = window.meetingClientSettings?.public?.whiteboard?.allowInfiniteWhiteboardPanForViewers; @@ -204,7 +209,13 @@ const Whiteboard = React.memo((props) => { // the throw happen during render, where the boundary sees it - a bare .catch could not. const [, setSwapError] = React.useState(); const updateCursorZoomRef = React.useRef(null); - + const [laserMenuVisible, setLaserMenuVisible] = React.useState(false); + const [laserMenuPos, setLaserMenuPos] = React.useState({ x: 0, y: 0 }); + const laserMenuRef = React.useRef(null); + const [laserMode, setLaserMode] = React.useState(''); + const [presenterCursorPoint, setPresenterCursorPoint] = React.useState( { x: -1, y: -1} ); + const [viewerLaserZoom, setViewerLaserZoom] = React.useState(1); + if (isMounting) { setDefaultEditorAssetUrls(getCustomEditorAssetUrls()); setDefaultUiAssetUrls(getCustomAssetUrls()); @@ -246,9 +257,16 @@ const Whiteboard = React.memo((props) => { const hasZoomSyncedRef = useRef(false); const lastForcedViewRef = useRef(null); const currentUserRef = useRef(currentUser); + const currentLaserTypeRef = React.useRef(null); + const laserLayerRef = React.useRef(null); + const laserElRef = React.useRef(null); currentUserRef.current = currentUser; + const getWhiteboardDocument = () => ( + whiteboardRef.current?.ownerDocument || document + ); + const [pageZoomMap, setPageZoomMap] = useState(() => { try { const saved = localStorage.getItem('pageZoomMap'); @@ -924,9 +942,15 @@ const Whiteboard = React.memo((props) => { const language = React.useMemo(() => mapLanguage(locale?.toLowerCase() || 'en'), [locale]); + const getLaserType = React.useCallback(() => { + const tool = tlEditorRef.current?.getCurrentToolId?.(); + return tool === 'hand' ? laserMode : ''; + }, [laserMode]); + const updateCursorPosition = useCursor( publishCursorUpdate, whiteboardIdRef.current, + getLaserType, ); const setCamera = (zoom, x = 0, y = 0) => { @@ -1976,6 +2000,103 @@ const Whiteboard = React.memo((props) => { } }; + const makeLaserSvg = ({ color, cx, cy, r }, id) => { + const width = cx * 2; + const height = cy * 2; + + // On Windows and Linux, it darkens towards the edge + /* + return ` + + + + + + + + + + + `.replace(/\s+/g, ' ').trim(); + */ + return ` + + + + + + + + + + + + + + + + + + + + + + + `.replace(/\s+/g, ' ').trim(); + }; + + const laserSizes = [ + ['Small', laserRadiusSmall], + ['Large', laserRadiusLarge], + ]; + + const laserDefs = Object.fromEntries( + laserSizes.flatMap(([sizeName, radius]) => ( + laserColors.map((color, index) => [ + `color${index + 1}${sizeName}`, + { + color, + cx: radius + 2, + cy: radius + 2, + r: radius, + }, + ]) + )), + ); + + const laserSvgs = Object.fromEntries( + Object.entries(laserDefs).map(([key, def]) => [ + key, + makeLaserSvg(def, key), + ]) + ); + + const svgToDataUrl = (svg) => + `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; + + const svgToCursor = (svg, x, y) => + `url("${svgToDataUrl(svg)}") ${x} ${y}, auto`; + + const cursorLasers = Object.fromEntries( + Object.entries(laserDefs).map(([key, def]) => [ + key, + svgToCursor(laserSvgs[key], def.cx, def.cy), + ]) + ); + + const createLaserElement = (svgString, targetDoc) => { + const wrapper = targetDoc.createElement('div'); + wrapper.className = 'custom-laser'; + wrapper.innerHTML = svgString; + const el = wrapper.firstChild; + Object.assign(el.style, { + position: 'absolute', + pointerEvents: 'none', + overflow: 'visible', + }); + return el; + }; + useMouseEvents( { whiteboardRef, tlEditorRef, isWheelZoomRef, initialZoomRef, isPresenterRef, @@ -2181,6 +2302,120 @@ const Whiteboard = React.memo((props) => { } }, [currentPresentationPage, isPresenter, viewerCanPan]); + React.useEffect(() => { + const targetDoc = getWhiteboardDocument(); + const presentationWrapper = targetDoc.querySelector('#presentationInnerWrapper'); + if (!presentationWrapper) return; + if (!isPresenter) return; + + const handleContextMenu = (e) => { + const tool = tlEditorRef.current?.getCurrentToolId?.(); + if (tool !== 'hand') return; + if (!presentationWrapper.contains(e.target)) return; + + e.preventDefault(); + e.stopPropagation(); + + setLaserMenuPos({ x: e.clientX, y: e.clientY }); + setLaserMenuVisible(true); + }; + + let timer = null; + + const handleTouchStart = (e) => { + const tool = tlEditorRef.current?.getCurrentToolId?.(); + if (tool !== 'hand') return; + if (!presentationWrapper.contains(e.target)) return; + + const touch = e.touches[0]; + + timer = setTimeout(() => { + setLaserMenuPos({ + x: touch.clientX, + y: touch.clientY, + }); + setLaserMenuVisible(true); + }, 500); + }; + + const cancel = () => { + clearTimeout(timer); + }; + + presentationWrapper.addEventListener('contextmenu', handleContextMenu, true); + presentationWrapper.addEventListener('touchstart', handleTouchStart, true); + presentationWrapper.addEventListener('touchend', cancel, true); + presentationWrapper.addEventListener('touchmove', cancel, true); + + return () => { + clearTimeout(timer); + presentationWrapper.removeEventListener('contextmenu', handleContextMenu, true); + presentationWrapper.removeEventListener('touchstart', handleTouchStart, true); + presentationWrapper.removeEventListener('touchend', cancel, true); + presentationWrapper.removeEventListener('touchmove', cancel, true); + }; + }, [isPresenter]); + + React.useEffect(() => { + if (!laserMenuVisible) return; + + const targetDoc = getWhiteboardDocument(); + const presentationWrapper = targetDoc.querySelector('#presentationInnerWrapper'); + if (!presentationWrapper) return; + + const handleOutsideClick = (e) => { + if (laserMenuRef.current?.contains(e.target)) return; + setLaserMenuVisible(false); + }; + + presentationWrapper.addEventListener('pointerdown', handleOutsideClick, true); + + return () => { + presentationWrapper.removeEventListener('pointerdown', handleOutsideClick, true); + }; + }, [laserMenuVisible]); + + React.useEffect(() => { + // compensation at the window edge + if (!laserMenuVisible) return; + const targetDoc = getWhiteboardDocument(); + + const el = laserMenuRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const vw = targetDoc.defaultView.innerWidth; + const vh = targetDoc.defaultView.innerHeight; + + let x = laserMenuPos.x; + let y = laserMenuPos.y; + + if (rect.right > vw) x = vw - rect.width - 8; + if (rect.bottom > vh) y = vh - rect.height - 50; + + if (x !== laserMenuPos.x || y !== laserMenuPos.y) { + setLaserMenuPos({ x, y }); + } + }, [laserMenuVisible]); + + React.useEffect(() => { + const targetDoc = getWhiteboardDocument(); + if (!isPresenter) return; + const el = targetDoc.querySelector('.tl-container'); + if (!el) return; + + removeViewerLaser(); + + const laser = cursorLasers[laserMode]; + if (laser) { + el.style.setProperty('--tl-cursor-grab', laser); + el.style.setProperty('--tl-cursor-grabbing', laser); + } else { + el.style.removeProperty('--tl-cursor-grab'); + el.style.removeProperty('--tl-cursor-grabbing'); + } + }, [laserMode, isPresenter]); + React.useEffect(() => { if (tlEditorRef.current) { const useElement = document.querySelector('.tl-cursor use'); @@ -2208,7 +2443,7 @@ const Whiteboard = React.memo((props) => { const updatedPresences = otherCursors .map(({ - userId, xPercent, yPercent, presenter, name, isModerator, + userId, xPercent, yPercent, laserType, presenter, name, isModerator, }) => { const id = InstancePresenceRecordType.createId(userId); const active = xPercent !== -1 && yPercent !== -1; @@ -2257,6 +2492,155 @@ const Whiteboard = React.memo((props) => { } }, [otherCursors, whiteboardWriters]); + // Store presenter's cursor position to draw laser for mobile presenter + React.useEffect(() => { + const editor = tlEditorRef.current; + if (!editor) return undefined; + + const unlisten = editor.store.listen(() => { + const p = editor.inputs.currentPagePoint; + if (!p) return; + const screenPos = editor.pageToScreen(p); + setPresenterCursorPoint({ x: screenPos.x, y: screenPos.y }); + }); + + return () => { + unlisten?.(); + }; + }, [tlEditorRef.current]); + + const removeViewerLaser = () => { + laserElRef.current = null; + const targetDoc = getWhiteboardDocument(); + const lasers = targetDoc.querySelectorAll('.bbb-laser-pointer'); + lasers.forEach(el => el.remove()); + }; + + React.useEffect(() => { + if (isPresenter) return undefined; + + const editor = tlEditorRef.current; + if (!editor) return undefined; + + let previousZoom = editor.getCamera().z; + setViewerLaserZoom(previousZoom); + + const unlisten = editor.store.listen(() => { + const zoom = editor.getCamera().z; + + if (zoom === previousZoom) return; + + previousZoom = zoom; + setViewerLaserZoom(zoom); + }); + + return () => { + unlisten?.(); + }; + }, [isPresenter, tlEditorRef.current]); + + // Show viewers Laser SVG + React.useEffect(() => { + if (isPresenter) return; + //if (isMultiUserActive) return; + + const targetDoc = getWhiteboardDocument(); + + //Comment out below if we do not want to show laser when a presenter uses drawing tools on mobile devices. + // Note a problem that the laser remains on the screen after switching to drawing tools. + //const tool = tlEditorRef.current?.getCurrentToolId?.(); + //if (isPresenter && isMobile && tool !== 'hand') return; + + const tlContainer = targetDoc.querySelector('.tl-container'); + + let layer = laserLayerRef.current; + if (!layer || !targetDoc.contains(layer)) { + layer = targetDoc.querySelector('.tl-overlays > .tl-html-layer'); + laserLayerRef.current = layer; + } + + let laserEl = laserElRef.current; + + const presenterCursor = otherCursors.find(c => c.presenter); + if (!presenterCursor) return; + + const laserKey = presenterCursor?.laserType; + const laserDef = laserDefs[laserKey]; + + // Hide + if (laserDef) { + tlContainer?.classList.add('bbb-laser-active'); + } else { + tlContainer?.classList.remove('bbb-laser-active'); + } + + const changed = laserKey !== currentLaserTypeRef.current; + if (changed) { + currentLaserTypeRef.current = laserKey; + laserEl?.remove(); + laserEl = null; + laserElRef.current = null; + const defaultPointer = document.getElementById('redPointer'); + if (!laserDef) { + // Presenter uses hand tool, so the default red pointer is visible for viewers + defaultPointer?.style.setProperty('display', 'block'); + } else { + // Presenter uses laser pointer, so the default red pointer is invisible for viewers + defaultPointer?.style.setProperty('display', 'none'); + } + } + + if (!layer) return; + if (!laserDef) return; // meaning that hand tool is selected, so we move forward to draw laser pointer + + if (!laserEl && layer) { + laserEl = createLaserElement(laserSvgs[laserKey], targetDoc); + layer.appendChild(laserEl); + laserElRef.current = laserEl; + } + + // Now we place the laser SVG at the position of redPointer, which is invisible. + + //const cursorEl = document.querySelector('.tl-collaborator__cursor'); + //if (!cursorEl) return; + + //const zoom = parseFloat(getComputedStyle(tlContainer).getPropertyValue('--tl-zoom')) || 1; + //const { z: zoom } = tlEditorRef.current ? tlEditorRef.current.getCamera() : 1; + //const zoom = tlEditorRef.current?.getCamera()?.z ?? 1; + + //const transform = cursorEl.style.transform; + //if (!transform) return; + //const match = transform.match(/translate\(([^,]+)px,\s*([^)]+)px\)/); + //if (!match) return; + //const x = parseFloat(match[1]); + //const y = parseFloat(match[2]); + const x = presenterCursor.xPercent; + const y = presenterCursor.yPercent; + if (x === -1 || y === -1) { + removeViewerLaser(); + return; + } + + // Keep cursor size regardless of the slide zoom or window size change, + // similar to the pointer of the presenter (CSS-based) or the one in the real world. + laserEl.style.transform = ` + translate(${x - laserDef.cx}px, ${y - laserDef.cy}px) + scale(${1/viewerLaserZoom}) + `; + return; + }, [otherCursors, isPresenter, viewerLaserZoom]); + + React.useEffect(() => { + removeViewerLaser(); + }, [curPageId]); + + const updateStore = (pages, cameras) => { + tlEditorRef.current.store.put(pages); + tlEditorRef.current.store.put(cameras); + tlEditorRef.current.store.put(assets); + tlEditorRef.current.store.put(bgShape); + }; + const finalizeStore = () => { tlEditorRef.current.history.clear(); }; @@ -2544,6 +2928,63 @@ const Whiteboard = React.memo((props) => { viewerCanPan, }} /> + { (isPresenter && isMobile) && (() => { + const svg = laserSvgs[laserMode]; + if (!svg) return null; + const tool = tlEditorRef.current?.getCurrentToolId?.(); + if (tool !== 'hand') return; + const svgMobilePresenter = svg.replace( + 'bbb-laser-pointer', + 'bbb-laser-pointer-mobile-presenter' + ); + return ( +
+ ); + })()} + {laserMenuVisible && ( + + {Object.entries(laserDefs).map(([key, def]) => ( + { + setLaserMode(key); + setLaserMenuVisible(false); + }} + > + + + ))} + { + setLaserMode(''); + setLaserMenuVisible(false); + }} + > + + + + )}
); }); @@ -2553,6 +2994,7 @@ export default Whiteboard; Whiteboard.propTypes = { isPresenter: PropTypes.bool, isPhone: PropTypes.bool, + isMobile: PropTypes.bool, removeShapes: PropTypes.func.isRequired, persistShapeWrapper: PropTypes.func.isRequired, notifyNotAllowedChange: PropTypes.func.isRequired, @@ -2582,6 +3024,9 @@ Whiteboard.propTypes = { presentationAreaWidth: PropTypes.number.isRequired, maxNumberOfAnnotations: PropTypes.number.isRequired, pointerDiameter: PropTypes.number, + laserRadiusSmall: PropTypes.number.isRequired, + laserRadiusLarge: PropTypes.number.isRequired, + laserColors: PropTypes.arrayOf(PropTypes.string).isRequired, setTldrawIsMounting: PropTypes.func.isRequired, presentationId: PropTypes.string, setTldrawAPI: PropTypes.func.isRequired, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index 60eddb91fd57..14542a7e0f0b 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -231,7 +231,7 @@ const WhiteboardContainer = (props) => { }; const publishCursorUpdate = useCallback((payload) => { - const { whiteboardId, xPercent, yPercent } = payload; + const { whiteboardId, xPercent, yPercent, laserType } = payload; if (!whiteboardId || xPercent == null || yPercent == null || !(hasWBAccess || isPresenter)) return; presentationPublishCursor({ @@ -239,6 +239,7 @@ const WhiteboardContainer = (props) => { whiteboardId, xPercent, yPercent, + laserType, }, }); }, [hasWBAccess, isPresenter]); @@ -433,7 +434,7 @@ const WhiteboardContainer = (props) => { const bgShape = []; - const { isIphone, isPhone } = deviceInfo; + const { isIphone, isPhone, isMobile } = deviceInfo; const assetId = AssetRecordType.createId(curPageNum); const assets = [{ @@ -462,7 +463,9 @@ const WhiteboardContainer = (props) => { const sidebarNavigationWidth = layoutSelect( (i) => i?.output?.sidebarNavigation?.width, ); - const { maxStickyNoteLength, maxNumberOfAnnotations, lockToolbarTools, pointerDiameter } = WHITEBOARD_CONFIG; + const { maxStickyNoteLength, maxNumberOfAnnotations, lockToolbarTools, pointerDiameter, + laserRadiusSmall, laserRadiusLarge, laserColors, + } = WHITEBOARD_CONFIG; const fontFamily = WHITEBOARD_CONFIG.styles.text.family; const { colorStyle, dashStyle, fillStyle, fontStyle, sizeStyle, @@ -509,6 +512,9 @@ const WhiteboardContainer = (props) => { maxNumberOfAnnotations, lockToolbarTools, pointerDiameter, + laserRadiusSmall, + laserRadiusLarge, + laserColors, fontFamily, colorStyle, dashStyle, @@ -534,6 +540,7 @@ const WhiteboardContainer = (props) => { toggleToolsAnimations, isIphone, isPhone, + isMobile, currentPresentationPage, numberOfPages: currentPresentationPage?.totalPages, presentationId, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.js b/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.js index f91c9ad00cd6..dbf6edcd56cf 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.js @@ -7,14 +7,16 @@ const hasBackgroundImageUrl = (el) => { return bg.includes('url('); }; -const useCursor = (publishCursorUpdate, whiteboardId) => { +const useCursor = (publishCursorUpdate, whiteboardId, getLaserType) => { const publishRef = React.useRef(publishCursorUpdate); const whiteboardIdRef = React.useRef(whiteboardId); + const getLaserTypeRef = React.useRef(getLaserType); const pendingRef = React.useRef(null); const rafRef = React.useRef(null); useEffect(() => { publishRef.current = publishCursorUpdate; }, [publishCursorUpdate]); useEffect(() => { whiteboardIdRef.current = whiteboardId; }, [whiteboardId]); + useEffect(() => { getLaserTypeRef.current = getLaserType; }, [getLaserType]); useEffect(() => () => { if (rafRef.current) { @@ -24,6 +26,7 @@ const useCursor = (publishCursorUpdate, whiteboardId) => { publishRef.current({ whiteboardId: whiteboardIdRef.current, ...pendingRef.current, + laserType: getLaserTypeRef.current?.() ?? '' }); pendingRef.current = null; } @@ -40,6 +43,7 @@ const useCursor = (publishCursorUpdate, whiteboardId) => { publishRef.current({ whiteboardId: whiteboardIdRef.current, ...pendingRef.current, + laserType: getLaserTypeRef.current?.() ?? '' }); pendingRef.current = null; } diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/queries.ts b/bigbluebutton-html5/imports/ui/components/whiteboard/queries.ts index c9d0042bb5e8..8593a07aa084 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/queries.ts +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/queries.ts @@ -4,6 +4,7 @@ import { gql } from '@apollo/client'; export interface CursorCoordinates { xPercent: number; yPercent: number; + laserType: string; userId: string; } @@ -220,6 +221,7 @@ export const CURRENT_PAGE_CURSORS_COORDINATES_STREAM = gql` batch_size: 100) { xPercent yPercent + laserType userId } } diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js index e1fa6d8af219..3ac5ddf93553 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js @@ -191,6 +191,12 @@ const TldrawV2GlobalStyle = createGlobalStyle` display: flex; } + .tl-container.bbb-laser-active + .tl-collaborator__cursor-hint + use[href='#cursor_hint'][color='#FF0000'] { + display: none; + } + ${({ presentationHeight }) => { const minRange = { height: 345, top: 14 }; const maxRange = { height: 1200, top: 384 }; @@ -264,7 +270,34 @@ const EditableWBWrapper = styled.div` } `; +const LaserContextMenu = styled.div` + position: fixed !important; + height: auto !important; + background: #1e1e1e; + color: #fff; + padding: 6px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 99999; +`; + +const LaserMenuItem = styled.div` + width: 36px; + height: 36px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: #333; + } +`; + export default { TldrawV2GlobalStyle, EditableWBWrapper, + LaserContextMenu, + LaserMenuItem, }; diff --git a/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts b/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts index 0bfcb199a201..f1d71d4e043c 100644 --- a/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts +++ b/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts @@ -820,6 +820,12 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = { annotationsQueueProcessInterval: 60, cursorInterval: 100, pointerDiameter: 5, + laserRadiusSmall: 10, + laserRadiusLarge: 16, + laserColors: [ + '#ff1414', + '#00a080', + ], maxStickyNoteLength: 1000, maxNumberOfAnnotations: 300, maxNumberOfActiveUsers: 25, diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 1adb2f961e1e..a1ea19285c29 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -1120,6 +1120,11 @@ public: allowInfiniteWhiteboardInBreakouts: false allowInfiniteWhiteboardPanForViewers: false pointerDiameter: 5 + laserRadiusSmall: 10 + laserRadiusLarge: 16 + laserColors: + - '#ff1414' + - '#00a080' maxStickyNoteLength: 1000 # limit number of annotations per slide maxNumberOfAnnotations: 300