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, } = props; clearTldrawCache(); @@ -179,7 +182,12 @@ const Whiteboard = React.memo((props) => { const [cursorType, setCursorType] = React.useState(''); const [cursorZoom, setCursorZoom] = React.useState({ slideZoom: 1, containerZoom: 1 }); 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} ); + if (isMounting) { setDefaultEditorAssetUrls(getCustomEditorAssetUrls()); setDefaultUiAssetUrls(getCustomAssetUrls()); @@ -219,9 +227,20 @@ 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 laserItems = [ + { key: 'redSmall', label: '🔴', size: 10 }, + { key: 'greenSmall', label: '🟢', size: 10 }, + { key: 'redLarge', label: '🔴', size: 18 }, + { key: 'greenLarge', label: '🟢', size: 18 }, + { key: '', label: '✋', size: 14 }, + ]; + const [pageZoomMap, setPageZoomMap] = useState(() => { try { const saved = localStorage.getItem('pageZoomMap'); @@ -875,6 +894,7 @@ const Whiteboard = React.memo((props) => { const updateCursorPosition = useCursor( publishCursorUpdate, whiteboardIdRef.current, + laserMode, ); const setCamera = (zoom, x = 0, y = 0) => { @@ -1907,6 +1927,67 @@ 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(); + }; + + const c = { + red: 'rgba(255, 20, 20)', + green: 'rgba(20, 255, 20)', + }; + + const laserDefs = { + redSmall: { color: c.red, cx: laserRadiusSmall+2, cy: laserRadiusSmall+2, r: laserRadiusSmall }, + greenSmall: { color: c.green, cx: laserRadiusSmall+2, cy: laserRadiusSmall+2, r: laserRadiusSmall }, + redLarge: { color: c.red, cx: laserRadiusLarge+2, cy: laserRadiusLarge+2, r: laserRadiusLarge }, + greenLarge: { color: c.green, cx: laserRadiusLarge+2, cy: laserRadiusLarge+2, r: laserRadiusLarge }, + }; + + const laserSvgs = Object.fromEntries( + Object.entries(laserDefs).map(([key, def]) => [ + key, + makeLaserSvg(def, key), + ]) + ); + + const svgToCursor = (svg, x, y) => + `url("data:image/svg+xml;utf8,${encodeURIComponent(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, @@ -2103,6 +2184,119 @@ const Whiteboard = React.memo((props) => { } }, [currentPresentationPage, isPresenter]); + React.useEffect(() => { + const targetDoc = document; + 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 () => { + 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 = document; + 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 = document; + + 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 = document; + 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'); @@ -2130,7 +2324,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; @@ -2179,6 +2373,111 @@ const Whiteboard = React.memo((props) => { } }, [otherCursors, whiteboardWriters]); + // Store presenter's cursor position to draw laser for mobile presenter + React.useEffect(() => { + tlEditorRef.current?.store.listen(({ changes }) => { + const p = tlEditorRef.current?.inputs.currentPagePoint; + if (p && tlEditorRef.current) { + const screenPos = tlEditorRef.current.pageToScreen(p); + setPresenterCursorPoint( {x: screenPos.x, y: screenPos.y} ); + } + }) + }, [tlEditorRef.current]); + + const removeViewerLaser = () => { + laserElRef.current = null; + const targetDoc = document; + const lasers = targetDoc.querySelectorAll('.bbb-laser-pointer'); + lasers.forEach(el => el.remove()); + }; + + // Show viewers Laser SVG + React.useEffect(() => { + if (isPresenter) return; + //if (isMultiUserActive) return; + + const targetDoc = document; + + //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]; + + 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 and the default red pointer is visible for viewers + defaultPointer.style.setProperty('display', 'block'); + } else { + // Presenter uses laser pointer and 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 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/zoom}) + `; + return; + }, [otherCursors, isPresenter]); + + React.useEffect(() => { + removeViewerLaser(); + }, [curPageId]); + const updateStore = (pages, cameras) => { tlEditorRef.current.store.put(pages); tlEditorRef.current.store.put(cameras); @@ -2357,6 +2656,49 @@ const Whiteboard = React.memo((props) => { hiddenGeoShapes, }} /> + { (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 && ( + + {laserItems.map(({ key, label, size }) => ( + { + setLaserMode(key); + setLaserMenuVisible(false); + }} + > + {label} + + ))} + + )}
); }); @@ -2395,6 +2737,8 @@ Whiteboard.propTypes = { presentationAreaWidth: PropTypes.number.isRequired, maxNumberOfAnnotations: PropTypes.number.isRequired, pointerDiameter: PropTypes.number, + laserRadiusSmall: PropTypes.number.isRequired, + laserRadiusLarge: PropTypes.number.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 30f7a64098f8..cc06fa90ce80 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 = [{ @@ -461,7 +462,7 @@ 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 } = WHITEBOARD_CONFIG; const fontFamily = WHITEBOARD_CONFIG.styles.text.family; const { colorStyle, dashStyle, fillStyle, fontStyle, sizeStyle, @@ -508,6 +509,8 @@ const WhiteboardContainer = (props) => { maxNumberOfAnnotations, lockToolbarTools, pointerDiameter, + laserRadiusSmall, + laserRadiusLarge, fontFamily, colorStyle, dashStyle, @@ -533,6 +536,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 e90cbf85ae2b..3aba23fbc942 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, laserMode) => { const publishRef = React.useRef(publishCursorUpdate); const whiteboardIdRef = React.useRef(whiteboardId); + const laserModeRef = React.useRef(laserMode); const pendingRef = React.useRef(null); const rafRef = React.useRef(null); useEffect(() => { publishRef.current = publishCursorUpdate; }, [publishCursorUpdate]); useEffect(() => { whiteboardIdRef.current = whiteboardId; }, [whiteboardId]); + useEffect(() => { laserModeRef.current = laserMode; }, [laserMode]); useEffect(() => () => { if (rafRef.current) { @@ -24,6 +26,7 @@ const useCursor = (publishCursorUpdate, whiteboardId) => { publishRef.current({ whiteboardId: whiteboardIdRef.current, ...pendingRef.current, + laserType: laserModeRef.current, }); pendingRef.current = null; } @@ -40,6 +43,7 @@ const useCursor = (publishCursorUpdate, whiteboardId) => { publishRef.current({ whiteboardId: whiteboardIdRef.current, ...pendingRef.current, + laserType: laserModeRef.current, }); pendingRef.current = null; } diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.ts b/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.ts index b76ef2b6b16b..4f35590e09b1 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.ts +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/hooks.ts @@ -86,6 +86,7 @@ export const useMergedCursorData = () => { return { ...coordinates, ...cursor, + //laserType: coordinates.laserType, }; } 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 88e064c4b8ed..14d013b3014a 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js @@ -264,7 +264,31 @@ 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` + padding: 6px 10px; + cursor: pointer; + border-radius: 4px; + text-align: 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 4a7455a54fc4..391568993062 100644 --- a/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts +++ b/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts @@ -815,6 +815,8 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = { annotationsQueueProcessInterval: 60, cursorInterval: 100, pointerDiameter: 5, + laserRadiusSmall: 10, + laserRadiusLarge: 16, maxStickyNoteLength: 1000, maxNumberOfAnnotations: 300, maxNumberOfActiveUsers: 25, diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 8213f0075074..1c36f6a807b5 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -1090,6 +1090,8 @@ public: allowInfiniteWhiteboard: false allowInfiniteWhiteboardInBreakouts: false pointerDiameter: 5 + laserRadiusSmall: 10 + laserRadiusLarge: 16 maxStickyNoteLength: 1000 # limit number of annotations per slide maxNumberOfAnnotations: 300