import * as React from 'react';
import PropTypes from 'prop-types';
import { useRef, useCallback } from 'react';
import { isEqual } from 'radash';
import {
Tldraw,
DefaultColorStyle,
DefaultDashStyle,
DefaultFillStyle,
DefaultFontStyle,
DefaultSizeStyle,
InstancePresenceRecordType,
setDefaultUiAssetUrls,
setDefaultEditorAssetUrls,
} from '@bigbluebutton/tldraw';
import '@bigbluebutton/tldraw/tldraw.css';
// eslint-disable-next-line import/no-extraneous-dependencies
import { compressToBase64, decompressFromBase64 } from 'lz-string';
import SlideCalcUtil, { HUNDRED_PERCENT } from '/imports/utils/slideCalcUtils';
import meetingClientSettingsInitialValues from '/imports/ui/core/initial-values/meetingClientSettings';
import getFromUserSettings from '/imports/ui/services/users-settings';
import KEY_CODES from '/imports/utils/keyCodes';
import Styled from './styles';
import {
mapLanguage,
isValidShapeType,
usePrevious,
getDifferences,
} from './utils';
import { useMouseEvents, useCursor } from './hooks';
import { notifyShapeNumberExceeded, getCustomEditorAssetUrls, getCustomAssetUrls } from './service';
import NoopTool from './custom-tools/noop-tool/component';
const CAMERA_TYPE = 'camera';
// Helper functions
const deleteLocalStorageItemsWithPrefix = (prefix) => {
const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith(prefix));
keysToRemove.forEach((key) => localStorage.removeItem(key));
};
// Example of typical LocalStorage entry tldraw creates:
// `{ TLDRAW_USER_DATA_v3: '{"version":2,"user":{"id":"epDk1 ...`
const clearTldrawCache = () => {
deleteLocalStorageItemsWithPrefix('TLDRAW');
};
const createCamera = (pageId, zoomLevel) => ({
id: `camera:page:${pageId}`,
meta: {},
typeName: CAMERA_TYPE,
x: 0,
y: 0,
z: zoomLevel,
});
const defaultUser = {
userId: '',
};
const Whiteboard = React.memo((props) => {
const {
isPresenter = false,
removeShapes,
persistShapeWrapper,
shapes,
assets,
currentUser = defaultUser,
whiteboardId = undefined,
zoomSlide,
curPageNum: curPageId,
zoomChanger,
isMultiUserActive,
isRTL,
fitToWidth,
zoomValue,
colorStyle,
dashStyle,
fillStyle,
fontStyle,
sizeStyle,
presentationAreaHeight,
presentationAreaWidth,
setTldrawIsMounting,
setTldrawAPI,
whiteboardToolbarAutoHide,
toggleToolsAnimations,
animations,
isToolbarVisible,
isModerator,
currentPresentationPage,
presentationId = undefined,
hasWBAccess,
bgShape,
publishCursorUpdate,
otherCursors,
hideViewersCursor,
presentationWidth,
presentationHeight,
skipToSlide,
intl,
maxNumberOfAnnotations,
notifyNotAllowedChange,
locale,
darkTheme,
selectedLayout,
isInfiniteWhiteboard,
whiteboardWriters,
} = props;
clearTldrawCache();
const [tlEditor, setTlEditor] = React.useState(null);
const [isMounting, setIsMounting] = React.useState(true);
if (isMounting) {
setDefaultEditorAssetUrls(getCustomEditorAssetUrls());
setDefaultUiAssetUrls(getCustomAssetUrls());
}
const whiteboardRef = React.useRef(null);
const zoomValueRef = React.useRef(null);
const prevShapesRef = React.useRef(shapes);
const tlEditorRef = React.useRef(tlEditor);
const slideChanged = React.useRef(false);
const slideNext = React.useRef(null);
const prevZoomValueRef = React.useRef(null);
const initialZoomRef = useRef(null);
const isMouseDownRef = useRef(false);
const shapeBatchRef = useRef({});
const isMountedRef = useRef(false);
const isWheelZoomRef = useRef(false);
const isPresenterRef = useRef(isPresenter);
const fitToWidthRef = useRef(fitToWidth);
const whiteboardIdRef = React.useRef(whiteboardId);
const curPageIdRef = React.useRef(curPageId);
const hasWBAccessRef = React.useRef(hasWBAccess);
const isModeratorRef = React.useRef(isModerator);
const currentPresentationPageRef = React.useRef(currentPresentationPage);
const initialViewBoxWidthRef = React.useRef(null);
const initialViewBoxHeightRef = React.useRef(null);
const previousTool = React.useRef(null);
const THRESHOLD = 0.1;
const CAMERA_UPDATE_DELAY = 650;
const lastKnownHeight = React.useRef(presentationAreaHeight);
const lastKnownWidth = React.useRef(presentationAreaWidth);
// eslint-disable-next-line no-unused-vars
const [shapesVersion, setShapesVersion] = React.useState(0);
const customTools = [NoopTool];
const presenterChanged = usePrevious(isPresenter) !== isPresenter;
let clipboardContent = null;
let isPasting = false;
let pasteTimeout = null;
const setIsMouseDown = (val) => {
isMouseDownRef.current = val;
};
const setIsWheelZoom = (val) => {
isWheelZoomRef.current = val;
};
const setWheelZoomTimeout = () => {
isWheelZoomRef.currentTimeout = setTimeout(() => {
setIsWheelZoom(false);
}, 300);
};
React.useEffect(() => {
currentPresentationPageRef.current = currentPresentationPage;
}, [currentPresentationPage]);
React.useEffect(() => {
curPageIdRef.current = curPageId;
}, [curPageId]);
React.useEffect(() => {
isModeratorRef.current = isModerator;
}, [isModerator]);
React.useEffect(() => {
whiteboardIdRef.current = whiteboardId;
}, [whiteboardId]);
React.useEffect(() => {
hasWBAccessRef.current = hasWBAccess;
if (!hasWBAccess && !isPresenter) {
tlEditorRef?.current?.setCurrentTool('noop');
} else if (hasWBAccess && !isPresenter) {
tlEditorRef?.current?.setCurrentTool('draw');
}
}, [hasWBAccess]);
React.useEffect(() => {
isPresenterRef.current = isPresenter;
if (!hasWBAccessRef.current && !isPresenter) {
tlEditorRef?.current?.setCurrentTool('noop');
}
}, [isPresenter]);
React.useEffect(() => {
fitToWidthRef.current = fitToWidth;
}, [fitToWidth]);
React.useEffect(() => {
if (!isEqual(prevShapesRef.current, shapes)) {
prevShapesRef.current = shapes;
setShapesVersion((v) => v + 1);
}
}, [shapes]);
const handleCopy = useCallback(() => {
const selectedShapes = tlEditorRef.current?.getSelectedShapes();
if (!selectedShapes || selectedShapes.length === 0) {
return;
}
const content = tlEditorRef.current?.getContentFromCurrentPage(
selectedShapes.map((shape) => shape.id),
);
if (content) {
clipboardContent = content;
const stringifiedClipboard = compressToBase64(
JSON.stringify({
type: 'application/tldraw',
kind: 'content',
data: content,
}),
);
if (navigator.clipboard?.write) {
const htmlBlob = new Blob([`${stringifiedClipboard}`], {
type: 'text/html',
});
navigator.clipboard.write([
new ClipboardItem({
'text/html': htmlBlob,
'text/plain': new Blob([''], { type: 'text/plain' }),
}),
]);
} else if (navigator.clipboard.writeText) {
navigator.clipboard.writeText(`${stringifiedClipboard}`);
}
}
}, [tlEditorRef]);
const handleCut = useCallback((shouldCopy) => {
const selectedShapes = tlEditorRef.current?.getSelectedShapes();
if (!selectedShapes || selectedShapes.length === 0) {
return;
}
if (shouldCopy) {
handleCopy();
}
tlEditorRef.current?.deleteShapes(selectedShapes.map((shape) => shape.id));
}, [tlEditorRef]);
const pasteTldrawContent = (editor, clipboard, point) => {
const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : undefined);
editor.mark('paste');
editor.putContentOntoCurrentPage(clipboard, {
point: p,
select: true,
});
};
const handlePaste = useCallback(() => {
if (isPasting) {
return;
}
isPasting = true;
clearTimeout(pasteTimeout);
pasteTimeout = setTimeout(() => {
if (clipboardContent) {
pasteTldrawContent(tlEditorRef.current, clipboardContent);
isPasting = false;
} else {
navigator.clipboard.readText().then((text) => {
const match = text.match(/(.*)<\/tldraw>/);
if (match && match[1]) {
const content = JSON.parse(decompressFromBase64(match[1]));
pasteTldrawContent(tlEditorRef.current, content);
}
isPasting = false;
}).catch(() => {
isPasting = false;
});
}
}, 100);
}, [tlEditorRef]);
const handleKeyDown = useCallback((event) => {
if (event.repeat) {
event.preventDefault();
event.stopPropagation();
return;
}
// ignore if the edit link dialog is open
if (document.querySelector('h2.tlui-dialog__header__title')?.textContent === 'Edit link') {
return;
}
const key = event.key.toLowerCase();
if (key === 'escape' || event.keyCode === 27) {
tlEditorRef.current?.deselect(...tlEditorRef.current?.getSelectedShapes());
return;
}
const editingShape = tlEditorRef.current?.getEditingShape();
if (editingShape && (isPresenterRef.current || hasWBAccessRef.current)) {
return;
}
if (key === 'delete') {
handleCut(false);
return;
}
if (key === ' ' && tlEditorRef.current?.getCurrentToolId() !== 'hand' && isPresenterRef.current) {
previousTool.current = tlEditorRef.current?.getCurrentToolId();
tlEditorRef.current?.setCurrentTool('hand');
return;
}
// Mapping of simple key shortcuts to tldraw functions
const simpleKeyMap = {
v: () => tlEditorRef.current?.setCurrentTool('select'),
d: () => tlEditorRef.current?.setCurrentTool('draw'),
e: () => tlEditorRef.current?.setCurrentTool('eraser'),
h: () => tlEditorRef.current?.setCurrentTool('hand'),
r: () => tlEditorRef.current?.setCurrentTool('rectangle'),
o: () => tlEditorRef.current?.setCurrentTool('ellipse'),
a: () => tlEditorRef.current?.setCurrentTool('arrow'),
l: () => tlEditorRef.current?.setCurrentTool('line'),
t: () => tlEditorRef.current?.setCurrentTool('text'),
f: () => tlEditorRef.current?.setCurrentTool('frame'),
n: () => tlEditorRef.current?.setCurrentTool('note'),
};
if (event.ctrlKey || event.metaKey) {
if (key === 'z') {
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) {
// Redo (Ctrl + Shift + z)
tlEditorRef.current?.redo();
} else {
// Undo (Ctrl + z)
tlEditorRef.current?.undo();
}
return;
}
const ctrlKeyMap = {
a: () => {
tlEditorRef.current?.selectAll();
tlEditorRef.current?.setCurrentTool('select');
},
d: () => {
tlEditorRef.current
?.duplicateShapes(tlEditorRef.current?.getSelectedShapes(), { x: 35, y: 35 });
tlEditorRef.current?.selectNone();
},
x: () => {
handleCut(true);
},
c: () => {
handleCopy();
},
v: () => {
if (!isPasting) {
handlePaste();
}
},
};
if (ctrlKeyMap[key]) {
event.preventDefault();
event.stopPropagation();
ctrlKeyMap[key]();
return;
}
}
if (simpleKeyMap[key]) {
event.preventDefault();
event.stopPropagation();
simpleKeyMap[key]();
return;
}
const moveDistance = 10;
const selectedShapes = tlEditorRef.current?.getSelectedShapes().map((shape) => shape.id);
const arrowKeyMap = {
ArrowUp: { x: 0, y: -moveDistance },
ArrowDown: { x: 0, y: moveDistance },
ArrowLeft: { x: -moveDistance, y: 0 },
ArrowRight: { x: moveDistance, y: 0 },
};
if (arrowKeyMap[event.key]) {
event.preventDefault();
event.stopPropagation();
tlEditorRef.current?.nudgeShapes(selectedShapes, arrowKeyMap[event.key], { squashing: true });
}
}, [
tlEditorRef, isPresenterRef, hasWBAccessRef, previousTool, handleCut, handleCopy, handlePaste,
]);
React.useEffect(() => {
if (whiteboardRef.current) {
whiteboardRef.current.addEventListener('keydown', handleKeyDown, {
capture: true,
});
}
return () => {
whiteboardRef.current?.removeEventListener('keydown', handleKeyDown);
};
}, [whiteboardRef.current]);
const language = React.useMemo(() => mapLanguage(locale?.toLowerCase() || 'en'), [locale]);
const [cursorPosition, updateCursorPosition] = useCursor(
publishCursorUpdate,
whiteboardIdRef.current,
);
const setCamera = (zoom, x = 0, y = 0) => {
if (tlEditorRef.current) {
tlEditorRef.current.setCamera({ x, y, z: zoom }, { duration: 175 });
}
};
const calculateZoomValue = (localWidth, localHeight) => {
const calcedZoom = fitToWidth
? presentationAreaWidth / localWidth
: Math.min(
presentationAreaWidth / localWidth,
presentationAreaHeight / localHeight,
);
return calcedZoom === 0 || calcedZoom === Infinity
? HUNDRED_PERCENT
: calcedZoom;
};
const adjustCameraOnMount = (includeViewerLogic = true) => {
if (presenterChanged) {
localStorage.removeItem('initialViewBoxWidth');
localStorage.removeItem('initialViewBoxHeight');
}
const storedWidth = localStorage.getItem('initialViewBoxWidth');
const storedHeight = localStorage.getItem('initialViewBoxHeight');
if (storedWidth && storedHeight) {
initialViewBoxWidthRef.current = parseFloat(storedWidth);
initialViewBoxHeightRef.current = parseFloat(storedHeight);
} else {
const currentZoomLevel = currentPresentationPageRef.current.scaledWidth
/ currentPresentationPageRef.current.scaledViewBoxWidth;
const calculatedWidth = currentZoomLevel !== 1
? currentPresentationPageRef.current.scaledWidth / currentZoomLevel
: currentPresentationPageRef.current.scaledWidth;
const calculatedHeight = currentZoomLevel !== 1
? currentPresentationPageRef.current.scaledHeight / currentZoomLevel
: currentPresentationPageRef.current.scaledHeight;
initialViewBoxWidthRef.current = calculatedWidth;
initialViewBoxHeightRef.current = calculatedHeight;
localStorage.setItem('initialViewBoxWidth', calculatedWidth.toString());
localStorage.setItem('initialViewBoxHeight', calculatedHeight.toString());
}
setTimeout(() => {
if (
presentationAreaHeight > 0
&& presentationAreaWidth > 0
&& currentPresentationPageRef.current
&& currentPresentationPageRef.current.scaledWidth > 0
&& currentPresentationPageRef.current.scaledHeight > 0
) {
const adjustedPresentationAreaHeight = isPresenter
? presentationAreaHeight - 40
: presentationAreaHeight;
let baseZoom;
if (isPresenter) {
baseZoom = fitToWidth
? presentationAreaWidth / currentPresentationPageRef.current.scaledWidth
: Math.min(
presentationAreaWidth / currentPresentationPageRef.current.scaledWidth,
adjustedPresentationAreaHeight / currentPresentationPageRef.current.scaledHeight,
);
const zoomAdjustmentFactor = currentPresentationPageRef.current.scaledWidth
/ currentPresentationPageRef.current.scaledViewBoxWidth;
baseZoom *= zoomAdjustmentFactor;
const adjustedXPos = currentPresentationPageRef.current.xOffset;
const adjustedYPos = currentPresentationPageRef.current.yOffset;
setCamera(baseZoom, adjustedXPos, adjustedYPos);
} else if (includeViewerLogic) {
baseZoom = calculateZoomValue(
currentPresentationPage.scaledViewBoxWidth,
currentPresentationPage.scaledViewBoxHeight,
);
const presenterXOffset = currentPresentationPageRef.current.xOffset;
const presenterYOffset = currentPresentationPageRef.current.yOffset;
const adjustedXPos = isInfiniteWhiteboard
? presenterXOffset
: currentPresentationPage.xOffset;
const adjustedYPos = isInfiniteWhiteboard
? presenterYOffset
: currentPresentationPage.yOffset;
setCamera(baseZoom, adjustedXPos, adjustedYPos);
}
}
}, 200);
};
const handleTldrawMount = (editor) => {
setTlEditor(editor);
setTldrawAPI(editor);
editor?.user?.updateUserPreferences({ locale: language });
const colorStyles = [
'black',
'blue',
'green',
'grey',
'light-blue',
'light-green',
'light-red',
'light-violet',
'orange',
'red',
'violet',
'yellow',
];
const dashStyles = ['dashed', 'dotted', 'draw', 'solid'];
const fillStyles = ['none', 'pattern', 'semi', 'solid'];
const fontStyles = ['draw', 'mono', 'sans', 'serif'];
const sizeStyles = ['l', 'm', 's', 'xl'];
if (colorStyles.includes(colorStyle)) {
editor.setStyleForNextShapes(DefaultColorStyle, colorStyle);
}
if (dashStyles.includes(dashStyle)) {
editor.setStyleForNextShapes(DefaultDashStyle, dashStyle);
}
if (fillStyles.includes(fillStyle)) {
editor.setStyleForNextShapes(DefaultFillStyle, fillStyle);
}
if (fontStyles.includes(fontStyle)) {
editor.setStyleForNextShapes(DefaultFontStyle, fontStyle);
}
if (sizeStyles.includes(sizeStyle)) {
editor.setStyleForNextShapes(DefaultSizeStyle, sizeStyle);
}
editor.store.listen(
(entry) => {
const { changes } = entry;
const { added, updated, removed } = changes;
const addedCount = Object.keys(added).length;
const localShapes = editor.getCurrentPageShapes();
const filteredShapes = localShapes?.filter((item) => item?.index !== 'a0') || [];
const shapeNumberExceeded = filteredShapes
.length + addedCount - 1 > maxNumberOfAnnotations;
const invalidShapeType = Object.keys(added).find((id) => !isValidShapeType(added[id]));
if (addedCount > 0 && (shapeNumberExceeded || invalidShapeType)) {
// notify and undo last command without persisting
// to not generate the onUndo/onRedo callback
if (shapeNumberExceeded) {
notifyShapeNumberExceeded(intl, maxNumberOfAnnotations);
} else {
notifyNotAllowedChange(intl);
}
// use remote to not trigger unwanted updates
editor.store.mergeRemoteChanges(() => {
editor.history.undo({ persist: false });
const tool = editor.getCurrentToolId();
editor.setCurrentTool('noop');
editor.setCurrentTool(tool);
});
} else {
// Add new shapes to the batch
Object.values(added).forEach((record) => {
const updatedRecord = {
...record,
meta: {
...record.meta,
createdBy: currentUser?.userId,
},
};
shapeBatchRef.current[updatedRecord.id] = updatedRecord;
});
}
// Update existing shapes and add them to the batch
Object.values(updated).forEach(([, record]) => {
const createdBy = prevShapesRef.current[record?.id]?.meta?.createdBy
|| currentUser?.userId;
const updatedRecord = {
...record,
meta: {
createdBy,
updatedBy: currentUser?.userId,
},
};
const diff = getDifferences(prevShapesRef.current[record?.id], updatedRecord);
if (diff) {
diff.id = record.id;
shapeBatchRef.current[updatedRecord.id] = diff;
} else {
shapeBatchRef.current[updatedRecord.id] = updatedRecord;
}
});
// Handle removed shapes immediately (not batched)
const idsToRemove = Object.keys(removed);
if (idsToRemove.length > 0) {
removeShapes(idsToRemove);
}
},
{ source: 'user', scope: 'document' },
);
editor.store.listen(
(entry) => {
const { changes } = entry;
const { updated } = changes;
const { 'pointer:pointer': pointers } = updated;
const path = editor.getPath();
if ((isPresenterRef.current || hasWBAccessRef.current) && pointers) {
const [, nextPointer] = pointers;
updateCursorPosition(nextPointer?.x, nextPointer?.y);
}
const camKey = `camera:page:${curPageIdRef.current}`;
const { [camKey]: cameras } = updated;
if (cameras) {
const [prevCam, nextCam] = cameras;
const panned = prevCam.x !== nextCam.x || prevCam.y !== nextCam.y;
const zoomed = prevCam.z !== nextCam.z;
if ((panned || (zoomed && fitToWidthRef.current)) && isPresenterRef.current) {
const viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
editor?.getViewportPageBounds()?.w,
currentPresentationPageRef.current?.scaledWidth,
);
const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
editor?.getViewportPageBounds()?.h,
currentPresentationPageRef.current?.scaledHeight,
);
zoomSlide(
viewedRegionW, viewedRegionH, nextCam.x, nextCam.y,
currentPresentationPageRef.current,
);
}
}
// Check for idle states and persist the batch if there are shapes
if (path === 'select.idle' || path === 'draw.idle' || path === 'select.editing_shape' || path === 'highlight.idle') {
if (Object.keys(shapeBatchRef.current).length > 0) {
const shapesToPersist = Object.values(shapeBatchRef.current);
shapesToPersist.forEach((shape) => {
persistShapeWrapper(
shape,
whiteboardIdRef.current,
isModeratorRef.current,
);
});
shapeBatchRef.current = {};
}
}
},
{ source: 'user' },
);
if (editor && curPageIdRef.current) {
const pages = [
{
meta: {},
id: `page:${curPageIdRef.current}`,
name: `Slide ${curPageIdRef.current}`,
index: 'a1',
typeName: 'page',
},
];
editor.store.mergeRemoteChanges(() => {
editor.batch(() => {
editor.store.put(pages);
editor.store.put(assets);
editor.setCurrentPage(`page:${curPageIdRef.current}`);
editor.store.put(bgShape);
editor.history.clear();
});
});
const remoteShapes = shapes;
const localShapes = editor.store.allRecords();
const filteredShapes = localShapes.filter((item) => item?.typeName === 'shape') || [];
const localShapesObj = {};
filteredShapes.forEach((shape) => {
localShapesObj[shape.id] = shape;
});
const shapesToAdd = [];
Object.keys(remoteShapes).forEach((id) => {
if (
!localShapesObj[id]
|| JSON.stringify(remoteShapes[id])
!== JSON.stringify(localShapesObj[id])
) {
shapesToAdd.push(remoteShapes[id]);
}
});
editor.store.mergeRemoteChanges(() => {
if (shapesToAdd && shapesToAdd.length) {
shapesToAdd.forEach((shape) => {
const newShape = shape;
delete newShape.isModerator;
delete newShape.questionType;
});
editor.store.put(shapesToAdd);
}
});
// eslint-disable-next-line no-param-reassign
editor.store.onBeforeChange = (prev, next) => {
const newNext = next;
if (next?.typeName === 'instance_page_state') {
if (isPresenterRef.current || isModeratorRef.current) return next;
// Filter selectedShapeIds based on shape owner
if (next.selectedShapeIds.length > 0
&& !isEqual(prev.selectedShapeIds, next.selectedShapeIds)
) {
newNext.selectedShapeIds = next.selectedShapeIds.filter((shapeId) => {
const shapeOwner = prevShapesRef.current[shapeId]?.meta?.createdBy;
return !shapeOwner || shapeOwner === currentUser?.userId;
});
}
if (!isEqual(prev.hoveredShapeId, next.hoveredShapeId)) {
const hoveredShapeOwner = prevShapesRef.current[next.hoveredShapeId]?.meta?.createdBy;
if (hoveredShapeOwner !== currentUser?.userId) {
newNext.hoveredShapeId = null;
}
}
return newNext;
}
// Get viewport dimensions and bounds
const viewportPageBounds = editor.getViewportPageBounds();
const { w: viewportWidth, h: viewportHeight } = viewportPageBounds;
const presentationWidthLocal = currentPresentationPage?.scaledWidth || 0;
const presentationHeightLocal = currentPresentationPage?.scaledHeight || 0;
// Adjust camera position to ensure it stays within bounds
const panned = next?.id?.includes('camera') && (prev.x !== next.x || prev.y !== next.y);
if (panned && !currentPresentationPageRef.current?.infiniteWhiteboard) {
// Horizontal bounds check
if (next.x > 0) {
newNext.x = 0;
} else if (next.x < -(presentationWidthLocal - viewportWidth)) {
newNext.x = -(presentationWidthLocal - viewportWidth);
}
// Vertical bounds check
if (next.y > 0) {
newNext.y = 0;
} else if (next.y < -(presentationHeightLocal - viewportHeight)) {
newNext.y = -(presentationHeightLocal - viewportHeight);
}
}
return newNext;
};
if (!isPresenterRef.current && !hasWBAccessRef.current) {
editor.setCurrentTool('noop');
}
}
adjustCameraOnMount(true);
isMountedRef.current = true;
};
const shapesToRemove = React.useMemo(() => {
if (isMouseDownRef.current) return [];
const remoteShapeIds = Object.keys(prevShapesRef.current);
const localShapes = tlEditorRef.current?.getCurrentPageShapes();
const filteredShapes = localShapes?.filter((item) => item?.index !== 'a0') || [];
return filteredShapes
.filter((localShape) => !remoteShapeIds.includes(localShape.id))
.map((localShape) => localShape.id);
}, [prevShapesRef.current, curPageId]);
const { shapesToAdd, shapesToUpdate } = React.useMemo(() => {
const toAdd = [];
const toUpdate = [];
Object.values(prevShapesRef.current).forEach((remoteShape) => {
if (!remoteShape.id) return;
const localShapes = tlEditorRef.current?.getCurrentPageShapes();
const filteredShapes = localShapes?.filter((item) => item?.index !== 'a0') || [];
const localLookup = new Map(
filteredShapes.map((shape) => [shape.id, shape]),
);
const localShape = localLookup.get(remoteShape.id);
if (!localShape) {
const newRemoteShape = remoteShape;
delete newRemoteShape.isModerator;
delete newRemoteShape.questionType;
toAdd.push(newRemoteShape);
} else {
const remoteShapeMeta = remoteShape?.meta;
const isCreatedByCurrentUser = remoteShapeMeta?.createdBy === currentUser?.userId;
const isUpdatedByCurrentUser = remoteShapeMeta?.updatedBy === currentUser?.userId;
// System-level shapes (background image) lack createdBy
// and updatedBy metadata, which can cause false positives.
// These cases expect an early return and shouldn't be updated.
if (
remoteShapeMeta && (
(isCreatedByCurrentUser && isUpdatedByCurrentUser)
|| (!isCreatedByCurrentUser && isUpdatedByCurrentUser)
)
) {
return;
}
const diff = remoteShape;
delete diff.isModerator;
delete diff.questionType;
toUpdate.push(diff);
}
});
return {
shapesToAdd: toAdd,
shapesToUpdate: toUpdate,
};
}, [prevShapesRef.current, curPageId]);
const calculateZoomWithGapValue = (
localWidth,
localHeight,
widthAdjustment = 0,
) => {
const presentationWidthLocal = presentationAreaWidth - widthAdjustment;
const calcedZoom = (fitToWidth
? presentationWidthLocal / localWidth
: Math.min(
presentationWidthLocal / localWidth,
presentationAreaHeight / localHeight,
));
return calcedZoom === 0 || calcedZoom === Infinity
? HUNDRED_PERCENT
: calcedZoom;
};
useMouseEvents(
{
whiteboardRef, tlEditorRef, isWheelZoomRef, initialZoomRef, isPresenterRef,
},
{
hasWBAccess: hasWBAccessRef.current,
whiteboardToolbarAutoHide,
animations,
publishCursorUpdate,
whiteboardId: whiteboardIdRef.current,
cursorPosition,
updateCursorPosition,
toggleToolsAnimations,
currentPresentationPage,
zoomChanger,
setIsMouseDown,
setIsWheelZoom,
setWheelZoomTimeout,
},
);
React.useEffect(() => {
tlEditorRef.current = tlEditor;
}, [tlEditor]);
React.useEffect(() => {
const handleArrowPress = (event) => {
const currPageNum = parseInt(curPageIdRef.current, 10);
const shapeSelected = tlEditorRef.current.getSelectedShapes()?.length > 0;
const changeSlide = (direction) => {
if (!currentPresentationPage) return;
const newSlideNum = currPageNum + direction;
const outOfBounds = direction > 0
? newSlideNum > currentPresentationPage?.totalPages
: newSlideNum < 1;
if (outOfBounds) return;
skipToSlide(newSlideNum);
zoomChanger(HUNDRED_PERCENT);
zoomSlide(HUNDRED_PERCENT, HUNDRED_PERCENT, 0, 0);
};
if (!shapeSelected) {
if (event.keyCode === KEY_CODES.ARROW_RIGHT) {
changeSlide(1); // Move to the next slide
} else if (event.keyCode === KEY_CODES.ARROW_LEFT) {
changeSlide(-1); // Move to the previous slide
}
}
};
const handleKeyDown2 = (event) => {
if (
(event.keyCode === KEY_CODES.ARROW_RIGHT
|| event.keyCode === KEY_CODES.ARROW_LEFT)
&& isPresenterRef.current
) {
handleArrowPress(event);
}
};
const handleKeyUp = (event) => {
if (event.key === ' ') {
if (previousTool.current) {
tlEditorRef.current?.setCurrentTool(previousTool.current);
previousTool.current = null;
}
}
};
whiteboardRef.current?.addEventListener('keydown', handleKeyDown2, {
capture: true,
});
whiteboardRef.current?.addEventListener('keyup', handleKeyUp, {
capture: true,
});
return () => {
whiteboardRef.current?.removeEventListener('keydown', handleKeyDown2);
whiteboardRef.current?.removeEventListener('keyup', handleKeyUp);
};
}, [whiteboardRef.current]);
React.useEffect(() => {
zoomValueRef.current = zoomValue;
let timeoutId = null;
if (
tlEditor
&& curPageIdRef.current
&& currentPresentationPage
&& isPresenter
&& isWheelZoomRef.current === false
) {
const zoomLevelForReset = (fitToWidthRef.current || !initialZoomRef.current)
? calculateZoomValue(
currentPresentationPage.scaledWidth,
currentPresentationPage.scaledHeight,
) : initialZoomRef.current;
const zoomCamera = zoomValue === HUNDRED_PERCENT
? zoomLevelForReset
: (zoomLevelForReset * zoomValue) / HUNDRED_PERCENT;
const camera = tlEditorRef.current.getCamera();
const nextCamera = {
x:
zoomValue === HUNDRED_PERCENT
? 0
: (camera.x
+ (tlEditorRef.current.getViewportPageBounds().w / 2 / zoomCamera
- tlEditorRef.current.getViewportPageBounds().w / 2 / camera.z)),
y:
zoomValue === HUNDRED_PERCENT
? 0
: (camera.y
+ (tlEditorRef.current.getViewportPageBounds().h / 2 / zoomCamera
- tlEditorRef.current.getViewportPageBounds().h / 2 / camera.z)),
z: zoomCamera,
};
if (
zoomValue !== prevZoomValueRef.current
) {
tlEditor.setCamera(nextCamera, { duration: 175 });
timeoutId = setTimeout(() => {
if (zoomValue === HUNDRED_PERCENT && !fitToWidthRef.current) {
zoomChanger(HUNDRED_PERCENT);
zoomSlide(HUNDRED_PERCENT, HUNDRED_PERCENT, 0, 0);
} else {
// Recalculate viewed region width and height for zoomSlide call
const viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
tlEditorRef.current.getViewportPageBounds().w,
currentPresentationPage.scaledWidth,
);
const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
tlEditorRef.current.getViewportPageBounds().h,
currentPresentationPage.scaledHeight,
);
zoomSlide(viewedRegionW, viewedRegionH, nextCamera.x, nextCamera.y);
}
}, 500);
}
}
// Update the previous zoom value ref with the current zoom value
prevZoomValueRef.current = zoomValue;
return () => clearTimeout(timeoutId);
}, [zoomValue, tlEditor, curPageId, isWheelZoomRef.current, fitToWidthRef.current]);
React.useEffect(() => {
// A slight delay to ensure the canvas has rendered
const timeoutId = setTimeout(() => {
if (
currentPresentationPage.scaledWidth > 0
&& currentPresentationPage.scaledHeight > 0
) {
// Subtract the toolbar height from the presentation area height for the presenter
const adjustedPresentationAreaHeight = isPresenter
? presentationAreaHeight - 40
: presentationAreaHeight;
const slideAspectRatio = currentPresentationPage.scaledWidth
/ currentPresentationPage.scaledHeight;
const presentationAreaAspectRatio = presentationAreaWidth / adjustedPresentationAreaHeight;
let initialZoom;
if (slideAspectRatio > presentationAreaAspectRatio
|| (fitToWidthRef.current && isPresenter)
) {
initialZoom = presentationAreaWidth / currentPresentationPage.scaledWidth;
} else {
initialZoom = adjustedPresentationAreaHeight
/ currentPresentationPage.scaledHeight;
}
initialZoomRef.current = initialZoom;
prevZoomValueRef.current = zoomValue;
}
}, CAMERA_UPDATE_DELAY);
return () => clearTimeout(timeoutId);
}, [
currentPresentationPage.scaledHeight,
currentPresentationPage.scaledWidth,
presentationAreaWidth,
presentationAreaHeight,
presentationWidth,
presentationHeight,
isPresenter,
presentationId,
fitToWidthRef.current,
]);
React.useEffect(() => {
if (fitToWidthRef.current && isPresenterRef.current) {
// when presentationHeight changes and we are using fitToWidht
// the zoom level and camera position stays same
// so we have to send the updated viewed area to server manually
const handleHeightChanged = () => {
const viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
tlEditorRef.current?.getViewportPageBounds()?.w,
currentPresentationPageRef.current?.scaledWidth,
);
const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
tlEditorRef.current?.getViewportPageBounds()?.h,
currentPresentationPageRef.current?.scaledHeight,
);
const camera = tlEditorRef.current.getCamera();
zoomSlide(
viewedRegionW, viewedRegionH, camera.x, camera.y,
currentPresentationPageRef.current,
);
};
const timeoutId = setTimeout(handleHeightChanged, CAMERA_UPDATE_DELAY);
return () => clearTimeout(timeoutId);
}
return () => null;
}, [
presentationHeight,
fitToWidthRef.current,
isPresenterRef.current,
]);
React.useEffect(() => {
const handleResize = () => {
if (!initialViewBoxWidthRef.current) {
initialViewBoxWidthRef.current = currentPresentationPageRef.current?.scaledViewBoxWidth;
}
if (!initialViewBoxHeightRef.current) {
initialViewBoxHeightRef.current = currentPresentationPageRef.current?.scaledViewBoxHeight;
}
// Calculate the absolute difference
const heightDifference = Math.abs(
presentationAreaHeight - lastKnownHeight.current,
);
const widthDifference = Math.abs(
presentationAreaWidth - lastKnownWidth.current,
);
// Check if the difference is greater than the threshold
if (heightDifference > THRESHOLD || widthDifference > THRESHOLD) {
// Update the last known dimensions
lastKnownHeight.current = presentationAreaHeight;
lastKnownWidth.current = presentationAreaWidth;
if (
presentationAreaHeight > 0
&& presentationAreaWidth > 0
&& tlEditor
&& currentPresentationPage
&& currentPresentationPage.scaledWidth > 0
&& currentPresentationPage.scaledHeight > 0
) {
const currentZoom = zoomValueRef.current || HUNDRED_PERCENT;
const baseZoom = calculateZoomValue(
currentPresentationPageRef.current.scaledWidth,
currentPresentationPageRef.current.scaledHeight,
);
let adjustedZoom = baseZoom * (currentZoom / HUNDRED_PERCENT);
if (isPresenter) {
const container = document.querySelector(
'[data-test="presentationContainer"]',
);
const innerWrapper = document.getElementById(
'presentationInnerWrapper',
);
const containerWidth = container ? container.offsetWidth : 0;
const innerWrapperWidth = innerWrapper
? innerWrapper.offsetWidth
: 0;
const widthGap = Math.max(containerWidth - innerWrapperWidth, 0);
const camera = tlEditorRef.current.getCamera();
if (widthGap > 0) {
adjustedZoom = calculateZoomWithGapValue(
currentPresentationPageRef.current.scaledWidth,
currentPresentationPageRef.current.scaledHeight,
widthGap,
);
adjustedZoom *= currentZoom / HUNDRED_PERCENT;
} else {
adjustedZoom = baseZoom * (currentZoom / HUNDRED_PERCENT);
}
const zoomToApply = widthGap > 0
? adjustedZoom
: baseZoom * (currentZoom / HUNDRED_PERCENT);
const formattedPageId = Number(curPageIdRef?.current);
const updatedCurrentCam = {
...camera,
z: adjustedZoom,
};
let cameras = [
createCamera(formattedPageId - 1, zoomToApply),
updatedCurrentCam,
createCamera(formattedPageId + 1, zoomToApply),
];
cameras = cameras.filter((cam) => cam.id !== 'camera:page:0');
tlEditorRef.current.store.put(cameras);
} else {
// Viewer logic
const newZoom = calculateZoomValue(
currentPresentationPage.scaledViewBoxWidth,
currentPresentationPage.scaledViewBoxHeight,
);
const camera = tlEditorRef.current.getCamera();
const formattedPageId = Number(curPageIdRef?.current);
const updatedCurrentCam = {
...camera,
z: newZoom,
};
let cameras = [
createCamera(formattedPageId - 1, newZoom),
updatedCurrentCam,
createCamera(formattedPageId + 1, newZoom),
];
cameras = cameras.filter((cam) => cam.id !== 'camera:page:0');
tlEditorRef.current.store.put(cameras);
}
}
}
};
const timeoutId = setTimeout(handleResize, CAMERA_UPDATE_DELAY);
return () => clearTimeout(timeoutId);
}, [presentationAreaHeight, presentationAreaWidth, curPageId, presentationId]);
React.useEffect(() => {
if (!fitToWidth && isPresenter) {
zoomChanger(HUNDRED_PERCENT);
zoomSlide(HUNDRED_PERCENT, HUNDRED_PERCENT, 0, 0);
}
}, [fitToWidth, isPresenter]);
React.useEffect(() => {
if (!isPresenter
&& tlEditorRef.current
&& initialViewBoxWidthRef.current
&& initialViewBoxHeightRef.current
&& currentPresentationPage
) {
const newZoom = calculateZoomValue(
currentPresentationPage.scaledViewBoxWidth,
currentPresentationPage.scaledViewBoxHeight,
);
const adjustedXPos = currentPresentationPage.xOffset;
const adjustedYPos = currentPresentationPage.yOffset;
setCamera(
newZoom,
adjustedXPos,
adjustedYPos,
);
}
}, [currentPresentationPage, isPresenter]);
React.useEffect(() => {
if (shapesToAdd.length || shapesToUpdate.length || shapesToRemove.length) {
const tlStoreUpdateTimeoutId = setTimeout(() => {
tlEditor?.store?.mergeRemoteChanges(() => {
if (shapesToRemove.length > 0) {
tlEditor?.store?.remove(shapesToRemove);
}
if (shapesToAdd.length) {
tlEditor?.store?.put(shapesToAdd);
}
if (shapesToUpdate.length) {
const updatedShapes = shapesToUpdate.map((shape) => {
const currentShape = tlEditor?.getShape(shape.id);
if (currentShape) {
return { ...currentShape, ...shape };
}
return null;
}).filter(Boolean);
if (updatedShapes.length) {
tlEditor?.store?.put(updatedShapes);
}
}
});
}, 300);
return () => clearTimeout(tlStoreUpdateTimeoutId);
}
return undefined;
}, [shapesToAdd, shapesToUpdate, shapesToRemove]);
// Updating presences in tldraw store based on changes in cursors
React.useEffect(() => {
if (tlEditorRef.current) {
const useElement = document.querySelector('.tl-cursor use');
if (useElement && !isMultiUserActive && !isPresenter) {
useElement.setAttribute('href', '#redPointer');
} else if (useElement) {
useElement.setAttribute('href', '#cursor');
}
const idsToRemove = [];
// Get all presence records from the store
const allRecords = tlEditorRef.current.store.allRecords();
const presenceRecords = allRecords.filter((record) => record.id.startsWith('instance_presence:'));
// Check if any presence records correspond to users not in whiteboardWriters
presenceRecords.forEach((record) => {
const userId = record.userId.split('instance_presence:')[1];
const hasAccessToWhiteboard = whiteboardWriters.some((writer) => writer.userId === userId);
if (!hasAccessToWhiteboard) {
idsToRemove.push(record.id);
}
});
const updatedPresences = otherCursors
.map(({
userId, user, xPercent, yPercent,
}) => {
const { presenter, name } = user;
const id = InstancePresenceRecordType.createId(userId);
const active = xPercent !== -1 && yPercent !== -1;
// if cursor is not active remove it from tldraw store
if (
!active
|| (hideViewersCursor
&& user.role === 'VIEWER'
&& !currentUser?.presenter)
|| (!presenter && !isMultiUserActive)
) {
idsToRemove.push(id);
return null;
}
const cursor = {
x: xPercent,
y: yPercent,
type: 'default',
rotation: 0,
};
const color = presenter ? '#FF0000' : '#70DB70';
const c = {
...InstancePresenceRecordType.create({
id,
currentPageId: `page:${curPageIdRef.current}`,
userId,
userName: name,
cursor,
color,
}),
lastActivityTimestamp: Date.now(),
};
return c;
})
.filter((cursor) => cursor && cursor.userId !== currentUser?.userId);
if (idsToRemove.length) {
tlEditorRef.current?.store.remove(idsToRemove);
}
// If there are any updated presences, put them all in the store
if (updatedPresences.length) {
tlEditorRef.current?.store.put(updatedPresences);
}
}
}, [otherCursors, whiteboardWriters]);
const createPage = (currentPageId) => [
{
meta: {},
id: currentPageId,
name: `Slide ${currentPageId?.split(':')[1]}`,
index: 'a1',
typeName: 'page',
},
];
const createCameras = (pageId, tlZ) => {
const cameras = [];
const MIN_PAGE_ID = 1;
const totalPages = currentPresentationPageRef.current?.totalPages || 1;
if (pageId > MIN_PAGE_ID) {
cameras.push(createCamera(pageId - 1, tlZ));
}
cameras.push(createCamera(pageId, tlZ));
if (pageId < totalPages) {
cameras.push(createCamera(pageId + 1, tlZ));
}
return cameras;
};
const cleanupStore = (currentPageId) => {
const allRecords = tlEditorRef.current.store.allRecords();
const shapeIdsToRemove = allRecords
.filter((record) => record.typeName === 'shape' && record.parentId && record.parentId !== currentPageId)
.map((shape) => shape.id);
if (shapeIdsToRemove.length > 0) {
tlEditorRef.current.deleteShapes(shapeIdsToRemove);
}
};
const updateStore = (pages, cameras) => {
tlEditorRef.current.store.put(pages);
tlEditorRef.current.store.put(cameras);
tlEditorRef.current.store.put(assets);
tlEditorRef.current.store.put(bgShape);
};
const finalizeStore = () => {
tlEditorRef.current.history.clear();
};
const toggleToolbarIfNeeded = () => {
if (whiteboardToolbarAutoHide && toggleToolsAnimations) {
toggleToolsAnimations('fade-in', 'fade-out', '0s', hasWBAccessRef.current || isPresenterRef.current);
}
};
const resetSlideState = () => {
slideChanged.current = false;
slideNext.current = null;
};
React.useEffect(() => {
const formattedPageId = parseInt(curPageIdRef.current, 10);
if (tlEditorRef.current && formattedPageId !== 0) {
const currentPageId = `page:${formattedPageId}`;
const tlZ = tlEditorRef.current.getCamera()?.z;
const pages = createPage(currentPageId);
const cameras = createCameras(formattedPageId, tlZ);
tlEditorRef.current.store.mergeRemoteChanges(() => {
tlEditorRef.current.batch(() => {
cleanupStore(currentPageId);
updateStore(pages, cameras);
tlEditorRef.current.setCurrentPage(currentPageId);
finalizeStore();
});
});
toggleToolbarIfNeeded();
resetSlideState();
}
}, [curPageId]);
React.useEffect(() => {
if (isMountedRef.current) {
adjustCameraOnMount(false);
}
}, [
isMountedRef.current,
selectedLayout,
isInfiniteWhiteboard,
fitToWidthRef.current,
presentationWidth,
curPageId,
isPresenter,
animations,
locale,
darkTheme,
]);
React.useEffect(() => {
setTldrawIsMounting(true);
return () => {
isMountedRef.current = false;
localStorage.removeItem('initialViewBoxWidth');
localStorage.removeItem('initialViewBoxHeight');
};
}, []);
React.useEffect(() => {
if (isMounting) {
setIsMounting(false);
/// brings presentation toolbar back
setTldrawIsMounting(false);
}
}, [
tlEditorRef?.current?.camera,
presentationAreaWidth,
presentationAreaHeight,
presentationId,
]);
React.useEffect(() => {
const bbbMultiUserTools = getFromUserSettings(
'bbb_multi_user_tools',
meetingClientSettingsInitialValues.public.whiteboard.toolbar.multiUserTools,
);
const allElements = document.querySelectorAll('[data-testid^="tools."]');
if (bbbMultiUserTools.length >= 1 && !isModerator) {
allElements.forEach((element) => {
const toolName = element.getAttribute('data-testid').split('.')[1];
if (!bbbMultiUserTools.includes(toolName)) {
// eslint-disable-next-line no-param-reassign
element.style.display = 'none';
}
});
}
// TODO: we should add the dependency list in [] parameter here
// so this is not run on every render
});
React.useEffect(() => {
const bbbPresenterTools = getFromUserSettings(
'bbb_presenter_tools',
meetingClientSettingsInitialValues.public.whiteboard.toolbar.presenterTools,
);
const allElements = document.querySelectorAll('[data-testid^="tools."]');
if (bbbPresenterTools.length >= 1 && isPresenter) {
allElements.forEach((element) => {
const toolName = element.getAttribute('data-testid').split('.')[1];
if (!bbbPresenterTools.includes(toolName)) {
// eslint-disable-next-line no-param-reassign
element.style.display = 'none';
}
});
}
// TODO: we should add the dependency list in [] parameter here
// so this is not run on every render
});
React.useEffect(() => {
const bbbMultiUserPenOnly = getFromUserSettings(
'bbb_multi_user_pen_only',
false,
);
const allElements = document.querySelectorAll('[data-testid^="tools."]');
if (bbbMultiUserPenOnly && !isModerator && !isPresenter) {
allElements.forEach((element) => {
const toolName = element.getAttribute('data-testid').split('.')[1];
const displayStyle = toolName !== 'draw' ? 'none' : 'flex';
// eslint-disable-next-line no-param-reassign
element.style.display = displayStyle;
});
}
// TODO: we should add the dependency list in [] parameter here
// so this is not run on every render
});
return (
);
});
export default Whiteboard;
Whiteboard.propTypes = {
isPresenter: PropTypes.bool,
removeShapes: PropTypes.func.isRequired,
persistShapeWrapper: PropTypes.func.isRequired,
notifyNotAllowedChange: PropTypes.func.isRequired,
shapes: PropTypes.objectOf(PropTypes.shape).isRequired,
assets: PropTypes.arrayOf(PropTypes.shape).isRequired,
currentUser: PropTypes.shape({
userId: PropTypes.string.isRequired,
}),
whiteboardId: PropTypes.string,
zoomSlide: PropTypes.func.isRequired,
curPageNum: PropTypes.number.isRequired,
presentationWidth: PropTypes.number.isRequired,
presentationHeight: PropTypes.number.isRequired,
zoomChanger: PropTypes.func.isRequired,
isRTL: PropTypes.bool.isRequired,
fitToWidth: PropTypes.bool.isRequired,
zoomValue: PropTypes.number.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
colorStyle: PropTypes.string.isRequired,
dashStyle: PropTypes.string.isRequired,
fillStyle: PropTypes.string.isRequired,
fontStyle: PropTypes.string.isRequired,
sizeStyle: PropTypes.string.isRequired,
presentationAreaHeight: PropTypes.number.isRequired,
presentationAreaWidth: PropTypes.number.isRequired,
maxNumberOfAnnotations: PropTypes.number.isRequired,
darkTheme: PropTypes.bool.isRequired,
setTldrawIsMounting: PropTypes.func.isRequired,
presentationId: PropTypes.string,
setTldrawAPI: PropTypes.func.isRequired,
isMultiUserActive: PropTypes.bool,
whiteboardToolbarAutoHide: PropTypes.bool,
toggleToolsAnimations: PropTypes.func.isRequired,
animations: PropTypes.bool,
isToolbarVisible: PropTypes.bool,
isModerator: PropTypes.bool,
currentPresentationPage: PropTypes.shape(),
hasWBAccess: PropTypes.bool,
bgShape: PropTypes.arrayOf(PropTypes.shape).isRequired,
publishCursorUpdate: PropTypes.func.isRequired,
otherCursors: PropTypes.arrayOf(PropTypes.shape).isRequired,
hideViewersCursor: PropTypes.bool,
skipToSlide: PropTypes.func.isRequired,
locale: PropTypes.string.isRequired,
selectedLayout: PropTypes.string.isRequired,
isInfiniteWhiteboard: PropTypes.bool,
whiteboardWriters: PropTypes.arrayOf(PropTypes.shape).isRequired,
};