import * as React from "react"; import _ from "lodash"; import Cursors from "./cursors/container"; import { TldrawApp, Tldraw } from "@tldraw/tldraw"; import logger from '/imports/startup/client/logger'; import { ColorStyle, DashStyle, SizeStyle, TDDocument, TDShapeType, } from "@tldraw/tldraw"; import { Renderer, Utils } from "@tldraw/core"; function usePrevious(value) { const ref = React.useRef(); React.useEffect(() => { ref.current = value; }, [value]); return ref.current; } const findRemoved = (A, B) => { return A.filter((a) => { return !B.includes(a); }); }; export default function Whiteboard(props) { const { isPresenter, removeShapes, initDefaultPages, meetingId, persistShape, persistAsset, shapes, assets, currentUser, curPres, whiteboardId, podId, zoomSlide, skipToSlide, slidePosition, curPageId, svgUri, presentationBounds, isViewersCursorLocked, setIsZoomed, zoomChanger, isZoomed, isMultiUserActive, } = 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 [doc, setDoc] = React.useState(rDocument.current); const [_assets, setAssets] = React.useState(assets); const [command, setCommand] = React.useState(""); const [wbAccess, setWBAccess] = React.useState(props?.hasMultiUserAccess(props.whiteboardId, props.currentUser.userId)); const [selectedIds, setSelectedIds] = React.useState([]); const [tldrawAPI, setTLDrawAPI] = React.useState(null); const [cameraFitSlide, setCameraFitSlide] = React.useState({point: [0, 0], zoom: 0}); const prevShapes = usePrevious(shapes); const prevSlidePosition = usePrevious(slidePosition); const prevPageId = usePrevious(curPageId); const calculateCameraFitSlide = () => { let zoom = Math.min( (presentationBounds.width) / slidePosition.width, (presentationBounds.height) / slidePosition.height ); zoom = Utils.clamp(zoom, 0.1, 5); let point = [0, 0]; if ((presentationBounds.width / presentationBounds.height) > (slidePosition.width / slidePosition.height)) { point[0] = (presentationBounds.width - (slidePosition.width * zoom)) / 2 / zoom } else { point[1] = (presentationBounds.height - (slidePosition.height * zoom)) / 2 / zoom } isPresenter && zoomChanger(zoom); return {point, zoom} } const doc = React.useMemo(() => { const currentDoc = rDocument.current; let next = { ...currentDoc }; let pageBindings = null; let history = null; let stack = null; let changed = false; if (next.pageStates[curPageId] && !_.isEqual(prevShapes, shapes)) { // mergeDocument loses bindings and history, save it pageBindings = tldrawAPI?.getPage(curPageId)?.bindings; history = tldrawAPI?.history stack = tldrawAPI?.stack next.pages[curPageId].shapes = shapes; changed = true; } if (next.pages[curPageId] && !next.pages[curPageId].shapes["slide-background-shape"]) { next.assets[`slide-background-asset-${curPageId}`] = { id: `slide-background-asset-${curPageId}`, size: [slidePosition?.width || 0, slidePosition?.height || 0], src: svgUri, type: "image", }; next.pages[curPageId].shapes["slide-background-shape"] = { assetId: `slide-background-asset-${curPageId}`, childIndex: 0.5, id: "slide-background-shape", name: "Image", type: TDShapeType.Image, parentId: `${curPageId}`, point: [0, 0], isLocked: true, size: [slidePosition?.width || 0, slidePosition?.height || 0], style: { dash: DashStyle.Draw, size: SizeStyle.Medium, color: ColorStyle.Blue, }, }; changed = true; } if (changed) { if (pageBindings) next.pages[curPageId].bindings = pageBindings; tldrawAPI?.mergeDocument(next); if (tldrawAPI && history) tldrawAPI.history = history; if (tldrawAPI && stack) tldrawAPI.stack = stack; } // move poll result text to bottom right if (next.pages[curPageId]) { const pollResults = Object.entries(next.pages[curPageId].shapes) .filter(([id, shape]) => shape.name.includes("poll-result")) for (const [id, shape] of pollResults) { if (_.isEqual(shape.point, [0, 0])) { const shapeBounds = tldrawAPI?.getShapeBounds(id); if (shapeBounds) { shape.point = [ slidePosition.width - shapeBounds.width, slidePosition.height - shapeBounds.height ] shape.size = [shapeBounds.width, shapeBounds.height] isPresenter && persistShape(shape, whiteboardId); } } }; } return currentDoc; }, [assets, shapes, tldrawAPI, curPageId, slidePosition]); // when presentationBounds change, update tldraw camera // to fit slide on center if zoomed out React.useEffect(() => { if (curPageId && slidePosition) { const camera = calculateCameraFitSlide(); setCameraFitSlide(camera); if (!isZoomed) { tldrawAPI?.setCamera(camera.point, camera.zoom); } } }, [presentationBounds, curPageId, document?.documentElement?.dir]); // change tldraw page when presentation page changes React.useEffect(() => { if (tldrawAPI && curPageId) { const previousPageZoom = tldrawAPI.getPageState()?.camera?.zoom; tldrawAPI.changePage(curPageId); //change zoom of the new page to follow the previous one if (!isZoomed && cameraFitSlide.zoom !== 0) { tldrawAPI?.setCamera(cameraFitSlide.point, cameraFitSlide.zoom, "zoomed"); } else { previousPageZoom && slidePosition && tldrawAPI.setCamera([slidePosition.xCamera, slidePosition.yCamera], previousPageZoom, "zoomed"); } } }, [curPageId]); // change tldraw camera when slidePosition changes React.useEffect(() => { if (tldrawAPI && !isPresenter && curPageId && slidePosition) { if (slidePosition.zoom === 0 && slidePosition.xCamera === 0 && slidePosition.yCamera === 0) { tldrawAPI?.setCamera(cameraFitSlide.point, cameraFitSlide.zoom); setIsZoomed(false); } else { tldrawAPI?.setCamera([slidePosition.xCamera, slidePosition.yCamera], slidePosition.zoom); setIsZoomed(true); } } }, [curPageId, slidePosition]); const hasWBAccess = props?.hasMultiUserAccess(props.whiteboardId, props.currentUser.userId); return ( <> { setTLDrawAPI(app); props.setTldrawAPI(app); // disable for non presenter that doesn't have multi user access if (!hasWBAccess && !isPresenter) { app.onPan = () => {}; app.setSelectedIds = () => {}; app.setHoveredId = () => {}; } else { // disable hover highlight for background slide shape app.setHoveredId = (id) => { if (id.includes('slide-background')) return null; app.patchState( { document: { pageStates: { [app.getPage()?.id]: { hoveredId: id, }, }, }, }, `set_hovered_id` ); }; } if (curPageId) { app.changePage(curPageId); if (slidePosition.zoom === 0) { // first load, center the view to fit slide const cameraFitSlide = calculateCameraFitSlide(); app.setCamera(cameraFitSlide.point, cameraFitSlide.zoom); setCameraFitSlide(cameraFitSlide); } else { app.setCamera([slidePosition.xCamera, slidePosition.yCamera], slidePosition.zoom); setIsZoomed(true); } } }} showPages={false} showZoom={false} showUI={curPres ? (isPresenter || hasWBAccess) : true} showMenu={curPres ? false : true} showMultiplayerMenu={false} readOnly={!isPresenter && !hasWBAccess} onUndo={(e, s) => { e?.selectedIds?.map(id => { persistShape(e.getShape(id), whiteboardId); }) const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes; let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes)) removeShapes(shapesIdsToRemove, whiteboardId) }} onRedo={(e, s) => { e?.selectedIds?.map(id => { persistShape(e.getShape(id), whiteboardId); }); const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes; let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes)) removeShapes(shapesIdsToRemove, whiteboardId) }} onChangePage={(app, s, b, a) => { if (app.getPage()?.id !== curPageId) { skipToSlide(Number.parseInt(app.getPage()?.id), podId) } }} onCommand={(e, s, g) => { if (s.includes("session:complete:DrawSession")) { Object.entries(e.state.document.pages[e.getPage()?.id]?.shapes) .filter(([k, s]) => s?.type === 'draw') .forEach(([k, s]) => { if (!e.prevShapes[k] && !k.includes('slide-background')) { const shapeBounds = e.getShapeBounds(k); s.size = [shapeBounds.width, shapeBounds.height]; persistShape(s, whiteboardId); } }); } if (s.includes("style") || s?.includes("session:complete:ArrowSession")) { e.selectedIds.forEach(id => { const shape = e.getShape(id); const shapeBounds = e.getShapeBounds(id); shape.size = [shapeBounds.width, shapeBounds.height]; persistShape(shape, whiteboardId); }); } if (s.includes('move_to_page')) { const movedShapes = e.selectedIds.map(id => { return e.getShape(id); }); //remove shapes on origin page removeShapes(e.selectedIds, whiteboardId); //persist shapes for destination page const newWhiteboardId = curPres.pages.find(page => page.num === Number.parseInt(e.getPage()?.id)).id; movedShapes.forEach(s => { persistShape(s, newWhiteboardId); }); return; } const conditions = [ "session:complete:TransformSingleSession", "session:complete:TranslateSession", "session:complete:RotateSession", "session:complete:HandleSession", "updated_shapes", "duplicate", "stretch", "align", "move", "create", "flip", "toggle", "group", "translate" ] if (conditions.some(el => s?.includes(el))) { e.selectedIds.forEach(id => { const shape = e.getShape(id); const shapeBounds = e.getShapeBounds(id); shape.size = [shapeBounds.width, shapeBounds.height]; persistShape(shape, whiteboardId); //checks to find any bindings assosiated with the selected shapes. //If any, they need to be updated as well. const pageBindings = e.state.document.pages[e.getPage()?.id]?.bindings; const boundShapes = []; if (pageBindings) { Object.entries(pageBindings).map(([k,b]) => { if (b.toId.includes(id)) { boundShapes.push(e.state.document.pages[e.getPage()?.id]?.shapes[b.fromId]) } }) } //persist shape(s) that was updated by the client and any shapes bound to it. boundShapes.forEach(bs => { const shapeBounds = e.getShapeBounds(bs.id); bs.size = [shapeBounds.width, shapeBounds.height]; persistShape(bs, whiteboardId) }) const children = e.getShape(id).children //also persist children of the selected shape (grouped shapes) children && children.forEach(c => { const shape = e.getShape(c); const shapeBounds = e.getShapeBounds(c); shape.size = [shapeBounds.width, shapeBounds.height]; persistShape(shape, whiteboardId) }) }); } if (s?.includes("session:complete:EraseSession") || s?.includes("delete")) { const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes; let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes)) removeShapes(shapesIdsToRemove, whiteboardId) } }} onPatch={(s, reason) => { if (reason && isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) { const camera = tldrawAPI.getPageState().camera; //don't allow zoom out more than fit if (camera.zoom <= cameraFitSlide.zoom) { tldrawAPI?.setCamera(cameraFitSlide.point, cameraFitSlide.zoom); setIsZoomed(false); zoomSlide(parseInt(curPageId), podId, 0, 0, 0); } else { zoomSlide(parseInt(curPageId), podId, camera.zoom, camera.point[0], camera.point[1]); setIsZoomed(true); } } //don't allow non-presenters to pan&zoom if (slidePosition && reason && !isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) { if (slidePosition.zoom === 0 && slidePosition.xCamera === 0 && slidePosition.yCamera === 0) { tldrawAPI?.setCamera(cameraFitSlide.point, cameraFitSlide.zoom); setIsZoomed(false); } else { tldrawAPI?.setCamera([slidePosition.xCamera, slidePosition.yCamera], slidePosition.zoom); setIsZoomed(true); } } }} /> ); }