2023-03-10 19:30:46 +08:00
|
|
|
import React from 'react';
|
2023-04-05 20:44:47 +08:00
|
|
|
import { isEqual } from 'radash';
|
2023-03-10 19:30:46 +08:00
|
|
|
import {
|
|
|
|
persistShape,
|
|
|
|
removeShapes,
|
|
|
|
notifyNotAllowedChange,
|
|
|
|
notifyShapeNumberExceeded,
|
|
|
|
} from './service';
|
|
|
|
|
|
|
|
const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
|
2023-06-06 04:02:06 +08:00
|
|
|
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
|
2023-03-10 19:30:46 +08:00
|
|
|
|
|
|
|
const usePrevious = (value) => {
|
|
|
|
const ref = React.useRef();
|
|
|
|
React.useEffect(() => {
|
|
|
|
ref.current = value;
|
|
|
|
}, [value]);
|
|
|
|
return ref.current;
|
|
|
|
};
|
|
|
|
|
|
|
|
const findRemoved = (A, B) => A.filter((a) => !B.includes(a));
|
|
|
|
|
|
|
|
const filterInvalidShapes = (shapes, curPageId, tldrawAPI) => {
|
|
|
|
const retShapes = shapes;
|
|
|
|
const keys = Object.keys(shapes);
|
|
|
|
const removedChildren = [];
|
|
|
|
const removedParents = [];
|
|
|
|
|
|
|
|
keys.forEach((shape) => {
|
|
|
|
if (shapes[shape].parentId !== curPageId) {
|
|
|
|
if (!keys.includes(shapes[shape].parentId)) {
|
|
|
|
delete retShapes[shape];
|
|
|
|
}
|
|
|
|
} else if (shapes[shape].type === 'group') {
|
|
|
|
const groupChildren = shapes[shape].children;
|
|
|
|
|
|
|
|
groupChildren.forEach((child) => {
|
|
|
|
if (!keys.includes(child)) {
|
|
|
|
removedChildren.push(child);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
retShapes[shape].children = groupChildren.filter((child) => !removedChildren.includes(child));
|
|
|
|
|
|
|
|
if (shapes[shape].children.length < 2) {
|
|
|
|
removedParents.push(shape);
|
|
|
|
delete retShapes[shape];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
// remove orphaned children
|
|
|
|
Object.keys(shapes).forEach((shape) => {
|
|
|
|
if (shapes[shape] && shapes[shape].parentId !== curPageId) {
|
|
|
|
if (removedParents.includes(shapes[shape].parentId)) {
|
|
|
|
delete retShapes[shape];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// remove orphaned bindings
|
|
|
|
if (shapes[shape] && shapes[shape].type === 'arrow'
|
|
|
|
&& (shapes[shape].handles.start.bindingId || shapes[shape].handles.end.bindingId)) {
|
|
|
|
const startBinding = shapes[shape].handles.start.bindingId;
|
|
|
|
const endBinding = shapes[shape].handles.end.bindingId;
|
|
|
|
|
|
|
|
const startBindingData = tldrawAPI?.getBinding(startBinding);
|
|
|
|
const endBindingData = tldrawAPI?.getBinding(endBinding);
|
|
|
|
|
|
|
|
if (startBinding && (!startBindingData && (
|
|
|
|
removedParents.includes(startBindingData?.fromId)
|
|
|
|
|| removedParents.includes(startBindingData?.toId)
|
|
|
|
|| !keys.includes(startBindingData?.fromId)
|
|
|
|
|| !keys.includes(startBindingData?.toId)
|
|
|
|
))) {
|
|
|
|
delete retShapes[shape].handles.start.bindingId;
|
|
|
|
}
|
|
|
|
if (endBinding && (!endBindingData && (
|
|
|
|
removedParents.includes(endBindingData?.fromId)
|
|
|
|
|| removedParents.includes(endBindingData?.toId)
|
|
|
|
|| !keys.includes(endBindingData?.fromId)
|
|
|
|
|| !keys.includes(endBindingData?.toId)
|
|
|
|
))) {
|
|
|
|
delete retShapes[shape].handles.end.bindingId;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return retShapes;
|
|
|
|
};
|
|
|
|
|
|
|
|
const isValidShapeType = (shape) => {
|
|
|
|
const invalidTypes = ['image', 'video'];
|
|
|
|
return !invalidTypes.includes(shape?.type);
|
|
|
|
};
|
|
|
|
|
|
|
|
const sendShapeChanges = (
|
|
|
|
app,
|
|
|
|
changedShapes,
|
|
|
|
shapes,
|
|
|
|
prevShapes,
|
|
|
|
hasShapeAccess,
|
|
|
|
whiteboardId,
|
|
|
|
currentUser,
|
|
|
|
intl,
|
|
|
|
redo = false,
|
|
|
|
) => {
|
2023-06-06 20:11:58 +08:00
|
|
|
let isModerator = currentUser?.role === ROLE_MODERATOR;
|
2023-06-06 04:02:06 +08:00
|
|
|
|
2023-03-10 19:30:46 +08:00
|
|
|
const invalidChange = Object.keys(changedShapes)
|
|
|
|
.find((id) => !hasShapeAccess(id));
|
|
|
|
|
|
|
|
const invalidShapeType = Object.keys(changedShapes)
|
|
|
|
.find((id) => !isValidShapeType(changedShapes[id]));
|
|
|
|
|
|
|
|
const currentShapes = app?.document?.pages[app?.currentPageId]?.shapes;
|
|
|
|
const { maxNumberOfAnnotations } = WHITEBOARD_CONFIG;
|
|
|
|
// -1 for background shape
|
|
|
|
const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations;
|
|
|
|
|
|
|
|
const isInserting = Object.keys(changedShapes)
|
|
|
|
.filter(
|
|
|
|
(shape) => typeof changedShapes[shape] === 'object'
|
|
|
|
&& changedShapes[shape].type
|
|
|
|
&& !prevShapes[shape],
|
|
|
|
).length !== 0;
|
|
|
|
|
|
|
|
if (invalidChange || invalidShapeType || (shapeNumberExceeded && isInserting)) {
|
|
|
|
if (shapeNumberExceeded) {
|
|
|
|
notifyShapeNumberExceeded(intl, maxNumberOfAnnotations);
|
|
|
|
} else {
|
|
|
|
notifyNotAllowedChange(intl);
|
|
|
|
}
|
|
|
|
const modApp = app;
|
|
|
|
// undo last command without persisting to not generate the onUndo/onRedo callback
|
|
|
|
if (!redo) {
|
|
|
|
const command = app.stack[app.pointer];
|
|
|
|
modApp.pointer -= 1;
|
|
|
|
app.applyPatch(command.before, 'undo');
|
|
|
|
return;
|
|
|
|
// eslint-disable-next-line no-else-return
|
|
|
|
} else {
|
|
|
|
modApp.pointer += 1;
|
|
|
|
const command = app.stack[app.pointer];
|
|
|
|
app.applyPatch(command.after, 'redo');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const 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).forEach(([, b]) => {
|
|
|
|
if (b.toId.includes(id)) {
|
|
|
|
const boundShape = app.getShape(b.fromId);
|
2023-04-05 20:44:47 +08:00
|
|
|
if (shapes[b.fromId] && !isEqual(boundShape, shapes[b.fromId])) {
|
2023-03-10 19:30:46 +08:00
|
|
|
const shapeBounds = app.getShapeBounds(b.fromId);
|
|
|
|
boundShape.size = [shapeBounds.width, shapeBounds.height];
|
2023-06-06 04:02:06 +08:00
|
|
|
persistShape(boundShape, whiteboardId, isModerator);
|
2023-03-10 19:30:46 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
let modShape = shape;
|
|
|
|
if (!shape.id) {
|
|
|
|
// check it already exists (otherwise we need the full shape)
|
|
|
|
if (!shapes[id]) {
|
|
|
|
modShape = app.getShape(id);
|
|
|
|
}
|
|
|
|
modShape.id = id;
|
|
|
|
}
|
|
|
|
const shapeBounds = app.getShapeBounds(id);
|
|
|
|
const size = [shapeBounds.width, shapeBounds.height];
|
2023-04-05 20:44:47 +08:00
|
|
|
if (!shapes[id] || (shapes[id] && !isEqual(shapes[id].size, size))) {
|
2023-03-10 19:30:46 +08:00
|
|
|
modShape.size = size;
|
|
|
|
}
|
|
|
|
if (!shapes[id] || (shapes[id] && !shapes[id].userId)) {
|
|
|
|
modShape.userId = currentUser?.userId;
|
|
|
|
}
|
2023-06-06 20:11:58 +08:00
|
|
|
// do not change moderator status for existing shapes
|
|
|
|
if (shapes[id]) {
|
|
|
|
isModerator = shapes[id].isModerator;
|
|
|
|
}
|
2023-06-06 04:02:06 +08:00
|
|
|
persistShape(modShape, whiteboardId, isModerator);
|
2023-03-10 19:30:46 +08:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// order the ids of shapes being deleted to prevent crash
|
|
|
|
// when removing a group shape before its children
|
|
|
|
const orderedDeletedShapes = [];
|
|
|
|
deletedShapes.forEach((eid) => {
|
|
|
|
if (shapes[eid]?.type !== 'group') {
|
|
|
|
orderedDeletedShapes.unshift(eid);
|
|
|
|
} else {
|
|
|
|
orderedDeletedShapes.push(eid);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (orderedDeletedShapes.length > 0) {
|
|
|
|
removeShapes(orderedDeletedShapes, whiteboardId);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// map different localeCodes from bbb to tldraw
|
|
|
|
const mapLanguage = (language) => {
|
|
|
|
// bbb has xx-xx but in tldraw it's only xx
|
|
|
|
if (['es', 'fa', 'it', 'pl', 'sv', 'uk'].some((lang) => language.startsWith(lang))) {
|
|
|
|
return language.substring(0, 2);
|
|
|
|
}
|
|
|
|
// exceptions
|
|
|
|
switch (language) {
|
|
|
|
case 'nb-no':
|
|
|
|
return 'no';
|
|
|
|
case 'zh-cn':
|
|
|
|
return 'zh-ch';
|
|
|
|
default:
|
|
|
|
return language;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-03-17 19:40:24 +08:00
|
|
|
/* getFontStyle adapted from tldraw source code
|
|
|
|
https://github.com/tldraw/tldraw/blob/55a8831a6b036faae0dfd77d6733a8f585f5ae23/packages/tldraw/src/state/shapes/shared/shape-styles.ts#L123 */
|
2023-03-17 02:55:56 +08:00
|
|
|
const getFontStyle = (style) => {
|
|
|
|
const fontSizes = {
|
|
|
|
small: 28,
|
|
|
|
medium: 48,
|
|
|
|
large: 96,
|
|
|
|
auto: 'auto',
|
|
|
|
};
|
|
|
|
|
|
|
|
const fontFaces = {
|
|
|
|
script: '"Caveat Brush"',
|
|
|
|
sans: '"Source Sans Pro"',
|
|
|
|
serif: '"Crimson Pro"',
|
|
|
|
mono: '"Source Code Pro"',
|
|
|
|
}
|
|
|
|
|
|
|
|
const fontSize = fontSizes[style.size];
|
|
|
|
const fontFace = fontFaces[style.font];
|
|
|
|
const { scale = 1 } = style;
|
|
|
|
|
|
|
|
return `${fontSize * scale}px/1 ${fontFace}`;
|
|
|
|
}
|
|
|
|
|
2023-03-17 19:40:24 +08:00
|
|
|
/* getMeasurementDiv and getTextSize adapted from tldraw source code
|
|
|
|
https://github.com/tldraw/tldraw/blob/55a8831a6b036faae0dfd77d6733a8f585f5ae23/packages/tldraw/src/state/shapes/shared/getTextSize.ts */
|
2023-03-17 02:55:56 +08:00
|
|
|
const getMeasurementDiv = (font) => {
|
|
|
|
// A div used for measurement
|
2023-03-18 01:51:39 +08:00
|
|
|
const pre = document.getElementById('text-measure');
|
|
|
|
pre.style.font = font;
|
2023-03-17 02:55:56 +08:00
|
|
|
|
|
|
|
return pre;
|
|
|
|
}
|
|
|
|
|
|
|
|
const getTextSize = (text, style, padding) => {
|
|
|
|
const font = getFontStyle(style);
|
|
|
|
|
|
|
|
if (!text) {
|
|
|
|
return [16, 32];
|
|
|
|
}
|
|
|
|
|
|
|
|
const melm = getMeasurementDiv(font);
|
2023-03-18 01:51:39 +08:00
|
|
|
melm.textContent = text;
|
2023-03-17 02:55:56 +08:00
|
|
|
|
|
|
|
if (!melm) {
|
|
|
|
// We're in SSR
|
|
|
|
return [10, 10];
|
|
|
|
}
|
|
|
|
|
|
|
|
// In tests, offsetWidth and offsetHeight will be 0
|
|
|
|
const width = melm.offsetWidth || 1;
|
|
|
|
const height = melm.offsetHeight || 1;
|
|
|
|
|
|
|
|
return [width + padding, height + padding];
|
|
|
|
};
|
|
|
|
|
2023-03-10 19:30:46 +08:00
|
|
|
const Utils = {
|
2023-03-17 02:55:56 +08:00
|
|
|
usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize,
|
2023-03-10 19:30:46 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
export default Utils;
|
|
|
|
export {
|
2023-03-17 02:55:56 +08:00
|
|
|
usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize,
|
2023-03-10 19:30:46 +08:00
|
|
|
};
|