Merge pull request #15636 from germanocaumo/tldraw-shape-updates

This commit is contained in:
Gustavo Trott 2022-10-27 08:17:23 -03:00 committed by GitHub
commit 861c42cecf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 434 additions and 377 deletions

View File

@ -1,9 +1,6 @@
package org.bigbluebutton.core.apps
import org.bigbluebutton.core.util.jhotdraw.BezierWrapper
import scala.collection.immutable.List
import scala.collection.immutable.HashMap
import scala.collection.JavaConverters._
import org.bigbluebutton.common2.msgs.AnnotationVO
import org.bigbluebutton.core.apps.whiteboard.Whiteboard
import org.bigbluebutton.SystemConfiguration
@ -24,86 +21,83 @@ class WhiteboardModel extends SystemConfiguration {
}
private def createWhiteboard(wbId: String): Whiteboard = {
new Whiteboard(
Whiteboard(
wbId,
Array.empty[String],
Array.empty[String],
System.currentTimeMillis(),
new HashMap[String, Map[String, AnnotationVO]]()
new HashMap[String, AnnotationVO]
)
}
private def getAnnotationsByUserId(wb: Whiteboard, id: String): Map[String, AnnotationVO] = {
wb.annotationsMap.get(id).getOrElse(Map[String, AnnotationVO]())
}
private def deepMerge(test: Map[String, _], that: Map[String, _]): Map[String, _] =
(for (k <- test.keys ++ that.keys) yield {
val newValue =
(test.get(k), that.get(k)) match {
case (Some(v), None) => v
case (None, Some(v)) => v
case (Some(v1), Some(v2)) =>
if (v1.isInstanceOf[Map[String, _]] && v2.isInstanceOf[Map[String, _]])
deepMerge(v1.asInstanceOf[Map[String, _]], v2.asInstanceOf[Map[String, _]])
else v2
case (_, _) => ???
}
k -> newValue
}).toMap
def addAnnotations(wbId: String, userId: String, annotations: Array[AnnotationVO]): Array[AnnotationVO] = {
def addAnnotations(wbId: String, userId: String, annotations: Array[AnnotationVO], isPresenter: Boolean, isModerator: Boolean): Array[AnnotationVO] = {
var annotationsAdded = Array[AnnotationVO]()
val wb = getWhiteboard(wbId)
val usersAnnotations = getAnnotationsByUserId(wb, userId)
var newUserAnnotations = usersAnnotations
var newAnnotationsMap = wb.annotationsMap
for (annotation <- annotations) {
newUserAnnotations = newUserAnnotations + (annotation.id -> annotation)
println("Adding annotation to page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].")
val oldAnnotation = wb.annotationsMap.get(annotation.id)
if (!oldAnnotation.isEmpty) {
val hasPermission = isPresenter || isModerator || oldAnnotation.get.userId == userId
if (hasPermission) {
val newAnnotation = oldAnnotation.get.copy(annotationInfo = deepMerge(oldAnnotation.get.annotationInfo, annotation.annotationInfo))
newAnnotationsMap += (annotation.id -> newAnnotation)
annotationsAdded :+= annotation
println(s"Updated annotation onpage [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].")
} else {
println(s"User $userId doesn't have permission to edit annotation ${annotation.id}, ignoring...")
}
} else if (annotation.annotationInfo.contains("type")) {
newAnnotationsMap += (annotation.id -> annotation)
annotationsAdded :+= annotation
println(s"Adding annotation to page [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].")
} else {
println(s"New annotation [${annotation.id}] with no type, ignoring (probably received a remove message before and now the shape is incomplete, ignoring...")
}
}
val newAnnotationsMap = wb.annotationsMap + (userId -> newUserAnnotations)
val newWb = wb.copy(annotationsMap = newAnnotationsMap)
saveWhiteboard(newWb)
annotations
annotationsAdded
}
def getHistory(wbId: String): Array[AnnotationVO] = {
//wb.annotationsMap.values.flatten.toArray.sortBy(_.position);
val wb = getWhiteboard(wbId)
var annotations = Array[AnnotationVO]()
// TODO: revisit this, probably there is a one-liner simple solution
wb.annotationsMap.values.foreach(
user => user.values.foreach(
annotation => annotations = annotations :+ annotation
)
)
annotations
wb.annotationsMap.values.toArray
}
def clearWhiteboard(wbId: String, userId: String): Option[Boolean] = {
var cleared: Option[Boolean] = None
if (hasWhiteboard(wbId)) {
val wb = getWhiteboard(wbId)
if (wb.multiUser.contains(userId)) {
if (wb.annotationsMap.contains(userId)) {
val newWb = wb.copy(annotationsMap = wb.annotationsMap - userId)
saveWhiteboard(newWb)
cleared = Some(false)
}
} else {
if (wb.annotationsMap.nonEmpty) {
val newWb = wb.copy(annotationsMap = new HashMap[String, Map[String, AnnotationVO]]())
saveWhiteboard(newWb)
cleared = Some(true)
}
}
}
cleared
}
def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String]): Array[String] = {
def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String], isPresenter: Boolean, isModerator: Boolean): Array[String] = {
var annotationsIdsRemoved = Array[String]()
val wb = getWhiteboard(wbId)
var newAnnotationsMap = wb.annotationsMap
val usersAnnotations = getAnnotationsByUserId(wb, userId)
var newUserAnnotations = usersAnnotations
for (annotationId <- annotationsIds) {
val annotation = usersAnnotations.get(annotationId)
val annotation = wb.annotationsMap.get(annotationId)
//not empty and annotation exists
if (!usersAnnotations.isEmpty && !annotation.isEmpty) {
newUserAnnotations = newUserAnnotations - annotationId
println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].")
annotationsIdsRemoved = annotationsIdsRemoved :+ annotationId
if (!annotation.isEmpty) {
val hasPermission = isPresenter || isModerator || annotation.get.userId == userId
if (hasPermission) {
newAnnotationsMap -= annotationId
println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newAnnotationsMap.size + "].")
annotationsIdsRemoved :+= annotationId
} else {
println("User doesn't have permission to remove this annotation, ignoring...")
}
}
}
val newAnnotationsMap = wb.annotationsMap + (userId -> newUserAnnotations)
val newWb = wb.copy(annotationsMap = newAnnotationsMap)
saveWhiteboard(newWb)
annotationsIdsRemoved

