bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx
Arthurk12 49c33b4eea fix(whiteboard): check before manipulating pan tool class list
Since the pan tool is only available for the presenter, it has to be checked
whether it actually exists before attempting to modify its class list.
2024-04-16 16:18:30 -03:00

1193 lines
40 KiB
JavaScript

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);
if (panButton) {
// only presenter has the pan button
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 = (
<Styled.EditableWBWrapper onKeyDown={handleOnKeyDown}>
<Tldraw
key={`wb-${isRTL}-${dockPos}-${presentationAreaHeight}-${presentationAreaWidth}-${sidebarNavigationWidth}`}
document={doc}
// disable the ability to drag and drop files onto the whiteboard
// until we handle saving of assets in akka.
disableAssets
// Disable automatic focus. Users were losing focus on shared notes
// and chat on presentation mount.
autofocus={false}
onMount={onMount}
showPages={false}
showZoom={false}
showUI={curPres ? (isPresenter || hasWBAccess) : true}
showMenu={!curPres}
showMultiplayerMenu={false}
readOnly={false}
onPatch={onPatch}
onUndo={onUndo}
onRedo={onRedo}
onCommand={onCommand}
/>
</Styled.EditableWBWrapper>
);
const readOnlyWB = (
<Tldraw
key="wb-readOnly"
document={doc}
onMount={onMount}
// disable the ability to drag and drop files onto the whiteboard
// until we handle saving of assets in akka.
disableAssets
// Disable automatic focus. Users were losing focus on shared notes
// and chat on presentation mount.
autofocus={false}
showPages={false}
showZoom={false}
showUI={false}
showMenu={false}
showMultiplayerMenu={false}
readOnly
onPatch={onPatch}
/>
);
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 (
<div key={`animations=-${animations}`}>
<Cursors
tldrawAPI={tldrawAPI}
currentUser={currentUser}
hasMultiUserAccess={hasMultiUserAccess}
whiteboardId={whiteboardId}
isViewersCursorLocked={isViewersCursorLocked}
isMultiUserActive={isMultiUserActive}
isPanning={isPanning || panSelected}
isMoving={isMoving}
currentTool={currentTool}
whiteboardToolbarAutoHide={whiteboardToolbarAutoHide}
toggleToolsAnimations={toggleToolsAnimations}
>
{(hasWBAccess || isPresenter) ? editableWB : readOnlyWB}
<Styled.TldrawGlobalStyle
hideContextMenu={!hasWBAccess && !isPresenter}
{...{
hasWBAccess,
isPresenter,
size,
darkTheme,
menuOffset,
panSelected,
isToolbarVisible,
}}
/>
</Cursors>
{isPresenter && (
<PanToolInjector
{...{
tldrawAPI,
fitToWidth,
isPanning,
setIsPanning,
zoomValue,
panSelected,
setPanSelected,
currentTool,
}}
formatMessage={intl?.formatMessage}
/>
)}
</div>
);
}
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,
};