import * as React from 'react'; import PropTypes from 'prop-types'; import { TldrawApp, Tldraw } from '@tldraw/tldraw'; import SlideCalcUtil, { HUNDRED_PERCENT, MAX_PERCENT } from '/imports/utils/slideCalcUtils'; // eslint-disable-next-line import/no-extraneous-dependencies import { Utils } from '@tldraw/core'; import Cursors from './cursors/container'; import Settings from '/imports/ui/services/settings'; import logger from '/imports/startup/client/logger'; import KEY_CODES from '/imports/utils/keyCodes'; import { presentationMenuHeight, styleMenuOffset, styleMenuOffsetSmall } from '/imports/ui/stylesheets/styled-components/general'; import Styled from './styles'; import PanToolInjector from './pan-tool-injector/component'; import { findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, usePrevious, } from './utils'; import { isEqual } from 'radash'; const SMALL_HEIGHT = 435; const SMALLEST_DOCK_HEIGHT = 475; const SMALL_WIDTH = 800; const SMALLEST_DOCK_WIDTH = 710; const TOOLBAR_SMALL = 28; const TOOLBAR_LARGE = 32; const MOUNTED_RESIZE_DELAY = 1500; export default function Whiteboard(props) { const { isPresenter, removeShapes, initDefaultPages, persistShape, shapes, assets, currentUser, curPres, whiteboardId, podId, zoomSlide, skipToSlide, slidePosition, curPageId, presentationWidth, presentationHeight, isViewersCursorLocked, zoomChanger, isMultiUserActive, isRTL, fitToWidth, zoomValue, intl, svgUri, maxStickyNoteLength, fontFamily, hasShapeAccess, presentationAreaHeight, presentationAreaWidth, maxNumberOfAnnotations, notifyShapeNumberExceeded, darkTheme, isPanning: shortcutPanning, setTldrawIsMounting, width, height, hasMultiUserAccess, tldrawAPI, setTldrawAPI, whiteboardToolbarAutoHide, toggleToolsAnimations, isIphone, sidebarNavigationWidth, animations, isToolbarVisible, isModerator, fullscreenRef, fullscreenElementId, layoutContextDispatch, } = props; const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1); const rDocument = React.useRef({ name: 'test', version: TldrawApp.version, id: whiteboardId, pages, pageStates, bindings: {}, assets: {}, }); const [history, setHistory] = React.useState(null); const [zoom, setZoom] = React.useState(HUNDRED_PERCENT); const [tldrawZoom, setTldrawZoom] = React.useState(1); const zoomValueRef = React.useRef(zoomValue); const [isMounting, setIsMounting] = React.useState(true); const prevShapes = usePrevious(shapes); const prevSlidePosition = usePrevious(slidePosition); const prevFitToWidth = usePrevious(fitToWidth); const prevSvgUri = usePrevious(svgUri); const prevPageId = usePrevious(curPageId); const language = mapLanguage(Settings?.application?.locale?.toLowerCase() || 'en'); const [currentTool, setCurrentTool] = React.useState(null); const [currentStyle, setCurrentStyle] = React.useState({}); const [isMoving, setIsMoving] = React.useState(false); const [isPanning, setIsPanning] = React.useState(shortcutPanning); const [panSelected, setPanSelected] = React.useState(isPanning); const isMountedRef = React.useRef(true); const [isToolLocked, setIsToolLocked] = React.useState(tldrawAPI?.appState?.isToolLocked); const [bgShape, setBgShape] = React.useState(null); // eslint-disable-next-line arrow-body-style React.useEffect(() => { return () => { isMountedRef.current = false; }; }, []); React.useEffect(() => { zoomValueRef.current = zoomValue; }, [zoomValue]); const setSafeTLDrawAPI = (api) => { if (isMountedRef.current) { setTldrawAPI(api); } }; const toggleOffCheck = (evt) => { const clickedElement = evt.target; const panBtnClicked = clickedElement?.getAttribute('data-test') === 'panButton' || clickedElement?.parentElement?.getAttribute('data-test') === 'panButton'; setIsToolLocked(false); const panButton = document.querySelector('[data-test="panButton"]'); if (panBtnClicked) { const dataZoom = panButton.getAttribute('data-zoom'); if ((dataZoom <= HUNDRED_PERCENT && !fitToWidth)) { return; } panButton.classList.add('select'); panButton.classList.remove('selectOverride'); } else { setIsPanning(false); setPanSelected(false); panButton.classList.add('selectOverride'); panButton.classList.remove('select'); } }; const setDockPosition = (setSetting) => { if (hasWBAccess || isPresenter) { if (((height < SMALLEST_DOCK_HEIGHT) || (width < SMALLEST_DOCK_WIDTH))) { setSetting('dockPosition', 'bottom'); } else { setSetting('dockPosition', isRTL ? 'left' : 'right'); } } } React.useEffect(() => { const toolbar = document.getElementById('TD-PrimaryTools'); const handleClick = (evt) => { toggleOffCheck(evt); }; const handleDBClick = (evt) => { evt.preventDefault(); evt.stopPropagation(); setIsToolLocked(true); tldrawAPI?.patchState( { appState: { isToolLocked: true, }, }, ); }; toolbar?.addEventListener('click', handleClick); toolbar?.addEventListener('dblclick', handleDBClick); return () => { toolbar?.removeEventListener('click', handleClick); toolbar?.removeEventListener('dblclick', handleDBClick); }; }, [tldrawAPI, isToolLocked]); React.useEffect(() => { if (whiteboardToolbarAutoHide) { toggleToolsAnimations('fade-in', 'fade-out', animations ? '3s' : '0s'); } else { toggleToolsAnimations('fade-out', 'fade-in', animations ? '.3s' : '0s'); } }, [whiteboardToolbarAutoHide]); const calculateZoom = (localWidth, localHeight) => { const calcedZoom = fitToWidth ? (presentationWidth / localWidth) : Math.min( (presentationWidth) / localWidth, (presentationHeight) / localHeight, ); return (calcedZoom === 0 || calcedZoom === Infinity) ? HUNDRED_PERCENT : calcedZoom; }; React.useEffect(() => { setTldrawIsMounting(true); }, []); const checkClientBounds = (e) => { if ( e.clientX > document.documentElement.clientWidth || e.clientX < 0 || e.clientY > document.documentElement.clientHeight || e.clientY < 0 ) { if (tldrawAPI?.session) { tldrawAPI?.completeSession?.(); } } }; const checkVisibility = () => { if (document.visibilityState === 'hidden' && tldrawAPI?.session) { tldrawAPI?.completeSession?.(); } }; const handleWheelEvent = (event) => { if ((zoomValueRef.current >= MAX_PERCENT && event.deltaY < 0) || (zoomValueRef.current <= HUNDRED_PERCENT && event.deltaY > 0)) { event.stopPropagation(); event.preventDefault(); return window.dispatchEvent(new Event('resize')); } if (!event.ctrlKey) { // Prevent the event from reaching the tldraw library event.stopPropagation(); event.preventDefault(); const newEvent = new WheelEvent('wheel', { deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ, ctrlKey: true, clientX: event.clientX, clientY: event.clientY, }); const canvas = document.getElementById('canvas'); if (canvas) { canvas.dispatchEvent(newEvent); } } window.dispatchEvent(new Event('resize')); } React.useEffect(() => { document.addEventListener('mouseup', checkClientBounds); document.addEventListener('visibilitychange', checkVisibility); return () => { document.removeEventListener('mouseup', checkClientBounds); document.removeEventListener('visibilitychange', checkVisibility); const canvas = document.getElementById('canvas'); if (canvas) { canvas.removeEventListener('wheel', handleWheelEvent); } }; }, [tldrawAPI]); /* needed to prevent an issue with presentation images not loading correctly in Firefox more info: https://github.com/bigbluebutton/bigbluebutton/issues/17969#issuecomment-1561758200 */ React.useEffect(() => { if (bgShape && bgShape.parentElement && bgShape.parentElement.clientWidth > 0) { bgShape.parentElement.style.width = `${bgShape.parentElement.clientWidth + .1}px`; } }, [bgShape]); const doc = React.useMemo(() => { const currentDoc = rDocument.current; // update document if the number of pages has changed if (currentDoc.id !== whiteboardId && currentDoc?.pages.length !== curPres?.pages.length) { const currentPageShapes = currentDoc?.pages[curPageId]?.shapes; currentDoc.id = whiteboardId; currentDoc.pages = pages; currentDoc.pages[curPageId].shapes = currentPageShapes; currentDoc.pageStates = pageStates; } const next = { ...currentDoc }; let changed = false; if (next.pageStates[curPageId] && !isEqual(prevShapes, shapes)) { const editingShape = tldrawAPI?.getShape(tldrawAPI?.getPageState()?.editingId); if (editingShape) { shapes[editingShape?.id] = editingShape; } const removed = prevShapes && findRemoved(Object.keys(prevShapes), Object.keys((shapes))); if (removed && removed.length > 0) { const patchedShapes = Object.fromEntries(removed.map((id) => [id, undefined])); try { tldrawAPI?.patchState( { document: { pageStates: { [curPageId]: { selectedIds: tldrawAPI?.selectedIds?.filter((id) => !removed.includes(id)) || [], }, }, pages: { [curPageId]: { shapes: patchedShapes, }, }, }, }, ); } catch (error) { logger.error({ logCode: 'whiteboard_shapes_remove_error', extraInfo: { error }, }, 'Whiteboard catch error on removing shapes'); } } next.pages[curPageId].shapes = filterInvalidShapes(shapes, curPageId, tldrawAPI); changed = true; } if (curPageId && (!next.assets[`slide-background-asset-${curPageId}`] || (svgUri && !isEqual(prevSvgUri, svgUri)))) { next.assets[`slide-background-asset-${curPageId}`] = assets[`slide-background-asset-${curPageId}`]; tldrawAPI?.patchState( { document: { assets, }, }, ); changed = true; } if (changed && tldrawAPI) { // merge patch manually (this improves performance and reduce side effects on fast updates) const patch = { document: { pages: { [curPageId]: { shapes: filterInvalidShapes(shapes, curPageId, tldrawAPI) }, }, }, }; const prevState = tldrawAPI._state; const nextState = Utils.deepMerge(tldrawAPI._state, patch); if (nextState.document.pages[curPageId].shapes) { filterInvalidShapes(nextState.document.pages[curPageId].shapes, curPageId, tldrawAPI); } const final = tldrawAPI.cleanup(nextState, prevState, patch, ''); tldrawAPI._state = final; try { tldrawAPI?.forceUpdate(); } catch (error) { logger.error({ logCode: 'whiteboard_shapes_update_error', extraInfo: { error }, }, 'Whiteboard catch error on updating shapes'); } } // move poll result text to bottom right if (next.pages[curPageId] && slidePosition) { const pollResults = Object.entries(next.pages[curPageId].shapes) .filter(([, shape]) => shape.name?.includes('poll-result')); pollResults.forEach(([id, shape]) => { if (isEqual(shape.point, [0, 0])) { try { const shapeBounds = tldrawAPI?.getShapeBounds(id); if (shapeBounds) { const editedShape = shape; editedShape.point = [ slidePosition.width - shapeBounds.width, slidePosition.height - shapeBounds.height, ]; editedShape.size = [shapeBounds.width, shapeBounds.height]; if (isPresenter) persistShape(editedShape, whiteboardId, isModerator); } } catch (error) { logger.error({ logCode: 'whiteboard_poll_results_error', extraInfo: { error }, }, 'Whiteboard catch error on moving unpublished poll results'); } } }); } return currentDoc; }, [shapes, tldrawAPI, curPageId, slidePosition]); // when presentationSizes change, update tldraw camera React.useEffect(() => { if (curPageId && slidePosition && tldrawAPI && presentationWidth > 0 && presentationHeight > 0 ) { if (prevFitToWidth !== null && fitToWidth !== prevFitToWidth) { const newZoom = calculateZoom(slidePosition.width, slidePosition.height); tldrawAPI?.setCamera([0, 0], newZoom); const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight( tldrawAPI?.viewport.height, slidePosition.height, ); setZoom(HUNDRED_PERCENT); zoomChanger(HUNDRED_PERCENT); zoomSlide(parseInt(curPageId, 10), podId, HUNDRED_PERCENT, viewedRegionH, 0, 0); } else { const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100; const previousAspectRatio = Math.round( (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100, ) / 100; if (fitToWidth && currentAspectRatio !== previousAspectRatio) { // we need this to ensure tldraw updates the viewport size after re-mounting setTimeout(() => { const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight); tldrawAPI.setCamera([slidePosition.x, slidePosition.y], newZoom, 'zoomed'); }, 50); } else { const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight); tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom); } } } }, [presentationWidth, presentationHeight, curPageId, document?.documentElement?.dir]); React.useEffect(() => { if (presentationWidth > 0 && presentationHeight > 0 && slidePosition) { const cameraZoom = tldrawAPI?.getPageState()?.camera?.zoom; const newzoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight); if (cameraZoom && cameraZoom === 1) { tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom); } else if (isMounting) { setIsMounting(false); setTldrawIsMounting(false); const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100; const previousAspectRatio = Math.round( (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100, ) / 100; // case where the presenter had fit-to-width enabled and he reloads the page if (!fitToWidth && currentAspectRatio !== previousAspectRatio) { // wee need this to ensure tldraw updates the viewport size after re-mounting setTimeout(() => { tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom, 'zoomed'); }, 50); } else { tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom); } } } }, [tldrawAPI?.getPageState()?.camera, presentationWidth, presentationHeight]); // change tldraw camera when slidePosition changes React.useEffect(() => { if (tldrawAPI && !isPresenter && curPageId && slidePosition) { const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight); tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom, 'zoomed'); } const camera = tldrawAPI?.getPageState()?.camera; if (isPresenter && slidePosition && camera) { const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height); const zoomCamera = (zoomFitSlide * zoomValue) / HUNDRED_PERCENT; let zoomToolbar = Math.round( ((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100, ) / 100; if ((zoom !== zoomToolbar) && (curPageId && curPageId !== prevPageId)) { setZoom(zoomToolbar); zoomChanger(zoomToolbar); } } }, [curPageId, slidePosition]); // update zoom according to toolbar React.useEffect(() => { if (tldrawAPI && isPresenter && curPageId && slidePosition && zoom !== zoomValue) { const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height); const zoomCamera = (zoomFitSlide * zoomValue) / HUNDRED_PERCENT; setTimeout(() => { tldrawAPI?.zoomTo(zoomCamera); }, 50); } }, [zoomValue]); // update zoom when presenter changes if the aspectRatio has changed React.useEffect(() => { if (tldrawAPI && isPresenter && curPageId && slidePosition && !isMounting) { const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100; const previousAspectRatio = Math.round( (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100, ) / 100; if (previousAspectRatio !== currentAspectRatio) { if (fitToWidth) { const newZoom = calculateZoom(slidePosition.width, slidePosition.height); tldrawAPI?.setCamera([0, 0], newZoom); const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight( tldrawAPI?.viewport.height, slidePosition.height, ); zoomSlide(parseInt(curPageId, 10), podId, HUNDRED_PERCENT, viewedRegionH, 0, 0); setZoom(HUNDRED_PERCENT); zoomChanger(HUNDRED_PERCENT); } else if (!isMounting) { let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth( tldrawAPI?.viewport.width, slidePosition.width, ); let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight( tldrawAPI?.viewport.height, slidePosition.height, ); const camera = tldrawAPI?.getPageState()?.camera; const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height); if (!fitToWidth && camera.zoom === zoomFitSlide) { viewedRegionW = HUNDRED_PERCENT; viewedRegionH = HUNDRED_PERCENT; } zoomSlide( parseInt(curPageId, 10), podId, viewedRegionW, viewedRegionH, camera.point[0], camera.point[1], ); const zoomToolbar = Math.round( ((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100, ) / 100; if (zoom !== zoomToolbar) { setZoom(zoomToolbar); zoomChanger(zoomToolbar); } } } } }, [isPresenter]); const hasWBAccess = hasMultiUserAccess(whiteboardId, currentUser.userId); React.useEffect(() => { if (tldrawAPI) { tldrawAPI.isForcePanning = isPanning; } }, [isPanning]); React.useEffect(() => { tldrawAPI && setDockPosition(tldrawAPI?.setSetting); }, [height, width]); React.useEffect(() => { tldrawAPI?.setSetting('language', language); }, [language]); // Reset zoom to default when current presentation changes. React.useEffect(() => { if (isPresenter && slidePosition && tldrawAPI) { tldrawAPI?.zoomTo(0); setHistory(null); tldrawAPI?.resetHistory(); } }, [curPres?.id]); React.useEffect(() => { const currentZoom = tldrawAPI?.getPageState()?.camera?.zoom; if (currentZoom !== tldrawZoom) { setTldrawZoom(currentZoom); } setBgShape(null); }, [presentationAreaHeight, presentationAreaWidth]); const fullscreenToggleHandler = () => { const { isFullscreen, fullscreenAction, handleToggleFullScreen, } = props; handleToggleFullScreen(fullscreenRef); const newElement = isFullscreen ? '' : fullscreenElementId; layoutContextDispatch({ type: fullscreenAction, value: { element: newElement, group: '', }, }); }; const nextSlideHandler = (event) => { const { nextSlide, numberOfSlides } = props; if (event) event.currentTarget.blur(); nextSlide(+curPageId, numberOfSlides, podId); }; const previousSlideHandler = (event) => { const { previousSlide } = props; if (event) event.currentTarget.blur(); previousSlide(+curPageId, podId); }; const handleOnKeyDown = (event) => { const { which, ctrlKey } = event; switch (which) { case KEY_CODES.ARROW_LEFT: case KEY_CODES.PAGE_UP: previousSlideHandler(); break; case KEY_CODES.ARROW_RIGHT: case KEY_CODES.PAGE_DOWN: nextSlideHandler(); break; case KEY_CODES.ENTER: fullscreenToggleHandler(); break; case KEY_CODES.A: if (ctrlKey) { event.preventDefault(); event.stopPropagation(); tldrawAPI?.selectAll(); } break; case KEY_CODES.ARROW_DOWN: case KEY_CODES.ARROW_UP: event.preventDefault(); event.stopPropagation(); break; default: } }; const onMount = (app) => { setDockPosition(app?.setSetting); const menu = document.getElementById('TD-Styles')?.parentElement; const canvas = document.getElementById('canvas'); if (canvas) { canvas.addEventListener('wheel', handleWheelEvent, { capture: true }); } if (menu) { menu.style.position = 'relative'; menu.style.height = presentationMenuHeight; menu.setAttribute('id', 'TD-Styles-Parent'); [...menu.children] .sort((a, b) => (a?.id > b?.id ? -1 : 1)) .forEach((n) => menu.appendChild(n)); } app.setSetting('language', language); app?.setSetting('isDarkMode', false); const textAlign = isRTL ? 'end' : 'start'; app?.patchState( { appState: { currentStyle: { textAlign: currentStyle?.textAlign || textAlign, font: currentStyle?.font || fontFamily, }, }, }, ); setSafeTLDrawAPI(app); // disable for non presenter that doesn't have multi user access if (!hasWBAccess && !isPresenter) { const newApp = app; newApp.onPan = () => { }; newApp.setSelectedIds = () => { }; newApp.setHoveredId = () => { }; } if (history) { app.replaceHistory(history); } if (curPageId) { app.patchState( { appState: { currentPageId: curPageId, }, }, ); setIsMounting(true); } // needed to ensure the correct calculations for cursors on mount. setTimeout(() => { window.dispatchEvent(new Event('resize')); }, MOUNTED_RESIZE_DELAY); }; const onPatch = (e, t, reason) => { if (!e?.pageState || !reason) return; if (((isPanning || panSelected) && (reason === 'selected' || reason === 'set_hovered_id'))) { e.patchState( { document: { pageStates: { [e.getPage()?.id]: { selectedIds: [], hoveredId: null, }, }, }, }, ); return; } // don't allow select others shapes for editing if don't have permission if (reason && reason.includes('set_editing_id')) { if (!hasShapeAccess(e.pageState.editingId)) { e.pageState.editingId = null; } } // don't allow hover others shapes for editing if don't have permission if (reason && reason.includes('set_hovered_id')) { if (!hasShapeAccess(e.pageState.hoveredId)) { e.pageState.hoveredId = null; } } // don't allow select others shapes if don't have permission if (reason && reason.includes('selected')) { const validIds = []; e.pageState.selectedIds.forEach((id) => hasShapeAccess(id) && validIds.push(id)); e.pageState.selectedIds = validIds; e.patchState( { document: { pageStates: { [e.getPage()?.id]: { selectedIds: validIds, }, }, }, }, ); } // don't allow selecting others shapes with ctrl (brush) if (e?.session?.type === 'brush' && e?.session?.status === 'brushing') { const validIds = []; e.pageState.selectedIds.forEach((id) => hasShapeAccess(id) && validIds.push(id)); e.pageState.selectedIds = validIds; if (!validIds.find((id) => id === e.pageState.hoveredId)) { e.pageState.hoveredId = undefined; } } // change cursor when moving shapes if (e?.session?.type === 'translate' && e?.session?.status === 'translating') { if (!isMoving) setIsMoving(true); if (reason === 'set_status:idle') setIsMoving(false); } if (reason && isPresenter && slidePosition && (reason.includes('zoomed') || reason.includes('panned'))) { const camera = tldrawAPI?.getPageState()?.camera; // limit bounds if (tldrawAPI?.viewport.maxX > slidePosition.width) { camera.point[0] += (tldrawAPI?.viewport.maxX - slidePosition.width); } if (tldrawAPI?.viewport.maxY > slidePosition.height) { camera.point[1] += (tldrawAPI?.viewport.maxY - slidePosition.height); } if (camera.point[0] > 0 || tldrawAPI?.viewport.minX < 0) { camera.point[0] = 0; } if (camera.point[1] > 0 || tldrawAPI?.viewport.minY < 0) { camera.point[1] = 0; } const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height); if (camera.zoom < zoomFitSlide) { camera.zoom = zoomFitSlide; } const zoomToolbar = Math.round(((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100) / 100; if (zoom !== zoomToolbar) { setZoom(zoomToolbar); if (isPresenter) zoomChanger(zoomToolbar); } let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth( tldrawAPI?.viewport.width, slidePosition.width, ); let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight( tldrawAPI?.viewport.height, slidePosition.height, ); if (!fitToWidth && camera.zoom === zoomFitSlide) { viewedRegionW = HUNDRED_PERCENT; viewedRegionH = HUNDRED_PERCENT; camera.point = [0,0]; } zoomSlide( parseInt(curPageId, 10), podId, viewedRegionW, viewedRegionH, camera.point[0], camera.point[1], ); } // don't allow non-presenters to pan&zoom if (slidePosition && reason && !isPresenter && (reason.includes('zoomed') || reason.includes('panned'))) { const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight); tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom); } // disable select for non presenter that doesn't have multi user access if (!hasWBAccess && !isPresenter) { if (e?.getPageState()?.brush || e?.selectedIds?.length !== 0) { e.patchState( { document: { pageStates: { [e?.currentPageId]: { selectedIds: [], brush: null, }, }, }, }, ); } } if (reason && reason === 'patched_shapes' && e?.session?.type === 'edit') { const patchedShape = e?.getShape(e?.getPageState()?.editingId); if (e?.session?.initialShape?.type === 'sticky' && patchedShape?.text?.length > maxStickyNoteLength) { patchedShape.text = patchedShape.text.substring(0, maxStickyNoteLength); } if (e?.session?.initialShape?.type === 'text' && !shapes[patchedShape.id]) { // check for maxShapes const currentShapes = e?.document?.pages[e?.currentPageId]?.shapes; const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations; if (shapeNumberExceeded) { notifyShapeNumberExceeded(intl, maxNumberOfAnnotations); e?.cancelSession?.(); } else { patchedShape.userId = currentUser?.userId; persistShape(patchedShape, whiteboardId, isModerator); } } else { const diff = { id: patchedShape.id, point: patchedShape.point, text: patchedShape.text, }; persistShape(diff, whiteboardId, isModerator); } } if (reason && reason.includes('selected_tool')) { const tool = reason.split(':')[1]; setCurrentTool(tool); setPanSelected(false); setIsPanning(false); } if (reason && reason.includes('ui:toggled_is_loading')) { e?.patchState( { appState: { currentStyle, }, }, ); } e?.patchState( { appState: { isToolLocked, }, }, ); if ((panSelected || isPanning)) { e.isForcePanning = isPanning; } }; const onUndo = (app) => { if (app.currentPageId !== curPageId) { if (isPresenter) { // change slide for others skipToSlide(Number.parseInt(app.currentPageId, 10), podId); } else { // ignore, stay on same page app.changePage(curPageId); } return; } const lastCommand = app.stack[app.pointer + 1]; const changedShapes = lastCommand?.before?.document?.pages[app.currentPageId]?.shapes; if (changedShapes) { sendShapeChanges( app, changedShapes, shapes, prevShapes, hasShapeAccess, whiteboardId, currentUser, intl, true, ); } }; const onRedo = (app) => { if (app.currentPageId !== curPageId) { if (isPresenter) { // change slide for others skipToSlide(Number.parseInt(app.currentPageId, 10), podId); } else { // ignore, stay on same page app.changePage(curPageId); } return; } const lastCommand = app.stack[app.pointer]; const changedShapes = lastCommand?.after?.document?.pages[app.currentPageId]?.shapes; if (changedShapes) { sendShapeChanges( app, changedShapes, shapes, prevShapes, hasShapeAccess, whiteboardId, currentUser, intl, ); } }; const onCommand = (app, command) => { const isFirstCommand = command.id === "change_page" && command.before?.appState.currentPageId === "0"; if (!isFirstCommand){ setHistory(app.history); } if (whiteboardToolbarAutoHide && command && command.id === "change_page") { toggleToolsAnimations('fade-in', 'fade-out', '0s'); } if (command?.id?.includes('style')) { setCurrentStyle({ ...currentStyle, ...command?.after?.appState?.currentStyle }); } const changedShapes = command.after?.document?.pages[app.currentPageId]?.shapes; if (!isMounting && app.currentPageId !== curPageId) { // can happen then the "move to page action" is called, or using undo after changing a page const currentPage = curPres.pages.find( (page) => page.num === Number.parseInt(app.currentPageId, 10), ); if (!currentPage) return; const newWhiteboardId = currentPage.id; // remove from previous page and persist on new if (changedShapes) { removeShapes(Object.keys(changedShapes), whiteboardId); Object.entries(changedShapes) .forEach(([id, shape]) => { const shapeBounds = app.getShapeBounds(id); const editedShape = shape; editedShape.size = [shapeBounds.width, shapeBounds.height]; persistShape(editedShape, newWhiteboardId, isModerator); }); } if (isPresenter) { // change slide for others skipToSlide(Number.parseInt(app.currentPageId, 10), podId); } else { // ignore, stay on same page app.changePage(curPageId); } } else if (changedShapes) { sendShapeChanges( app, changedShapes, shapes, prevShapes, hasShapeAccess, whiteboardId, currentUser, intl, ); } }; const webcams = document.getElementById('cameraDock'); const dockPos = webcams?.getAttribute('data-position'); const backgroundShape = document.getElementById('slide-background-shape_image'); if (currentTool && !isPanning && !tldrawAPI?.isForcePanning) tldrawAPI?.selectTool(currentTool); if (backgroundShape && backgroundShape.src // if there is a background image && backgroundShape.complete // and it's fully downloaded && backgroundShape.src !== bgShape?.src // and if it's a different image && backgroundShape.parentElement?.clientWidth > 0 // and if the whiteboard area is visible ) { setBgShape(backgroundShape); } const editableWB = ( ); const readOnlyWB = ( ); const size = ((height < SMALL_HEIGHT) || (width < SMALL_WIDTH)) ? TOOLBAR_SMALL : TOOLBAR_LARGE; const menuOffsetValues = { true: { true: `${styleMenuOffsetSmall}`, false: `${styleMenuOffset}`, }, false: { true: `-${styleMenuOffsetSmall}`, false: `-${styleMenuOffset}`, }, }; const menuOffset = menuOffsetValues[isRTL][isIphone]; return (
{(hasWBAccess || isPresenter) ? editableWB : readOnlyWB} {isPresenter && ( )}
); } Whiteboard.propTypes = { isPresenter: PropTypes.bool.isRequired, isIphone: PropTypes.bool.isRequired, removeShapes: PropTypes.func.isRequired, initDefaultPages: PropTypes.func.isRequired, persistShape: PropTypes.func.isRequired, notifyNotAllowedChange: PropTypes.func.isRequired, shapes: PropTypes.objectOf(PropTypes.shape).isRequired, assets: PropTypes.objectOf(PropTypes.shape).isRequired, currentUser: PropTypes.shape({ userId: PropTypes.string.isRequired, }).isRequired, curPres: PropTypes.shape({ pages: PropTypes.arrayOf(PropTypes.shape({})), id: PropTypes.string.isRequired, }), whiteboardId: PropTypes.string, podId: PropTypes.string.isRequired, zoomSlide: PropTypes.func.isRequired, skipToSlide: PropTypes.func.isRequired, slidePosition: PropTypes.shape({ x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, height: PropTypes.number.isRequired, width: PropTypes.number.isRequired, viewBoxWidth: PropTypes.number.isRequired, viewBoxHeight: PropTypes.number.isRequired, }), curPageId: PropTypes.string.isRequired, presentationWidth: PropTypes.number.isRequired, presentationHeight: PropTypes.number.isRequired, isViewersCursorLocked: PropTypes.bool.isRequired, zoomChanger: PropTypes.func.isRequired, isMultiUserActive: PropTypes.func.isRequired, isRTL: PropTypes.bool.isRequired, fitToWidth: PropTypes.bool.isRequired, zoomValue: PropTypes.number.isRequired, intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired, }).isRequired, svgUri: PropTypes.string, maxStickyNoteLength: PropTypes.number.isRequired, fontFamily: PropTypes.string.isRequired, hasShapeAccess: PropTypes.func.isRequired, presentationAreaHeight: PropTypes.number.isRequired, presentationAreaWidth: PropTypes.number.isRequired, maxNumberOfAnnotations: PropTypes.number.isRequired, notifyShapeNumberExceeded: PropTypes.func.isRequired, darkTheme: PropTypes.bool.isRequired, isPanning: PropTypes.bool.isRequired, setTldrawIsMounting: PropTypes.func.isRequired, width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, hasMultiUserAccess: PropTypes.func.isRequired, fullscreenElementId: PropTypes.string.isRequired, isFullscreen: PropTypes.bool.isRequired, layoutContextDispatch: PropTypes.func.isRequired, fullscreenAction: PropTypes.string.isRequired, fullscreenRef: PropTypes.instanceOf(Element), handleToggleFullScreen: PropTypes.func.isRequired, nextSlide: PropTypes.func.isRequired, numberOfSlides: PropTypes.number.isRequired, previousSlide: PropTypes.func.isRequired, sidebarNavigationWidth: PropTypes.number, }; Whiteboard.defaultProps = { curPres: undefined, fullscreenRef: undefined, slidePosition: undefined, svgUri: undefined, whiteboardId: undefined, sidebarNavigationWidth: 0, };