View File

@ -55,7 +55,7 @@ trait ChangeLockSettingsInMeetingCmdMsgHdlr extends RightsManagementTrait {
outGW.send(notifyEvent)
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
// Dial-in
def buildLockMessage(meetingId: String, userId: String, lockedBy: String, locked: Boolean): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)

View File

@ -28,11 +28,7 @@ trait ClearWhiteboardPubMsgHdlr extends RightsManagementTrait {
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
}
} else {
for {
fullClear <- clearWhiteboard(msg.body.whiteboardId, msg.header.userId, liveMeeting)
} yield {
broadcastEvent(msg, fullClear)
}
log.error("Ignoring message ClearWhiteboardPubMsg since this functions is not available in the new Whiteboard")
}
}
}

View File

@ -21,14 +21,24 @@ trait DeleteWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
bus.outGW.send(msgEvent)
}
if (filterWhiteboardMessage(msg.body.whiteboardId, msg.header.userId, liveMeeting) && permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val isUserAmongPresenters = !permissionFailed(
PermissionCheck.GUEST_LEVEL,
PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId
)
val isUserModerator = !permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId
)
if (filterWhiteboardMessage(msg.body.whiteboardId, msg.header.userId, liveMeeting) && !isUserAmongPresenters) {
if (isNonEjectionGracePeriodOver(msg.body.whiteboardId, msg.header.userId, liveMeeting)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to delete an annotation."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
}
} else {
val deletedAnnotations = deleteWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotationsIds, liveMeeting)
val deletedAnnotations = deleteWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotationsIds, liveMeeting, isUserAmongPresenters, isUserModerator)
if (!deletedAnnotations.isEmpty) {
broadcastEvent(msg, deletedAnnotations)
}

View File

