1245 lines
40 KiB
JavaScript
1245 lines
40 KiB
JavaScript
import * as React from "react";
|
|
import PropTypes from "prop-types";
|
|
import { useRef } from "react";
|
|
import { debounce, isEqual } from "radash";
|
|
import {
|
|
Tldraw,
|
|
DefaultColorStyle,
|
|
DefaultDashStyle,
|
|
DefaultFillStyle,
|
|
DefaultFontStyle,
|
|
DefaultSizeStyle,
|
|
InstancePresenceRecordType,
|
|
setDefaultUiAssetUrls,
|
|
setDefaultEditorAssetUrls,
|
|
} from "@tldraw/tldraw";
|
|
import "@tldraw/tldraw/tldraw.css";
|
|
import SlideCalcUtil from "/imports/utils/slideCalcUtils";
|
|
import { HUNDRED_PERCENT } from "/imports/utils/slideCalcUtils";
|
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
import Settings from "/imports/ui/services/settings";
|
|
import KEY_CODES from "/imports/utils/keyCodes";
|
|
import Styled from "./styles";
|
|
import {
|
|
mapLanguage,
|
|
isValidShapeType,
|
|
} from "./utils";
|
|
import { useMouseEvents, useCursor } from "./hooks";
|
|
import { notifyShapeNumberExceeded, customEditorAssetUrls, customAssetUrls } from "./service";
|
|
|
|
import NoopTool from './custom-tools/noop-tool/component';
|
|
|
|
// 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 calculateEffectiveZoom = (initViewboxWidth, curViewboxWidth) => {
|
|
// Calculate the effective zoom level based on the change in viewBoxWidth
|
|
const effectiveZoomValue = (initViewboxWidth * 100) / curViewboxWidth;
|
|
return effectiveZoomValue;
|
|
};
|
|
|
|
const determineViewerFitToWidth = (currentPresentationPage) => {
|
|
return (
|
|
currentPresentationPage?.scaledViewBoxWidth ===
|
|
currentPresentationPage?.scaledWidth &&
|
|
currentPresentationPage?.scaledViewBoxHeight !==
|
|
currentPresentationPage?.scaledHeight
|
|
);
|
|
};
|
|
|
|
setDefaultEditorAssetUrls(customEditorAssetUrls);
|
|
|
|
setDefaultUiAssetUrls(customAssetUrls);
|
|
|
|
const Whiteboard = React.memo(function Whiteboard(props) {
|
|
const {
|
|
isPresenter,
|
|
removeShapes,
|
|
persistShapeWrapper,
|
|
shapes,
|
|
assets,
|
|
currentUser,
|
|
whiteboardId,
|
|
zoomSlide,
|
|
curPageId,
|
|
zoomChanger,
|
|
isMultiUserActive,
|
|
isRTL,
|
|
fitToWidth,
|
|
zoomValue,
|
|
colorStyle,
|
|
dashStyle,
|
|
fillStyle,
|
|
fontStyle,
|
|
sizeStyle,
|
|
presentationAreaHeight,
|
|
presentationAreaWidth,
|
|
setTldrawIsMounting,
|
|
setTldrawAPI,
|
|
whiteboardToolbarAutoHide,
|
|
toggleToolsAnimations,
|
|
animations,
|
|
isToolbarVisible,
|
|
isModerator,
|
|
currentPresentationPage,
|
|
presentationId,
|
|
hasWBAccess,
|
|
bgShape,
|
|
publishCursorUpdate,
|
|
otherCursors,
|
|
hideViewersCursor,
|
|
presentationHeight,
|
|
presentationWidth,
|
|
skipToSlide,
|
|
intl,
|
|
maxNumberOfAnnotations,
|
|
notifyNotAllowedChange,
|
|
} = props;
|
|
|
|
clearTldrawCache();
|
|
|
|
const [tlEditor, setTlEditor] = React.useState(null);
|
|
const [isMounting, setIsMounting] = React.useState(true);
|
|
const [initialZoomSet, setInitialZoomSet] = React.useState(false);
|
|
const [initialViewBoxWidth, setInitialViewBoxWidth] = React.useState(null);
|
|
|
|
const whiteboardRef = React.useRef(null);
|
|
const zoomValueRef = React.useRef(null);
|
|
const prevShapesRef = React.useRef(shapes);
|
|
const prevOtherCursorsRef = useRef(otherCursors);
|
|
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 isMountedRef = useRef(false);
|
|
const isWheelZoomRef = useRef(false);
|
|
const isPresenterRef = useRef(isPresenter);
|
|
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 THRESHOLD = 0.1;
|
|
const lastKnownHeight = React.useRef(presentationAreaHeight);
|
|
const lastKnownWidth = React.useRef(presentationAreaWidth);
|
|
|
|
const [shapesVersion, setShapesVersion] = React.useState(0);
|
|
|
|
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");
|
|
}
|
|
}, [hasWBAccess]);
|
|
|
|
React.useEffect(() => {
|
|
isPresenterRef.current = isPresenter;
|
|
|
|
if (!hasWBAccessRef.current && !isPresenter) {
|
|
tlEditorRef?.current?.setCurrentTool("noop");
|
|
}
|
|
}, [isPresenter]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isEqual(prevShapesRef.current, shapes)) {
|
|
prevShapesRef.current = shapes;
|
|
setShapesVersion((v) => v + 1);
|
|
}
|
|
}, [shapes]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isEqual(prevOtherCursorsRef.current, otherCursors)) {
|
|
prevOtherCursorsRef.current = otherCursors;
|
|
}
|
|
}, [otherCursors]);
|
|
|
|
React.useEffect(() => {
|
|
if (whiteboardRef.current) {
|
|
whiteboardRef.current.addEventListener("keydown", handleKeyDown, {
|
|
capture: true,
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
whiteboardRef.current?.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [whiteboardRef.current]);
|
|
|
|
const language = React.useMemo(() => {
|
|
return mapLanguage(Settings?.application?.locale?.toLowerCase() || "en");
|
|
}, [Settings?.application?.locale]);
|
|
|
|
const [cursorPosition, updateCursorPosition] = useCursor(
|
|
publishCursorUpdate,
|
|
whiteboardIdRef.current
|
|
);
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (!isPresenterRef.current) {
|
|
if (
|
|
!hasWBAccessRef.current ||
|
|
(hasWBAccessRef.current && !tlEditorRef.current?.getEditingShape())
|
|
) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTldrawMount = (editor) => {
|
|
setTlEditor(editor);
|
|
setTldrawAPI(editor);
|
|
|
|
editor?.user?.updateUserPreferences({ locale: language });
|
|
|
|
const debouncePersistShape = debounce({ delay: 0 }, persistShapeWrapper);
|
|
|
|
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 shapeNumberExceeded = Object.keys(prevShapesRef.current).length + addedCount > maxNumberOfAnnotations;
|
|
const invalidShapeType = Object.keys(added).find((id) => !isValidShapeType(added[id]));
|
|
|
|
if (shapeNumberExceeded || invalidShapeType) {
|
|
// notify and undo last command without persisting to not generate the onUndo/onRedo callback
|
|
if (shapeNumberExceeded) {
|
|
notifyShapeNumberExceeded(intl, maxNumberOfAnnotations);
|
|
} else {
|
|
notifyNotAllowedChange(intl);
|
|
}
|
|
editor.history.undo({ persist: false });
|
|
} else {
|
|
Object.values(added).forEach((record) => {
|
|
const updatedRecord = {
|
|
...record,
|
|
meta: {
|
|
...record.meta,
|
|
createdBy: currentUser?.userId,
|
|
},
|
|
};
|
|
|
|
persistShapeWrapper(
|
|
updatedRecord,
|
|
whiteboardIdRef.current,
|
|
isModeratorRef.current
|
|
);
|
|
});
|
|
}
|
|
|
|
Object.values(updated).forEach(([_, record]) => {
|
|
const createdBy =
|
|
prevShapesRef.current[record?.id]?.meta?.createdBy ||
|
|
currentUser?.userId;
|
|
const updatedRecord = {
|
|
...record,
|
|
meta: {
|
|
createdBy,
|
|
updatedBy: currentUser?.userId,
|
|
},
|
|
};
|
|
|
|
persistShapeWrapper(
|
|
updatedRecord,
|
|
whiteboardIdRef.current,
|
|
isModeratorRef.current
|
|
);
|
|
});
|
|
|
|
Object.values(removed).forEach((record) => {
|
|
removeShapes([record?.id]);
|
|
});
|
|
},
|
|
{ source: "user", scope: "document" }
|
|
);
|
|
|
|
editor.store.listen(
|
|
(entry) => {
|
|
const { changes, source } = entry;
|
|
const { updated } = changes;
|
|
const { "pointer:pointer": pointers } = updated;
|
|
|
|
if ((isPresenter || hasWBAccessRef.current) && pointers) {
|
|
const [prevPointer, 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;
|
|
|
|
if (panned && isPresenter) {
|
|
let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
|
|
editor?.getViewportPageBounds()?.w,
|
|
currentPresentationPage?.scaledWidth
|
|
);
|
|
let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
|
|
editor?.getViewportPageBounds()?.h,
|
|
currentPresentationPage?.scaledHeight
|
|
);
|
|
|
|
zoomSlide(viewedRegionW, viewedRegionH, nextCam.x, nextCam.y);
|
|
}
|
|
}
|
|
},
|
|
{ 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.deletePage(editor.currentPageId);
|
|
editor.setCurrentPage(`page:${curPageIdRef.current}`);
|
|
editor.store.put(assets);
|
|
editor.createShapes(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 = [];
|
|
for (let id in remoteShapes) {
|
|
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) => {
|
|
delete shape.isModerator;
|
|
delete shape.questionType;
|
|
});
|
|
editor.store.put(shapesToAdd);
|
|
}
|
|
});
|
|
|
|
editor.store.onBeforeChange = (prev, next, source) => {
|
|
if (next?.typeName === "instance_page_state") {
|
|
if (isPresenter || isModeratorRef.current) return next;
|
|
|
|
// Filter selectedShapeIds based on shape owner
|
|
if (
|
|
next.selectedShapeIds.length > 0 &&
|
|
!isEqual(prev.selectedShapeIds, next.selectedShapeIds)
|
|
) {
|
|
next.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) {
|
|
next.hoveredShapeId = null;
|
|
}
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
const camera = editor?.getCamera();
|
|
const { maxX, maxY, minX, minY } = editor.getViewportPageBounds();
|
|
const panned =
|
|
next?.id?.includes("camera") &&
|
|
(prev.x !== next.x || prev.y !== next.y);
|
|
const zoomed = next?.id?.includes("camera") && prev.z !== next.z;
|
|
if (panned) {
|
|
// // limit bounds
|
|
if (maxX > currentPresentationPage?.scaledWidth) {
|
|
next.x += maxX - currentPresentationPage?.scaledWidth;
|
|
}
|
|
if (maxY > currentPresentationPage?.scaledHeight) {
|
|
next.y += maxY - currentPresentationPage?.scaledHeight;
|
|
}
|
|
if (next.x > 0 || minX < 0) {
|
|
next.x = 0;
|
|
}
|
|
if (next.y > 0 || minY < 0) {
|
|
next.y = 0;
|
|
}
|
|
}
|
|
return next;
|
|
};
|
|
}
|
|
|
|
isMountedRef.current = true;
|
|
};
|
|
|
|
const { shapesToAdd, shapesToUpdate, shapesToRemove } = React.useMemo(() => {
|
|
const selectedShapeIds = tlEditorRef.current?.getSelectedShapeIds() || [];
|
|
const localShapes = tlEditorRef.current?.getCurrentPageShapes();
|
|
const filteredShapes =
|
|
localShapes?.filter((item) => item?.index !== "a0") || [];
|
|
const localLookup = new Map(
|
|
filteredShapes.map((shape) => [shape.id, shape])
|
|
);
|
|
const remoteShapeIds = Object.keys(prevShapesRef.current);
|
|
const toAdd = [];
|
|
const toUpdate = [];
|
|
const toRemove = [];
|
|
|
|
filteredShapes.forEach((localShape) => {
|
|
if (!remoteShapeIds.includes(localShape.id)) {
|
|
toRemove.push(localShape.id);
|
|
}
|
|
});
|
|
|
|
Object.values(prevShapesRef.current).forEach((remoteShape) => {
|
|
if (!remoteShape.id) return;
|
|
const localShape = localLookup.get(remoteShape.id);
|
|
const prevShape = prevShapesRef.current[remoteShape.id];
|
|
|
|
if (!localShape) {
|
|
delete remoteShape.isModerator;
|
|
delete remoteShape.questionType;
|
|
toAdd.push(remoteShape);
|
|
} else if (!isEqual(localShape, remoteShape) && prevShape) {
|
|
const diff = {
|
|
id: remoteShape.id,
|
|
type: remoteShape.type,
|
|
typeName: remoteShape.typeName,
|
|
};
|
|
|
|
Object.keys(remoteShape).forEach((key) => {
|
|
if (
|
|
key !== "isModerator" &&
|
|
!isEqual(remoteShape[key], localShape[key])
|
|
) {
|
|
diff[key] = remoteShape[key];
|
|
}
|
|
});
|
|
|
|
if (remoteShape.props) {
|
|
Object.keys(remoteShape.props).forEach((key) => {
|
|
if (!isEqual(remoteShape.props[key], localShape.props[key])) {
|
|
diff.props = diff.props || {};
|
|
diff.props[key] = remoteShape.props[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
delete diff.isModerator;
|
|
delete diff.questionType;
|
|
toUpdate.push(diff);
|
|
}
|
|
});
|
|
|
|
return {
|
|
shapesToAdd: toAdd,
|
|
shapesToUpdate: toUpdate,
|
|
shapesToRemove: toRemove,
|
|
};
|
|
}, [prevShapesRef.current, curPageIdRef.current]);
|
|
|
|
const setCamera = (zoom, x = 0, y = 0) => {
|
|
if (tlEditorRef.current) {
|
|
tlEditorRef.current.setCamera({ x, y, z: zoom }, false);
|
|
}
|
|
};
|
|
|
|
const calculateZoomValue = (localWidth, localHeight, isViewer = false) => {
|
|
let calcedZoom;
|
|
if (isViewer) {
|
|
// Logic originally in calculateViewerZoom
|
|
calcedZoom = fitToWidth
|
|
? presentationAreaWidth / localWidth
|
|
: Math.min(
|
|
presentationAreaWidth / localWidth,
|
|
presentationAreaHeight / localHeight
|
|
);
|
|
} else {
|
|
// Logic originally in calculateZoom
|
|
calcedZoom = fitToWidth
|
|
? presentationAreaWidth / localWidth
|
|
: Math.min(
|
|
presentationAreaWidth / localWidth,
|
|
presentationAreaHeight / localHeight
|
|
);
|
|
}
|
|
|
|
return calcedZoom === 0 || calcedZoom === Infinity
|
|
? HUNDRED_PERCENT
|
|
: calcedZoom;
|
|
};
|
|
|
|
const calculateZoomWithGapValue = (
|
|
localWidth,
|
|
localHeight,
|
|
isViewer = false,
|
|
widthAdjustment = 0
|
|
) => {
|
|
let presentationWidth = presentationAreaWidth - widthAdjustment;
|
|
let calcedZoom = (baseZoom = fitToWidth
|
|
? presentationWidth / localWidth
|
|
: Math.min(
|
|
presentationWidth / localWidth,
|
|
presentationAreaHeight / localHeight
|
|
));
|
|
return calcedZoom === 0 || calcedZoom === Infinity
|
|
? HUNDRED_PERCENT
|
|
: calcedZoom;
|
|
};
|
|
|
|
useMouseEvents(
|
|
{ whiteboardRef, tlEditorRef, isWheelZoomRef, initialZoomRef },
|
|
{
|
|
isPresenter,
|
|
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(() => {
|
|
let undoRedoIntervalId = null;
|
|
|
|
const undo = () => {
|
|
tlEditorRef?.current?.history?.undo();
|
|
};
|
|
|
|
const redo = () => {
|
|
tlEditorRef?.current?.history?.redo();
|
|
};
|
|
|
|
const handleArrowPress = (event) => {
|
|
const currPageNum = parseInt(curPageIdRef.current);
|
|
const shapeSelected = tlEditorRef.current.getSelectedShapes()?.length > 0;
|
|
const changeSlide = (direction) => {
|
|
if (!currentPresentationPage) return;
|
|
let 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 handleUndoRedoOnCondition = (condition, action) => {
|
|
if (condition) {
|
|
action();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
const undoCondition =
|
|
event.ctrlKey && event.key === "z" && !event.shiftKey;
|
|
const redoCondition =
|
|
event.ctrlKey && event.shiftKey && event.key === "Z";
|
|
|
|
if (
|
|
(undoCondition || redoCondition) &&
|
|
(isPresenterRef.current || hasWBAccessRef.current)
|
|
) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!undoRedoIntervalId) {
|
|
undoRedoIntervalId = setInterval(() => {
|
|
handleUndoRedoOnCondition(undoCondition, undo);
|
|
handleUndoRedoOnCondition(redoCondition, redo);
|
|
}, 150);
|
|
}
|
|
}
|
|
|
|
if (
|
|
(event.keyCode === KEY_CODES.ARROW_RIGHT ||
|
|
event.keyCode === KEY_CODES.ARROW_LEFT) &&
|
|
isPresenterRef.current
|
|
) {
|
|
handleArrowPress(event);
|
|
}
|
|
};
|
|
|
|
const handleKeyUp = (event) => {
|
|
if ((event.key === "z" || event.key === "Z") && undoRedoIntervalId) {
|
|
clearInterval(undoRedoIntervalId);
|
|
undoRedoIntervalId = null;
|
|
}
|
|
};
|
|
|
|
whiteboardRef.current?.addEventListener("keydown", handleKeyDown, {
|
|
capture: true,
|
|
});
|
|
whiteboardRef.current?.addEventListener("keyup", handleKeyUp, {
|
|
capture: true,
|
|
});
|
|
|
|
return () => {
|
|
whiteboardRef.current?.removeEventListener("keydown", handleKeyDown);
|
|
whiteboardRef.current?.removeEventListener("keyup", handleKeyUp);
|
|
if (undoRedoIntervalId) {
|
|
clearInterval(undoRedoIntervalId);
|
|
}
|
|
};
|
|
}, [whiteboardRef.current]);
|
|
|
|
React.useEffect(() => {
|
|
zoomValueRef.current = zoomValue;
|
|
let timeoutId = null;
|
|
|
|
if (
|
|
tlEditor &&
|
|
curPageIdRef.current &&
|
|
currentPresentationPage &&
|
|
isPresenter &&
|
|
isWheelZoomRef.current === false
|
|
) {
|
|
const zoomLevelForReset =
|
|
initialZoomRef.current ||
|
|
calculateZoomValue(
|
|
currentPresentationPage.scaledWidth,
|
|
currentPresentationPage.scaledHeight
|
|
);
|
|
|
|
const zoomCamera =
|
|
zoomValue === HUNDRED_PERCENT
|
|
? zoomLevelForReset
|
|
: (zoomLevelForReset * zoomValue) / HUNDRED_PERCENT;
|
|
const camera = tlEditorRef.current.getCamera();
|
|
|
|
const nextCamera = {
|
|
x:
|
|
zoomValue === HUNDRED_PERCENT
|
|
? 0
|
|
: camera.x +
|
|
((camera.x + tlEditorRef.current.getViewportPageBounds().w / 2) /
|
|
zoomCamera -
|
|
camera.x),
|
|
y:
|
|
zoomValue === HUNDRED_PERCENT
|
|
? 0
|
|
: camera.y +
|
|
((camera.y + tlEditorRef.current.getViewportPageBounds().h / 2) /
|
|
zoomCamera -
|
|
camera.y),
|
|
z: zoomCamera,
|
|
};
|
|
|
|
if (
|
|
zoomValue !== prevZoomValueRef.current ||
|
|
zoomValue === HUNDRED_PERCENT
|
|
) {
|
|
tlEditor.setCamera(nextCamera, false);
|
|
|
|
timeoutId = setTimeout(() => {
|
|
if (zoomValue === HUNDRED_PERCENT) {
|
|
zoomChanger(HUNDRED_PERCENT);
|
|
zoomSlide(HUNDRED_PERCENT, HUNDRED_PERCENT, 0, 0);
|
|
} else {
|
|
// Recalculate viewed region width and height for zoomSlide call
|
|
let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
|
|
tlEditorRef.current.getViewportPageBounds().w,
|
|
currentPresentationPage.scaledWidth
|
|
);
|
|
let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
|
|
tlEditorRef.current.getViewportPageBounds().h,
|
|
currentPresentationPage.scaledHeight
|
|
);
|
|
|
|
zoomSlide(viewedRegionW, viewedRegionH, nextCamera.x, nextCamera.y);
|
|
}
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
// Update the previous zoom value ref with the current zoom value
|
|
prevZoomValueRef.current = zoomValue;
|
|
return () => clearTimeout(timeoutId);
|
|
}, [zoomValue, tlEditor, curPageIdRef.current, isWheelZoomRef.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) {
|
|
initialZoom =
|
|
presentationAreaWidth / currentPresentationPage.scaledWidth;
|
|
} else {
|
|
initialZoom =
|
|
adjustedPresentationAreaHeight /
|
|
currentPresentationPage.scaledHeight;
|
|
}
|
|
|
|
const tldrawZoom = initialZoom;
|
|
initialZoomRef.current = initialZoom;
|
|
setInitialZoomSet(true);
|
|
prevZoomValueRef.current = zoomValue;
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [
|
|
currentPresentationPage,
|
|
presentationAreaWidth,
|
|
presentationAreaHeight,
|
|
isPresenter,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
const handleResize = () => {
|
|
if (!initialViewBoxWidthRef.current) {
|
|
initialViewBoxWidthRef.current = currentPresentationPageRef.current?.scaledViewBoxWidth;
|
|
}
|
|
// 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(
|
|
currentPresentationPage.scaledWidth,
|
|
currentPresentationPage.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();
|
|
|
|
let adjustedZoom;
|
|
if (widthGap > 0) {
|
|
adjustedZoom = calculateZoomWithGapValue(
|
|
currentPresentationPage.scaledWidth,
|
|
currentPresentationPage.scaledHeight,
|
|
false,
|
|
widthGap
|
|
);
|
|
|
|
adjustedZoom *= currentZoom / HUNDRED_PERCENT;
|
|
} else {
|
|
adjustedZoom = baseZoom * (currentZoom / HUNDRED_PERCENT);
|
|
}
|
|
|
|
const zoomToApply =
|
|
widthGap > 0
|
|
? adjustedZoom
|
|
: baseZoom * (currentZoom / HUNDRED_PERCENT);
|
|
|
|
setCamera(zoomToApply, camera.x, camera.y);
|
|
} else {
|
|
// Viewer logic
|
|
const effectiveZoom = calculateEffectiveZoom(
|
|
initialViewBoxWidthRef.current,
|
|
currentPresentationPage.scaledViewBoxWidth
|
|
);
|
|
adjustedZoom = baseZoom * (effectiveZoom / HUNDRED_PERCENT);
|
|
|
|
const camera = tlEditorRef.current.getCamera();
|
|
setCamera(adjustedZoom, camera.x, camera.y);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const timeoutId = setTimeout(handleResize, 300);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [presentationAreaHeight, presentationAreaWidth, curPageIdRef.current]);
|
|
|
|
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 && currentPresentationPage) {
|
|
const currentZoomRatio = currentPresentationPage.scaledViewBoxWidth / currentPresentationPage.scaledWidth;
|
|
const initialZoomRatio = initialViewBoxWidthRef.current / currentPresentationPage.scaledWidth;
|
|
const effectiveZoom = initialZoomRatio / currentZoomRatio;
|
|
|
|
let adjustedZoom = calculateZoomValue(
|
|
currentPresentationPage.scaledWidth,
|
|
currentPresentationPage.scaledHeight
|
|
) * effectiveZoom;
|
|
|
|
const adjustedXPos = currentPresentationPage.xOffset;
|
|
const adjustedYPos = currentPresentationPage.yOffset;
|
|
|
|
setCamera(
|
|
adjustedZoom,
|
|
adjustedXPos,
|
|
adjustedYPos
|
|
);
|
|
}
|
|
}, [currentPresentationPage, initialViewBoxWidthRef.current]);
|
|
|
|
React.useEffect(() => {
|
|
// Check if there are any changes to be made
|
|
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) {
|
|
tlEditor?.updateShapes(shapesToUpdate);
|
|
}
|
|
});
|
|
}, 150);
|
|
|
|
return () => clearTimeout(tlStoreUpdateTimeoutId);
|
|
}
|
|
}, [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 updatedPresences = prevOtherCursorsRef.current
|
|
.map(({ userId, user, xPercent, yPercent }) => {
|
|
const { presenter, name } = user;
|
|
const id = InstancePresenceRecordType.createId(userId);
|
|
const active = yPercent !== -1 && yPercent !== -1;
|
|
// if cursor is not active remove it from tldraw store
|
|
if (
|
|
!active ||
|
|
(hideViewersCursor &&
|
|
user.role === "VIEWER" &&
|
|
!currentUser?.presenter) ||
|
|
(!presenter && !isMultiUserActive)
|
|
) {
|
|
tlEditorRef.current?.store.remove([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 there are any updated presences, put them all in the store
|
|
if (updatedPresences.length) {
|
|
tlEditorRef.current?.store.put(updatedPresences);
|
|
}
|
|
}
|
|
}, [prevOtherCursorsRef.current]);
|
|
|
|
// set current tldraw page when presentation id updates
|
|
React.useEffect(() => {
|
|
if (tlEditorRef.current && curPageIdRef.current !== "0") {
|
|
const pages = [
|
|
{
|
|
meta: {},
|
|
id: `page:${curPageIdRef.current}`,
|
|
name: `Slide ${curPageIdRef.current}`,
|
|
index: `a1`,
|
|
typeName: "page",
|
|
},
|
|
];
|
|
|
|
tlEditorRef.current.store.mergeRemoteChanges(() => {
|
|
tlEditorRef.current.batch(() => {
|
|
tlEditorRef.current.store.put(pages);
|
|
// tlEditorRef.current.deletePage(tlEditorRef.current.currentPageId);
|
|
tlEditorRef.current.setCurrentPage(`page:${curPageIdRef.current}`);
|
|
tlEditorRef.current.store.put(assets);
|
|
tlEditorRef.current.createShapes(bgShape);
|
|
tlEditorRef.current.history.clear();
|
|
});
|
|
});
|
|
|
|
whiteboardToolbarAutoHide &&
|
|
toggleToolsAnimations(
|
|
"fade-in",
|
|
"fade-out",
|
|
"0s",
|
|
hasWBAccessRef.current || isPresenter
|
|
);
|
|
slideChanged.current = false;
|
|
slideNext.current = null;
|
|
}
|
|
}, [curPageIdRef.current]);
|
|
|
|
React.useEffect(() => {
|
|
if (isMountedRef.current) {
|
|
const storedWidth = localStorage.getItem('initialViewBoxWidth');
|
|
if (storedWidth) {
|
|
initialViewBoxWidthRef.current = parseFloat(storedWidth);
|
|
} else {
|
|
const currentZoomLevel = currentPresentationPageRef.current.scaledWidth / currentPresentationPageRef.current.scaledViewBoxWidth;
|
|
const calculatedWidth = currentZoomLevel !== 1
|
|
? currentPresentationPageRef.current.scaledWidth / currentZoomLevel
|
|
: currentPresentationPageRef.current.scaledWidth;
|
|
initialViewBoxWidthRef.current = calculatedWidth;
|
|
localStorage.setItem('initialViewBoxWidth', calculatedWidth.toString());
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (
|
|
presentationAreaHeight > 0 &&
|
|
presentationAreaWidth > 0 &&
|
|
currentPresentationPageRef.current &&
|
|
currentPresentationPageRef.current.scaledWidth > 0 &&
|
|
currentPresentationPageRef.current.scaledHeight > 0
|
|
) {
|
|
const adjustedPresentationAreaHeight = isPresenter
|
|
? presentationAreaHeight - 40
|
|
: presentationAreaHeight;
|
|
|
|
let effectiveZoom;
|
|
let baseZoom;
|
|
if (isPresenter) {
|
|
// For presenters, use the full area minus any UI components like toolbars
|
|
baseZoom = fitToWidth
|
|
? presentationAreaWidth / currentPresentationPageRef.current.scaledWidth
|
|
: Math.min(
|
|
presentationAreaWidth / currentPresentationPageRef.current.scaledWidth,
|
|
adjustedPresentationAreaHeight / currentPresentationPageRef.current.scaledHeight
|
|
);
|
|
} else {
|
|
// For viewers
|
|
effectiveZoom = calculateEffectiveZoom(
|
|
initialViewBoxWidthRef.current,
|
|
currentPresentationPageRef.current.scaledViewBoxWidth
|
|
);
|
|
baseZoom = calculateZoomValue(
|
|
currentPresentationPageRef.current.scaledWidth,
|
|
currentPresentationPageRef.current.scaledHeight
|
|
) * effectiveZoom / HUNDRED_PERCENT;
|
|
}
|
|
|
|
const zoomAdjustmentFactor = currentPresentationPageRef.current.scaledWidth / currentPresentationPageRef.current.scaledViewBoxWidth;
|
|
baseZoom *= zoomAdjustmentFactor;
|
|
|
|
const adjustedXPos = isPresenter
|
|
? currentPresentationPageRef.current.xOffset : currentPresentationPageRef.current.xOffset * effectiveZoom;
|
|
const adjustedYPos = isPresenter
|
|
? currentPresentationPageRef.current.yOffset : currentPresentationPageRef.current.yOffset * effectiveZoom;
|
|
|
|
setCamera(baseZoom, adjustedXPos, adjustedYPos);
|
|
}
|
|
}, 300);
|
|
}
|
|
}, [
|
|
isMountedRef.current,
|
|
presentationId,
|
|
curPageIdRef.current,
|
|
isMultiUserActive,
|
|
isPresenter,
|
|
animations,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
setTldrawIsMounting(true);
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (isMounting) {
|
|
setIsMounting(false);
|
|
/// brings presentation toolbar back
|
|
setTldrawIsMounting(false);
|
|
}
|
|
}, [
|
|
tlEditorRef?.current?.camera,
|
|
presentationAreaWidth,
|
|
presentationAreaHeight,
|
|
]);
|
|
|
|
const customTools = [NoopTool];
|
|
|
|
return (
|
|
<div
|
|
ref={whiteboardRef}
|
|
id={"whiteboard-element"}
|
|
key={`animations=-${animations}-${whiteboardToolbarAutoHide}-${language}`}
|
|
>
|
|
<Tldraw
|
|
key={`tldrawv2-${presentationId}-${animations}`}
|
|
forceMobile={true}
|
|
hideUi={hasWBAccessRef.current || isPresenter ? false : true}
|
|
onMount={handleTldrawMount}
|
|
tools={customTools}
|
|
/>
|
|
<Styled.TldrawV2GlobalStyle
|
|
{...{
|
|
hasWBAccess: hasWBAccessRef.current,
|
|
isPresenter,
|
|
isRTL,
|
|
isMultiUserActive,
|
|
isToolbarVisible,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default Whiteboard;
|
|
|
|
Whiteboard.propTypes = {
|
|
isPresenter: PropTypes.bool,
|
|
isIphone: PropTypes.bool.isRequired,
|
|
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,
|
|
curPageId: PropTypes.string.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,
|
|
svgUri: PropTypes.string,
|
|
maxStickyNoteLength: PropTypes.number.isRequired,
|
|
fontFamily: PropTypes.string.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,
|
|
notifyShapeNumberExceeded: PropTypes.func.isRequired,
|
|
darkTheme: PropTypes.bool.isRequired,
|
|
setTldrawIsMounting: PropTypes.func.isRequired,
|
|
width: PropTypes.number.isRequired,
|
|
height: PropTypes.number.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,
|
|
numberOfPages: PropTypes.number,
|
|
sidebarNavigationWidth: PropTypes.number,
|
|
presentationId: PropTypes.string,
|
|
};
|
|
|
|
Whiteboard.defaultProps = {
|
|
fullscreenRef: undefined,
|
|
svgUri: undefined,
|
|
whiteboardId: undefined,
|
|
sidebarNavigationWidth: 0,
|
|
presentationId: undefined,
|
|
currentUser: {
|
|
userId: "",
|
|
},
|
|
isPresenter: false,
|
|
numberOfPages: 0,
|
|
};
|