bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx

538 lines
20 KiB
React
Raw Normal View History

2022-04-05 22:49:13 +08:00
import * as React from "react";
import _ from "lodash";
import { createGlobalStyle } from "styled-components";
2022-04-05 22:49:13 +08:00
import Cursors from "./cursors/container";
import { TldrawApp, Tldraw } from "@tldraw/tldraw";
import SlideCalcUtil, {HUNDRED_PERCENT} from '/imports/utils/slideCalcUtils';
function usePrevious(value) {
const ref = React.useRef();
React.useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
2022-04-05 22:49:13 +08:00
const findRemoved = (A, B) => {
return A.filter((a) => {
return !B.includes(a);
});
};
const SMALL_HEIGHT = 435;
const SMALLEST_HEIGHT = 363;
const TOOLBAR_SMALL = 28;
const TOOLBAR_LARGE = 38;
const TOOLBAR_OFFSET = 0;
const TldrawGlobalStyle = createGlobalStyle`
${({ hideContextMenu }) => hideContextMenu && `
#TD-ContextMenu {
display: none;
}
`}
`;
2022-04-05 22:49:13 +08:00
export default function Whiteboard(props) {
const {
isPresenter,
removeShapes,
2022-04-05 22:49:13 +08:00
initDefaultPages,
persistShape,
shapes,
assets,
2022-04-05 22:49:13 +08:00
currentUser,
curPres,
2022-05-07 00:37:43 +08:00
whiteboardId,
2022-05-12 05:58:16 +08:00
podId,
zoomSlide,
skipToSlide,
2022-05-12 05:58:16 +08:00
slidePosition,
2022-05-16 10:35:17 +08:00
curPageId,
presentationWidth,
presentationHeight,
2022-06-02 23:00:28 +08:00
isViewersCursorLocked,
zoomChanger,
isMultiUserActive,
2022-08-03 22:19:12 +08:00
isRTL,
fitToWidth,
zoomValue,
width,
height,
2022-08-15 06:49:39 +08:00
isPanning,
2022-04-05 22:49:13 +08:00
} = props;
2022-05-16 10:35:17 +08:00
const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1);
2022-04-05 22:49:13 +08:00
const rDocument = React.useRef({
name: "test",
version: TldrawApp.version,
2022-05-16 10:35:17 +08:00
id: whiteboardId,
2022-04-05 22:49:13 +08:00
pages,
pageStates,
bindings: {},
assets: {},
2022-04-05 22:49:13 +08:00
});
const [tldrawAPI, setTLDrawAPI] = React.useState(null);
2022-08-15 06:49:39 +08:00
const [forcePanning, setForcePanning] = React.useState(false);
const [zoom, setZoom] = React.useState(HUNDRED_PERCENT);
const [isMounting, setIsMounting] = React.useState(true);
const prevShapes = usePrevious(shapes);
const prevSlidePosition = usePrevious(slidePosition);
const prevFitToWidth = usePrevious(fitToWidth);
const calculateZoom = (width, height) => {
let zoom = fitToWidth
? presentationWidth / width
: Math.min(
(presentationWidth) / width,
(presentationHeight) / height
);
return zoom;
}
const doc = React.useMemo(() => {
2022-04-05 22:49:13 +08:00
const currentDoc = rDocument.current;
let next = { ...currentDoc };
let pageBindings = null;
let history = null;
2022-05-19 08:52:45 +08:00
let stack = null;
let changed = false;
if (next.pageStates[curPageId] && !_.isEqual(prevShapes, shapes)) {
// mergeDocument loses bindings and history, save it
pageBindings = tldrawAPI?.getPage(curPageId)?.bindings;
history = tldrawAPI?.history
2022-05-19 08:52:45 +08:00
stack = tldrawAPI?.stack
next.pages[curPageId].shapes = shapes;
2022-06-16 01:55:14 +08:00
changed = true;
}
2022-04-05 22:49:13 +08:00
if (curPageId && !next.assets[`slide-background-asset-${curPageId}`]) {
next.assets[`slide-background-asset-${curPageId}`] = assets[`slide-background-asset-${curPageId}`]
2022-04-05 22:49:13 +08:00
}
if (changed) {
if (pageBindings) next.pages[curPageId].bindings = pageBindings;
tldrawAPI?.mergeDocument(next);
if (tldrawAPI && history) tldrawAPI.history = history;
2022-05-19 08:52:45 +08:00
if (tldrawAPI && stack) tldrawAPI.stack = stack;
}
// move poll result text to bottom right
if (next.pages[curPageId]) {
const pollResults = Object.entries(next.pages[curPageId].shapes)
.filter(([id, shape]) => shape.name.includes("poll-result"))
for (const [id, shape] of pollResults) {
if (_.isEqual(shape.point, [0, 0])) {
const shapeBounds = tldrawAPI?.getShapeBounds(id);
if (shapeBounds) {
shape.point = [
slidePosition.width - shapeBounds.width,
slidePosition.height - shapeBounds.height
]
shape.size = [shapeBounds.width, shapeBounds.height]
isPresenter && persistShape(shape, whiteboardId);
}
}
};
}
return currentDoc;
}, [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 zoom = calculateZoom(slidePosition.width, slidePosition.height)
tldrawAPI?.setCamera([0, 0], zoom);
const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(tldrawAPI?.viewport.width, slidePosition.height);
setZoom(HUNDRED_PERCENT);
zoomChanger(HUNDRED_PERCENT);
zoomSlide(parseInt(curPageId), 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) {
// wee need this to ensure tldraw updates the viewport size after re-mounting
setTimeout(() => {
const zoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
tldrawAPI.setCamera([slidePosition.x, slidePosition.y], zoom, 'zoomed');
}, 50);
} else {
const zoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], zoom);
}
}
}
}, [presentationWidth, presentationHeight, curPageId, document?.documentElement?.dir]);
React.useEffect(() => {
if (presentationWidth > 0 && presentationHeight > 0) {
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);
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 page when presentation page changes
React.useEffect(() => {
if (tldrawAPI && curPageId) {
tldrawAPI.changePage(curPageId);
let zoom = prevSlidePosition
? calculateZoom(prevSlidePosition.viewBoxWidth, prevSlidePosition.viewBoxHeight)
: calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight)
tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], zoom, 'zoomed_previous_page');
}
}, [curPageId]);
2022-04-05 22:49:13 +08:00
// change tldraw camera when slidePosition changes
React.useEffect(() => {
if (tldrawAPI && !isPresenter && curPageId && slidePosition) {
const zoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight)
tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], zoom, 'zoomed');
}
}, [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 zoom = calculateZoom(slidePosition.width, slidePosition.height)
tldrawAPI?.setCamera([0, 0], zoom);
const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(tldrawAPI?.viewport.width, slidePosition.height);
zoomSlide(parseInt(curPageId), podId, HUNDRED_PERCENT, viewedRegionH, 0, 0);
setZoom(HUNDRED_PERCENT);
zoomChanger(HUNDRED_PERCENT);
} else if (!isMounting) {
let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(tldrawAPI?.viewport.height, slidePosition.width);
let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(tldrawAPI?.viewport.width, 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), 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]);
2022-08-03 22:30:22 +08:00
const hasWBAccess = props?.hasMultiUserAccess(props.whiteboardId, props.currentUser.userId);
2022-08-03 22:19:12 +08:00
React.useEffect(() => {
if (hasWBAccess || isPresenter) {
tldrawAPI?.setSetting('dockPosition', isRTL ? 'left' : 'right');
const tdToolsDots = document.getElementById("TD-Tools-Dots");
const tdDelete = document.getElementById("TD-Delete");
2022-08-03 22:19:12 +08:00
const tdPrimaryTools = document.getElementById("TD-PrimaryTools");
const tdTools = document.getElementById("TD-Tools");
if (tdToolsDots && tdDelete && tdPrimaryTools) {
const size = props.height < SMALL_HEIGHT ? TOOLBAR_SMALL : TOOLBAR_LARGE;
tdToolsDots.style.height = `${size}px`;
tdToolsDots.style.width = `${size}px`;
const delButton = tdDelete.getElementsByTagName('button')[0];
delButton.style.height = `${size}px`;
delButton.style.width = `${size}px`;
const primaryBtns = tdPrimaryTools?.getElementsByTagName('button');
for (let item of primaryBtns) {
item.style.height = `${size}px`;
item.style.width = `${size}px`;
2022-08-03 22:19:12 +08:00
}
}
if (props.height < SMALLEST_HEIGHT && tdTools) {
tldrawAPI?.setSetting('dockPosition', 'bottom');
tdTools.parentElement.style.bottom = `${TOOLBAR_OFFSET}px`;
2022-08-03 22:19:12 +08:00
}
// removes tldraw native help menu button
tdTools?.parentElement?.nextSibling?.remove();
2022-08-03 22:19:12 +08:00
// removes image tool from the tldraw toolbar
document.getElementById("TD-PrimaryTools-Image").style.display = 'none';
}
2022-08-15 06:49:39 +08:00
if (tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
2022-08-03 22:19:12 +08:00
}
});
2022-08-15 06:49:39 +08:00
React.useEffect(() => {
if (tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
}
}, [isPanning]);
2022-07-21 02:50:13 +08:00
const onMount = (app) => {
app.setSetting('language', document.getElementsByTagName('html')[0]?.lang || 'en');
2022-07-21 02:50:13 +08:00
setTLDrawAPI(app);
props.setTldrawAPI(app);
// disable for non presenter that doesn't have multi user access
if (!hasWBAccess && !isPresenter) {
app.onPan = () => {};
app.setSelectedIds = () => {};
app.setHoveredId = () => {};
} else {
// disable hover highlight for background slide shape
app.setHoveredId = (id) => {
2022-07-28 02:46:44 +08:00
if (id?.includes('slide-background')) return null;
2022-07-21 02:50:13 +08:00
app.patchState(
{
document: {
pageStates: {
[app.getPage()?.id]: {
hoveredId: id || [],
2022-07-21 02:50:13 +08:00
},
},
},
},
`set_hovered_id`
);
};
// disable selecting background slide shape
app.setSelectedIds = (ids) => {
ids = ids.filter(id => !id.includes('slide-background'))
app.patchState(
{
document: {
pageStates: {
[app.getPage()?.id]: {
selectedIds: ids || [],
},
},
},
},
`selected`
);
};
2022-07-21 02:50:13 +08:00
}
2022-07-21 02:50:13 +08:00
if (curPageId) {
app.changePage(curPageId);
setIsMounting(true);
2022-07-21 02:50:13 +08:00
}
};
2022-08-03 22:19:12 +08:00
const onPatch = (e, t, reason) => {
2022-07-21 02:50:13 +08:00
if (reason && isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) {
const camera = tldrawAPI.getPageState()?.camera;
// limit bounds
if (tldrawAPI?.viewport.maxX > slidePosition.width) {
camera.point[0] = camera.point[0] + (tldrawAPI?.viewport.maxX - slidePosition.width);
}
if (tldrawAPI?.viewport.maxY > slidePosition.height) {
camera.point[1] = 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;
2022-07-21 02:50:13 +08:00
}
const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height);
if (camera.zoom < zoomFitSlide) {
camera.zoom = zoomFitSlide;
}
tldrawAPI?.setCamera([camera.point[0], camera.point[1]], camera.zoom);
const zoomToolbar = Math.round((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide * 100) / 100;
if (zoom !== zoomToolbar) {
setZoom(zoomToolbar);
isPresenter && zoomChanger(zoomToolbar);
2022-07-21 02:50:13 +08:00
}
let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(tldrawAPI?.viewport.height, slidePosition.width);
let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(tldrawAPI?.viewport.width, slidePosition.height);
if (!fitToWidth && camera.zoom === zoomFitSlide) {
viewedRegionW = HUNDRED_PERCENT;
viewedRegionH = HUNDRED_PERCENT;
}
zoomSlide(parseInt(curPageId), podId, viewedRegionW, viewedRegionH, camera.point[0], camera.point[1]);
2022-07-21 02:50:13 +08:00
}
//don't allow non-presenters to pan&zoom
if (slidePosition && reason && !isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) {
const zoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight)
tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], zoom);
2022-07-21 02:50:13 +08:00
}
// 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,
},
},
},
},
);
}
}
2022-09-01 03:01:53 +08:00
if (reason && reason === 'patched_shapes') {
const patchedShape = e?.getShape(e?.getPageState()?.editingId);
if (patchedShape?.type === 'text') {
persistShape(patchedShape, whiteboardId);
}
}
2022-07-21 02:50:13 +08:00
};
// this callback is called whenever the shapes on the page are changed by the user,
// with what changed stored in changedShapes
const onChangePage = (app, changedShapes, changedBindings, changedAssets, addToHistory) => {
if (addToHistory && (isPresenter || hasWBAccess)) {
if (!isMounting && app.currentPageId !== curPageId) {
// can happen then the "move to page action" is called, or using undo after changing a page
const newWhiteboardId = curPres.pages.find(page => page.num === Number.parseInt(app.currentPageId)).id;
//remove from previous page and persist on new
removeShapes(Object.keys(changedShapes), whiteboardId);
Object.entries(changedShapes)
.forEach(([id, shape]) => {
const shapeBounds = app.getShapeBounds(id);
shape.size = [shapeBounds.width, shapeBounds.height];
persistShape(shape, newWhiteboardId);
});
if (isPresenter) {
// change slide for others
skipToSlide(Number.parseInt(app.currentPageId), podId)
} else {
// ignore, stay on same page
app.changePage(curPageId);
}
} else {
let deletedShapes = [];
Object.entries(changedShapes)
.forEach(([id, shape]) => {
if (!shape) deletedShapes.push(id);
else {
//checks to find any bindings assosiated with the changed shapes.
//If any, they need to be updated as well.
const pageBindings = app.page.bindings;
if (pageBindings) {
Object.entries(pageBindings).map(([k,b]) => {
if (b.toId.includes(id)) {
const boundShape = app.getShape(b.fromId);
const shapeBounds = app.getShapeBounds(b.fromId);
boundShape.size = [shapeBounds.width, shapeBounds.height];
persistShape(boundShape, whiteboardId)
}
})
}
const shapeBounds = app.getShapeBounds(id);
shape.size = [shapeBounds.width, shapeBounds.height];
persistShape(shape, whiteboardId);
}
});
removeShapes(deletedShapes, whiteboardId);
}
}
};
const webcams = document.getElementById('cameraDock');
const dockPos = webcams?.getAttribute("data-position");
2022-07-21 02:50:13 +08:00
const editableWB = (
<Tldraw
key={`wb-${isRTL}-${width}-${height}-${dockPos}-${forcePanning}`}
2022-07-21 02:50:13 +08:00
document={doc}
// disable the ability to drag and drop files onto the whiteboard
// until we handle saving of assets in akka.
disableAssets={true}
// 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 ? false : true}
showMultiplayerMenu={false}
readOnly={false}
onPatch={onPatch}
onChangePage={onChangePage}
2022-07-21 02:50:13 +08:00
/>
);
const readOnlyWB = (
<Tldraw
2022-07-21 03:54:23 +08:00
key={`wb-readOnly`}
2022-07-21 02:50:13 +08:00
document={doc}
onMount={onMount}
// disable the ability to drag and drop files onto the whiteboard
// until we handle saving of assets in akka.
disableAssets={true}
// 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={true}
onPatch={onPatch}
/>
);
return (
<>
<Cursors
tldrawAPI={tldrawAPI}
currentUser={currentUser}
hasMultiUserAccess={props?.hasMultiUserAccess}
whiteboardId={whiteboardId}
isViewersCursorLocked={isViewersCursorLocked}
isMultiUserActive={isMultiUserActive}
2022-08-15 21:02:56 +08:00
isPanning={isPanning}
2022-07-21 02:50:13 +08:00
>
{hasWBAccess || isPresenter ? editableWB : readOnlyWB}
<TldrawGlobalStyle hideContextMenu={!hasWBAccess && !isPresenter} />
</Cursors>
</>
2022-04-05 22:49:13 +08:00
);
}