@ -46,13 +46,18 @@ trait SendWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId
)
val isUserModerator = !permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId
)
if (isUserOneOfPermited || isUserAmongPresenters) {
println("============= Printing Sanitized annotations ============")
for (annotation <- msg.body.annotations) {
printAnnotationInfo(annotation)
}
println("============= Printed Sanitized annotations ============")
val annotations = sendWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotations, liveMeeting)
val annotations = sendWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotations, liveMeeting, isUserAmongPresenters, isUserModerator)
broadcastEvent(msg, msg.body.whiteboardId, annotations, msg.body.html5InstanceId)
} else {
//val meetingId = liveMeeting.props.meetingProp.intId

View File

@ -11,7 +11,7 @@ case class Whiteboard(
multiUser: Array[String],
oldMultiUser: Array[String],
changedModeOn: Long,
annotationsMap: Map[String, Map[String, AnnotationVO]]
annotationsMap: Map[String, AnnotationVO]
)
class WhiteboardApp2x(implicit val context: ActorContext)
@ -24,9 +24,16 @@ class WhiteboardApp2x(implicit val context: ActorContext)
val log = Logging(context.system, getClass)
def sendWhiteboardAnnotations(whiteboardId: String, requesterId: String, annotations: Array[AnnotationVO], liveMeeting: LiveMeeting): Array[AnnotationVO] = {
def sendWhiteboardAnnotations(
whiteboardId: String,
requesterId: String,
annotations: Array[AnnotationVO],
liveMeeting: LiveMeeting,
isPresenter: Boolean,
isModerator: Boolean
): Array[AnnotationVO] = {
// println("Received whiteboard annotation. status=[" + status + "], annotationType=[" + annotationType + "]")
liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations)
liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations, isPresenter, isModerator)
}
def getWhiteboardAnnotations(whiteboardId: String, liveMeeting: LiveMeeting): Array[AnnotationVO] = {
@ -34,12 +41,15 @@ class WhiteboardApp2x(implicit val context: ActorContext)
liveMeeting.wbModel.getHistory(whiteboardId)
}
def clearWhiteboard(whiteboardId: String, requesterId: String, liveMeeting: LiveMeeting): Option[Boolean] = {
liveMeeting.wbModel.clearWhiteboard(whiteboardId, requesterId)
}
def deleteWhiteboardAnnotations(whiteboardId: String, requesterId: String, annotationsIds: Array[String], liveMeeting: LiveMeeting): Array[String] = {
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds)
def deleteWhiteboardAnnotations(
whiteboardId: String,
requesterId: String,
annotationsIds: Array[String],
liveMeeting: LiveMeeting,
isPresenter: Boolean,
isModerator: Boolean
): Array[String] = {
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds, isPresenter, isModerator)
}
def getWhiteboardAccess(whiteboardId: String, liveMeeting: LiveMeeting): Array[String] = {

View File

@ -112,7 +112,7 @@ object Polls {
shape = pollResultToWhiteboardShape(result)
annot <- send(result, shape)
} yield {
lm.wbModel.addAnnotations(annot.wbId, requesterId, Array[AnnotationVO](annot))
lm.wbModel.addAnnotations(annot.wbId, requesterId, Array[AnnotationVO](annot), false, false)
showPollResult(pollId, lm.polls)
(result, annot)
}

View File

@ -1 +1 @@
git clone --branch v5.0.0-alpha.2 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback
git clone --branch v5.0.0-alpha.3 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback

View File

@ -1,20 +1,27 @@
import { check } from 'meteor/check';
import _ from "lodash";
export default function addAnnotation(meetingId, whiteboardId, userId, annotation) {
export default function addAnnotation(meetingId, whiteboardId, userId, annotation, Annotations) {
check(meetingId, String);
check(whiteboardId, String);
check(annotation, Object);
const {
id, annotationInfo, wbId,
id, wbId,
} = annotation;
let { annotationInfo } = annotation;
const selector = {
meetingId,
id,
userId,
};
const oldAnnotation = Annotations.findOne(selector);
if (oldAnnotation) {
annotationInfo = _.merge(oldAnnotation.annotationInfo, annotationInfo)
}
const modifier = {
$set: {
whiteboardId,

View File

@ -8,7 +8,7 @@ export default function addAnnotation(meetingId, whiteboardId, userId, annotatio
check(whiteboardId, String);
check(annotation, Object);
const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation);
const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations);
try {
const { insertedId } = Annotations.upsert(query.selector, query.modifier);

View File

@ -95,8 +95,6 @@ class Presentation extends PureComponent {
this.setIsPanning = this.setIsPanning.bind(this);
this.handlePanShortcut = this.handlePanShortcut.bind(this);
this.renderPresentationMenu = this.renderPresentationMenu.bind(this);
this.setIsPanning = this.setIsPanning.bind(this);
this.handlePanShortcut = this.handlePanShortcut.bind(this);
this.onResize = () => setTimeout(this.handleResize.bind(this), 0);
this.renderCurrentPresentationToast = this.renderCurrentPresentationToast.bind(this);
@ -192,7 +190,7 @@ class Presentation extends PureComponent {
clearFakeAnnotations,
} = this.props;
const { presentationWidth, presentationHeight, zoom, isPanning } = this.state;
const { presentationWidth, presentationHeight, zoom, isPanning, fitToWidth } = this.state;
const {
numCameras: prevNumCameras,
presentationBounds: prevPresentationBounds,
@ -294,7 +292,7 @@ class Presentation extends PureComponent {
});
}
if (zoom <= HUNDRED_PERCENT && isPanning || !userIsPresenter && prevProps.userIsPresenter) {
if ((zoom <= HUNDRED_PERCENT && isPanning && !fitToWidth) || !userIsPresenter && prevProps.userIsPresenter) {
this.setIsPanning();
}
}
@ -326,13 +324,6 @@ class Presentation extends PureComponent {
});
}
setIsPanning() {
this.setState({
isPanning: !this.state.isPanning,
});
}
handleResize() {
const presentationSizes = this.getPresentationSizesAvailable();
if (Object.keys(presentationSizes).length > 0) {

View File

@ -106,9 +106,9 @@ class PresentationToolbar extends PureComponent {
document.addEventListener('keydown', this.switchSlide);
}
componentDidUpdate(prevProps) {
const { zoom, setIsPanning } = this.props;
if (zoom <= HUNDRED_PERCENT && zoom !== prevProps.zoom) setIsPanning();
componentDidUpdate(prevProps, prevState) {
const { zoom, setIsPanning, fitToWidth } = this.props;
if (zoom <= HUNDRED_PERCENT && zoom !== prevProps.zoom && !fitToWidth) setIsPanning();
}
componentWillUnmount() {
@ -402,7 +402,7 @@ class PresentationToolbar extends PureComponent {
data-test="panButton"
aria-label={intl.formatMessage(intlMessages.pan)}
color="light"
disabled={(zoom <= HUNDRED_PERCENT)}
disabled={(zoom <= HUNDRED_PERCENT && !fitToWidth)}
icon="hand"
size="md"
circle

View File

@ -3,13 +3,8 @@ import _ from "lodash";
import { createGlobalStyle } from "styled-components";
import Cursors from "./cursors/container";
import { TldrawApp, Tldraw } from "@tldraw/tldraw";
import {
ColorStyle,
DashStyle,
SizeStyle,
TDShapeType,
} from "@tldraw/tldraw";
import SlideCalcUtil, {HUNDRED_PERCENT} from '/imports/utils/slideCalcUtils';
import { Utils } from "@tldraw/core";
function usePrevious(value) {
const ref = React.useRef();
@ -42,10 +37,13 @@ const TldrawGlobalStyle = createGlobalStyle`
export default function Whiteboard(props) {
const {
isPresenter,
isModerator,
removeShapes,
initDefaultPages,
persistShape,
notifyNotAllowedChange,
shapes,
assets,
currentUser,
curPres,
whiteboardId,
@ -54,7 +52,6 @@ export default function Whiteboard(props) {
skipToSlide,
slidePosition,
curPageId,
svgUri,
presentationWidth,
presentationHeight,
isViewersCursorLocked,
@ -66,6 +63,7 @@ export default function Whiteboard(props) {
width,
height,
isPanning,
intl,
} = props;
const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1);
@ -97,66 +95,137 @@ export default function Whiteboard(props) {
return zoom;
}
const hasShapeAccess = (id) => {
const owner = shapes[id]?.userId;
const isBackgroundShape = id?.includes('slide-background');
const hasShapeAccess = !isBackgroundShape && ((owner && owner === currentUser?.userId) || !owner || isPresenter || isModerator);
return hasShapeAccess;
}
const sendShapeChanges= (app, changedShapes, redo = false) => {
const invalidChange = Object.keys(changedShapes)
.find(id => !hasShapeAccess(id));
if (invalidChange) {
notifyNotAllowedChange(intl);
// undo last command without persisting to not generate the onUndo/onRedo callback
if (!redo) {
const command = app.stack[app.pointer];
app.pointer--;
return app.applyPatch(command.before, `undo`);
} else {
app.pointer++
const command = app.stack[app.pointer]
return app.applyPatch(command.after, 'redo');
}
};
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 may 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);
if (shapes[b.fromId] && !_.isEqual(boundShape, shapes[b.fromId])) {
const shapeBounds = app.getShapeBounds(b.fromId);
boundShape.size = [shapeBounds.width, shapeBounds.height];
persistShape(boundShape, whiteboardId)
}
}
})
}
if (!shape.id) {
// check it already exists (otherwise we need the full shape)
if (!shapes[id]) {
shape = app.getShape(id);
}
shape.id = id;
}
const shapeBounds = app.getShapeBounds(id);
const size = [shapeBounds.width, shapeBounds.height];
if (!shapes[id] || (shapes[id] && !_.isEqual(shapes[id].size, size))) {
shape.size = size;
}
if (!shapes[id] || (shapes[id] && !shapes[id].userId)) shape.userId = currentUser?.userId;
persistShape(shape, whiteboardId);
}
});
removeShapes(deletedShapes, whiteboardId);
}
const doc = React.useMemo(() => {
const currentDoc = rDocument.current;
let next = { ...currentDoc };
let pageBindings = null;
let history = null;
let stack = null;
let changed = false;
if (next.pageStates[curPageId] && !_.isEqual(prevShapes, shapes)) {
// mergeDocument loses bindings and history, save it
pageBindings = tldrawAPI?.getPage(curPageId)?.bindings;
history = tldrawAPI?.history
stack = tldrawAPI?.stack
// set shapes as locked for those who aren't allowed to edit it
Object.entries(shapes).forEach(([shapeId, shape]) => {
if (!shape.isLocked && !hasShapeAccess(shapeId)) {
shape.isLocked = true;
}
});
const removed = prevShapes && findRemoved(Object.keys(prevShapes),Object.keys((shapes)))
if (removed && removed.length > 0) {
tldrawAPI?.patchState(
{
document: {
pageStates: {
[curPageId]: {
selectedIds: tldrawAPI?.selectedIds?.filter(id => !removed.includes(id)) || [],
},
},
pages: {
[curPageId]: {
shapes: Object.fromEntries(removed.map((id) => [id, undefined])),
},
},
},
},
);
}
next.pages[curPageId].shapes = shapes;
changed = true;
}
if (curPageId && next.pages[curPageId] && !next.pages[curPageId].shapes["slide-background-shape"]) {
next.assets[`slide-background-asset-${curPageId}`] = {
id: `slide-background-asset-${curPageId}`,
size: [slidePosition?.width || 0, slidePosition?.height || 0],
src: svgUri,
type: "image",
};
if (curPageId && !next.assets[`slide-background-asset-${curPageId}`]) {
next.assets[`slide-background-asset-${curPageId}`] = assets[`slide-background-asset-${curPageId}`]
tldrawAPI?.patchState(
{
document: {
assets: assets
},
},
);
}
next.pages[curPageId].shapes["slide-background-shape"] = {
assetId: `slide-background-asset-${curPageId}`,
childIndex: -1,
id: "slide-background-shape",
name: "Image",
type: TDShapeType.Image,
parentId: `${curPageId}`,
point: [0, 0],
isLocked: true,
size: [slidePosition?.width || 0, slidePosition?.height || 0],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Blue,
if (changed && tldrawAPI) {
// merge patch manually (this improves performance and reduce side effects on fast updates)
const patch = {
document: {
pages: {
[curPageId]: { shapes: shapes }
},
},
};
changed = true;
}
if (changed) {
if (pageBindings) next.pages[curPageId].bindings = pageBindings;
tldrawAPI?.mergeDocument(next);
if (tldrawAPI && history) tldrawAPI.history = history;
if (tldrawAPI && stack) tldrawAPI.stack = stack;
const prevState = tldrawAPI._state;
const nextState = Utils.deepMerge(tldrawAPI._state, patch);
const final = tldrawAPI.cleanup(nextState, prevState, patch, '');
tldrawAPI._state = final;
tldrawAPI?.forceUpdate();
}
// 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"))
.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);
@ -209,8 +278,11 @@ export default function Whiteboard(props) {
if (cameraZoom && cameraZoom === 1) {
tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom);
} else if (isMounting) {
if (!fitToWidth) {
setIsMounting(false);
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');
@ -252,32 +324,34 @@ export default function Whiteboard(props) {
}
}, [zoomValue]);
// update zoom when presenter changes
// 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 && 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 {
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);
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);
}
}
}
}
@ -335,47 +409,53 @@ export default function Whiteboard(props) {
app.onPan = () => {};
app.setSelectedIds = () => {};
app.setHoveredId = () => {};
} else {
// disable hover highlight for background slide shape
app.setHoveredId = (id) => {
if (id?.includes('slide-background')) return null;
app.patchState(
{
document: {
pageStates: {
[app.getPage()?.id]: {
hoveredId: id || [],
},
},
},
},
`set_hovered_id`
);
};
// 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`
);
};
}
if (curPageId) {
app.changePage(curPageId);
setIsMounting(true);
}
};
const onPatch = (e, t, reason) => {
// don't allow select others shapes for editing if don't have permission
if (reason && reason.includes("set_editing_id")) {
if (!hasShapeAccess(e.pageState.editingId)) {
e.pageState.editingId = null;
}
}
// don't allow hover others shapes for editing if don't have permission
if (reason && reason.includes("set_hovered_id")) {
if (!hasShapeAccess(e.pageState.hoveredId)) {
e.pageState.hoveredId = null;
}
}
// don't allow select others shapes if don't have permission
if (reason && reason.includes("selected")) {
const validIds = [];
e.pageState.selectedIds.forEach(id => hasShapeAccess(id) && validIds.push(id));
e.pageState.selectedIds = validIds;
e.patchState(
{
document: {
pageStates: {
[e.getPage()?.id]: {
selectedIds: validIds,
},
},
},
}
);
}
// don't allow selecting others shapes with ctrl (brush)
if (e?.session?.type === "brush" && e?.session?.status === "brushing") {
const validIds = [];
e.pageState.selectedIds.forEach(id => hasShapeAccess(id) && validIds.push(id));
e.pageState.selectedIds = validIds;
if (!validIds.find(id => id === e.pageState.hoveredId)) {
e.pageState.hoveredId = undefined;
}
}
if (reason && isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) {
const camera = tldrawAPI.getPageState()?.camera;
@ -438,14 +518,84 @@ export default function Whiteboard(props) {
}
}
if (reason && reason === 'patched_shapes') {
if (reason && reason === 'patched_shapes' && e?.session?.type === "edit" && e?.session?.initialShape?.type === "text") {
const patchedShape = e?.getShape(e?.getPageState()?.editingId);
if (patchedShape?.type === 'text') {
if (!shapes[patchedShape.id]) {
patchedShape.userId = currentUser?.userId;
persistShape(patchedShape, whiteboardId);
} else {
const diff = {
id: patchedShape.id,
point: patchedShape.point,
text: patchedShape.text
}
persistShape(diff, whiteboardId);
}
}
};
const onUndo = (app) => {
if (app.currentPageId !== curPageId) {
if (isPresenter) {
// change slide for others
skipToSlide(Number.parseInt(app.currentPageId), podId)
} else {
// ignore, stay on same page
app.changePage(curPageId);
}
return;
}
const lastCommand = app.stack[app.pointer+1];
const changedShapes = lastCommand?.before?.document?.pages[app.currentPageId]?.shapes;
if (changedShapes) {
sendShapeChanges(app, changedShapes, true);
}
};
const onRedo = (app) => {
if (app.currentPageId !== curPageId) {
if (isPresenter) {
// change slide for others
skipToSlide(Number.parseInt(app.currentPageId), podId)
} else {
// ignore, stay on same page
app.changePage(curPageId);
}
return;
}
const lastCommand = app.stack[app.pointer];
const changedShapes = lastCommand?.after?.document?.pages[app.currentPageId]?.shapes;
if (changedShapes) {
sendShapeChanges(app, changedShapes);
}
};
const onCommand = (app, command, reason) => {
const changedShapes = command.after?.document?.pages[app.currentPageId]?.shapes;
if (!isMounting && app.currentPageId !== curPageId) {
// can happen then the "move to page action" is called, or using undo after changing a page
const newWhiteboardId = curPres.pages.find(page => page.num === Number.parseInt(app.currentPageId)).id;
//remove from previous page and persist on new
changedShapes && removeShapes(Object.keys(changedShapes), whiteboardId);
changedShapes && 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 if (changedShapes) {
sendShapeChanges(app, changedShapes);
}
};
const webcams = document.getElementById('cameraDock');
const dockPos = webcams?.getAttribute("data-position");
const editableWB = (
@ -466,174 +616,9 @@ export default function Whiteboard(props) {
showMultiplayerMenu={false}
readOnly={false}
onPatch={onPatch}
onUndo={(e, s) => {
e?.selectedIds?.map(id => {
const shape = e.getShape(id);
persistShape(shape, whiteboardId);
const children = shape.children;
children && children.forEach(c => {
const childShape = e.getShape(c);
const shapeBounds = e.getShapeBounds(c);
childShape.size = [shapeBounds.width, shapeBounds.height];
persistShape(childShape, whiteboardId)
});
})
const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes;
let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes))
if (shapesIdsToRemove.length) {
// add a little delay, wee need to make sure children are updated first
setTimeout(() => removeShapes(shapesIdsToRemove, whiteboardId), 200);
}
}}
onRedo={(e, s) => {
e?.selectedIds?.map(id => {
const shape = e.getShape(id);
persistShape(shape, whiteboardId);
const children = shape.children;
children && children.forEach(c => {
const childShape = e.getShape(c);
const shapeBounds = e.getShapeBounds(c);
childShape.size = [shapeBounds.width, shapeBounds.height];
persistShape(childShape, whiteboardId)
});
});
const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes;
let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes))
if (shapesIdsToRemove.length) {
// add a little delay, wee need to make sure children are updated first
setTimeout(() => removeShapes(shapesIdsToRemove, whiteboardId), 200);
}
}}
onCommand={(e, s, g) => {
if (s?.id.includes('move_to_page')) {
let groupShapes = [];
let nonGroupShapes = [];
let movedShapes = {};
e.selectedIds.forEach(id => {
const shape = e.getShape(id);
if (shape.type === 'group')
groupShapes.push(id);
else
nonGroupShapes.push(id);
movedShapes[id] = e.getShape(id);
});
//remove shapes on origin page
let idsToRemove = nonGroupShapes.concat(groupShapes);
removeShapes(idsToRemove, whiteboardId);
//persist shapes for destination page
const newWhiteboardId = curPres.pages.find(page => page.num === Number.parseInt(e.getPage()?.id)).id;
let idsToInsert = groupShapes.concat(nonGroupShapes);
idsToInsert.forEach(id => {
persistShape(movedShapes[id], newWhiteboardId);
const children = movedShapes[id].children;
children && children.forEach(c => {
persistShape(e.getShape(c), newWhiteboardId)
});
});
if (isPresenter) {
// change slide for others
skipToSlide(Number.parseInt(e.getPage()?.id), podId)
} else {
// ignore, stay on same page
e.changePage(curPageId);
}
return;
}
if (s?.id.includes('ungroup')) {
e?.selectedIds?.map(id => {
persistShape(e.getShape(id), whiteboardId);
})
// check for deleted shapes
const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes;
let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes))
if (shapesIdsToRemove.length) {
// add a little delay, wee need to make sure children are updated first
setTimeout(() => removeShapes(shapesIdsToRemove, whiteboardId), 200);
}
return;
}
const conditions = [
"session:complete", "style", "updated_shapes", "duplicate", "stretch",
"align", "move", "delete", "create", "flip", "toggle", "group", "translate",
"transform_single", "arrow", "edit", "erase", "rotate",
]
if (conditions.some(el => s?.id?.startsWith(el))) {
e.selectedIds.forEach(id => {
const shape = e.getShape(id);
const shapeBounds = e.getShapeBounds(id);
shape.size = [shapeBounds.width, shapeBounds.height];
persistShape(shape, whiteboardId);
//checks to find any bindings assosiated with the selected shapes.
//If any, they need to be updated as well.
const pageBindings = e.bindings;
const boundShapes = {};
if (pageBindings) {
Object.entries(pageBindings).map(([k,b]) => {
if (b.toId.includes(id)) {
boundShapes[b.fromId] = e.getShape(b.fromId);
}
})
}
//persist shape(s) that was updated by the client and any shapes bound to it.
Object.entries(boundShapes).map(([k,bs]) => {
const shapeBounds = e.getShapeBounds(k);
bs.size = [shapeBounds.width, shapeBounds.height];
persistShape(bs, whiteboardId)
})
const children = e.getShape(id).children;
//also persist children of the selected shape (grouped shapes)
children && children.forEach(c => {
const shape = e.getShape(c);
const shapeBounds = e.getShapeBounds(c);
shape.size = [shapeBounds.width, shapeBounds.height];
persistShape(shape, whiteboardId)
// also persist shapes that are bound to the children
if (pageBindings) {
Object.entries(pageBindings).map(([k,b]) => {
if (!(b.fromId in boundShapes) && b.toId.includes(c)) {
const shape = e.getShape(b.fromId);
persistShape(shape, whiteboardId)
boundShapes[b.fromId] = shape;
}
})
}
})
});
// draw shapes
Object.entries(e.state.document.pages[e.getPage()?.id]?.shapes)
.filter(([k, s]) => s?.type === 'draw')
.forEach(([k, s]) => {
if (!prevShapes[k] && !k.includes('slide-background')) {
const shapeBounds = e.getShapeBounds(k);
s.size = [shapeBounds.width, shapeBounds.height];
persistShape(s, whiteboardId);
}
});
// check for deleted shapes
const pageShapes = e.state.document.pages[e.getPage()?.id]?.shapes;
let shapesIdsToRemove = findRemoved(Object.keys(shapes), Object.keys(pageShapes))
let groups = [];
let nonGroups = [];
// if we have groups, we need to make sure they are removed lastly
shapesIdsToRemove.forEach(shape => {
if (shapes[shape].type === 'group') {
groups.push(shape);
} else {
nonGroups.push(shape);
}
});
if (shapesIdsToRemove.length) {
shapesIdsToRemove = nonGroups.concat(groups);
removeShapes(shapesIdsToRemove, whiteboardId);
}
}
}}
onUndo={onUndo}
onRedo={onRedo}
onCommand={onCommand}
/>
);

View File

@ -6,6 +6,14 @@ import { UsersContext } from "../components-data/users-context/context";
import Auth from "/imports/ui/services/auth";
import PresentationToolbarService from '../presentation/presentation-toolbar/service';
import { layoutSelect } from '../layout/context';
import {
ColorStyle,
DashStyle,
SizeStyle,
TDShapeType,
} from "@tldraw/tldraw";
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const WhiteboardContainer = (props) => {
const usingUsersContext = useContext(UsersContext);
@ -15,13 +23,39 @@ const WhiteboardContainer = (props) => {
const { users } = usingUsersContext;
const currentUser = users[Auth.meetingID][Auth.userID];
const isPresenter = currentUser.presenter;
return <Whiteboard {...{ isPresenter, currentUser, isRTL, width, height }} {...props} meetingId={Auth.meetingID} />
const isModerator = currentUser.role === ROLE_MODERATOR;
return <Whiteboard {...{ isPresenter, isModerator, currentUser, isRTL, width, height }} {...props} meetingId={Auth.meetingID} />
};
export default withTracker(({ whiteboardId, curPageId, intl, zoomChanger }) => {
export default withTracker(({ whiteboardId, curPageId, intl, zoomChanger, slidePosition, svgUri }) => {
const shapes = Service.getShapes(whiteboardId, curPageId, intl);
const curPres = Service.getCurrentPres();
shapes["slide-background-shape"] = {
assetId: `slide-background-asset-${curPageId}`,
childIndex: -1,
id: "slide-background-shape",
name: "Image",
type: TDShapeType.Image,
parentId: `${curPageId}`,
point: [0, 0],
isLocked: true,
size: [slidePosition?.width || 0, slidePosition?.height || 0],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
};
const assets = {}
assets[`slide-background-asset-${curPageId}`] = {
id: `slide-background-asset-${curPageId}`,
size: [slidePosition?.width || 0, slidePosition?.height || 0],
src: svgUri,
type: "image",
};
return {
initDefaultPages: Service.initDefaultPages,
persistShape: Service.persistShape,
@ -29,10 +63,12 @@ export default withTracker(({ whiteboardId, curPageId, intl, zoomChanger }) => {
hasMultiUserAccess: Service.hasMultiUserAccess,
changeCurrentSlide: Service.changeCurrentSlide,
shapes: shapes,
assets: assets,
curPres,
removeShapes: Service.removeShapes,
zoomSlide: PresentationToolbarService.zoomSlide,
skipToSlide: PresentationToolbarService.skipToSlide,
zoomChanger: zoomChanger,
notifyNotAllowedChange: Service.notifyNotAllowedChange,
};
})(WhiteboardContainer);

View File

@ -305,7 +305,7 @@ export default function Cursors(props) {
{children}
</div>
{otherCursors
.filter((c) => c?.xPercent && c?.yPercent)
.filter((c) => c?.xPercent && c.xPercent !== -1.0 && c?.yPercent && c.yPercent !== -1.0)
.filter((c) => {
if ((isViewersCursorLocked && c?.role !== "VIEWER") || !isViewersCursorLocked || currentUser?.presenter) {
return c;

View File

@ -7,6 +7,8 @@ import { makeCall } from '/imports/ui/services/api';
import PresentationService from '/imports/ui/components/presentation/service';
import PollService from '/imports/ui/components/poll/service';
import logger from '/imports/startup/client/logger';
import { defineMessages } from 'react-intl';
import { notify } from '/imports/ui/services/notification';
const Annotations = new Mongo.Collection(null);
@ -14,6 +16,13 @@ const UnsentAnnotations = new Mongo.Collection(null);
const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
const DRAW_END = ANNOTATION_CONFIG.status.end;
const intlMessages = defineMessages({
notifyNotAllowedChange: {
id: 'app.whiteboard.annotations.notAllowed',
description: 'Label shown in toast when the user make a change on a shape he doesnt have permission',
},
});
let annotationsStreamListener = null;
const clearPreview = (annotation) => {
@ -32,7 +41,7 @@ function handleAddedAnnotation({
annotation,
}) {
const isOwn = Auth.meetingID === meetingId && Auth.userID === userId;
const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation);
const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations);
Annotations.upsert(query.selector, query.modifier);
@ -146,7 +155,12 @@ const sendAnnotation = (annotation) => {
// reconnected. With this it will miss things
if (!Meteor.status().connected) return;
annotationsQueue.push(annotation);
const index = annotationsQueue.findIndex(ann => ann.id === annotation.id);
if (index !== -1) {
annotationsQueue[index] = annotation;
} else {
annotationsQueue.push(annotation);
}
if (!annotationsSenderIsRunning)
setTimeout(proccessAnnotationsQueue, annotationsBufferTimeMin);
};
@ -295,7 +309,7 @@ const persistShape = (shape, whiteboardId) => {
id: shape.id,
annotationInfo: shape,
wbId: whiteboardId,
userId: shape.userId ? shape.userId : Auth.userID,
userId: Auth.userID,
};
sendAnnotation(annotation);
@ -343,8 +357,8 @@ const getShapes = (whiteboardId, curPageId, intl) => {
dash: "draw"
},
}
annotation.annotationInfo.questionType = false;
}
annotation.annotationInfo.userId = annotation.userId;
result[annotation.annotationInfo.id] = annotation.annotationInfo;
});
return result;
@ -379,6 +393,10 @@ const initDefaultPages = (count = 1) => {
return { pages, pageStates };
};
const notifyNotAllowedChange = (intl) => {
if (intl) notify(intl.formatMessage(intlMessages.notifyNotAllowedChange), 'warning', 'whiteboard');
};
export {
initDefaultPages,
Annotations,
@ -402,4 +420,5 @@ export {
removeShapes,
changeCurrentSlide,
clearFakeAnnotations,
notifyNotAllowedChange,
};

View File

@ -952,6 +952,7 @@
"app.whiteboard.annotations.poll": "Poll results were published",
"app.whiteboard.annotations.pollResult": "Poll Result",
"app.whiteboard.annotations.noResponses": "No responses",
"app.whiteboard.annotations.notAllowed": "You are not allowed to make this change",
"app.whiteboard.toolbar.tools": "Tools",
"app.whiteboard.toolbar.tools.hand": "Pan",
"app.whiteboard.toolbar.tools.pencil": "Pencil",

View File

@ -825,6 +825,7 @@
"app.whiteboard.annotations.poll": "Os resultados da enquete foram publicados",
"app.whiteboard.annotations.pollResult": "Resultado da Enquete",
"app.whiteboard.annotations.noResponses": "Sem respostas",
"app.whiteboard.annotations.notAllowed": "Você não tem permissão para fazer essa alteração",
"app.whiteboard.toolbar.tools": "Ferramentas",
"app.whiteboard.toolbar.tools.hand": "Mover",
"app.whiteboard.toolbar.tools.pencil": "Lápis",

View File

@ -31,6 +31,7 @@ require 'yaml'
require 'builder'
require 'fastimage' # require fastimage to get the image size of the slides (gem install fastimage)
require 'json'
require "active_support"
# This script lives in scripts/archive/steps while properties.yaml lives in scripts/
bbb_props = BigBlueButton.read_props
@ -607,12 +608,13 @@ def events_parse_tldraw_shape(shapes, event, current_presentation, current_slide
prev_shape = nil
if shape_id
# If we have a shape ID, look up the previous shape by ID
prev_shape_pos = shapes.rindex { |s| s[:shade_id] == shape_id }
prev_shape_pos = shapes.rindex { |s| s[:id] == shape_id }
prev_shape = prev_shape_pos ? shapes[prev_shape_pos] : nil
end
if prev_shape
prev_shape[:out] = timestamp
shape[:shape_unique_id] = prev_shape[:shape_unique_id]
shape[:shape_data] = prev_shape[:shape_data].deep_merge(shape[:shape_data])
else
shape[:shape_unique_id] = @svg_shape_unique_id
@svg_shape_unique_id += 1