bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/presentation/presentation-overlay/component.jsx
2020-11-30 16:09:35 +00:00

628 lines
17 KiB
JavaScript
Executable File

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { throttle } from 'lodash';
import SlideCalcUtil, {
HUNDRED_PERCENT, MAX_PERCENT, STEP,
} from '/imports/utils/slideCalcUtils';
import {Meteor} from "meteor/meteor";
// After lots of trial and error on why synching doesn't work properly, I found I had to
// multiply the coordinates by 2. There's something I don't understand probably on the
// canvas coordinate system. (ralam feb 22, 2012)
// maximum value of z-index to prevent other things from overlapping
const MAX_Z_INDEX = (2 ** 31) - 1;
const HAND_TOOL = 'hand';
const MOUSE_INTERVAL = 32;
export default class PresentationOverlay extends Component {
static calculateDistance(touches) {
return Math.sqrt(((touches[0].clientX - touches[1].clientX) ** 2)
+ ((touches[0].clientY - touches[1].clientY) ** 2));
}
static touchCenterPoint(touches) {
let totalX = 0; let
totalY = 0;
for (let i = 0; i < touches.length; i += 1) {
totalX += touches[i].clientX;
totalY += touches[i].clientY;
}
return { x: totalX / touches.length, y: totalY / touches.length };
}
constructor(props) {
super(props);
this.currentMouseX = 0;
this.currentMouseY = 0;
this.prevZoom = props.zoom;
this.state = {
pressed: false,
};
// Mobile Firefox has a bug where e.preventDefault on touchstart doesn't prevent
// onmousedown from triggering right after. Thus we have to track it manually.
// In case if it's fixed one day - there is another issue, React one.
// https://github.com/facebook/react/issues/9809
// Check it to figure if you can add onTouchStart in render(), or should use raw DOM api
this.touchStarted = false;
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = throttle(this.handleTouchMove.bind(this), MOUSE_INTERVAL);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.handleTouchCancel = this.handleTouchCancel.bind(this);
this.mouseDownHandler = this.mouseDownHandler.bind(this);
this.mouseMoveHandler = throttle(this.mouseMoveHandler.bind(this), MOUSE_INTERVAL);
this.mouseUpHandler = this.mouseUpHandler.bind(this);
this.mouseZoomHandler = this.mouseZoomHandler.bind(this);
this.tapedTwice = false;
}
componentDidMount() {
const {
zoom,
slideWidth,
svgWidth,
svgHeight,
userIsPresenter,
} = this.props;
if (userIsPresenter) {
this.viewBoxW = slideWidth / zoom * HUNDRED_PERCENT;
this.viewBoxH = svgHeight / svgWidth * this.viewBoxW;
this.doWidthBoundsDetection();
this.doHeightBoundsDetection();
this.pushSlideUpdate();
}
}
componentDidUpdate(prevProps) {
const {
zoom,
fitToWidth,
svgWidth,
svgHeight,
slideWidth,
userIsPresenter,
slide,
} = this.props;
if (!userIsPresenter) return;
if (zoom !== this.prevZoom) {
this.toolbarZoom();
}
if (fitToWidth !== prevProps.fitToWidth
|| this.checkResize(prevProps.svgWidth, prevProps.svgHeight)
|| slide.id !== prevProps.slide.id) {
this.viewBoxW = slideWidth / zoom * HUNDRED_PERCENT;
this.viewBoxH = svgHeight / svgWidth * this.viewBoxW;
this.doWidthBoundsDetection();
this.doHeightBoundsDetection();
this.pushSlideUpdate();
}
}
componentWillUnmount() {
window.removeEventListener('mousemove', this.mouseMoveHandler);
window.removeEventListener('mouseup', this.mouseUpHandler);
}
getTransformedSvgPoint(clientX, clientY) {
const {
getSvgRef,
} = this.props;
const svgObject = getSvgRef();
// If svgObject is not ready, return origin
if (!svgObject) return { x: 0, y: 0 };
const screenPoint = svgObject.createSVGPoint();
screenPoint.x = clientX;
screenPoint.y = clientY;
// transform a screen point to svg point
const CTM = svgObject.getScreenCTM();
return screenPoint.matrixTransform(CTM.inverse());
}
pushSlideUpdate() {
const {
updateLocalPosition,
panAndZoomChanger,
} = this.props;
if (this.didPositionChange()) {
this.calcViewedRegion();
updateLocalPosition(
this.viewBoxX, this.viewBoxY,
this.viewBoxW, this.viewBoxH,
this.prevZoom,
);
panAndZoomChanger(
this.viewedRegionW, this.viewedRegionH,
this.viewedRegionX, this.viewedRegionY,
);
}
}
checkResize(prevWidth, prevHeight) {
const {
svgWidth,
svgHeight,
} = this.props;
const heightChanged = svgWidth !== prevWidth;
const widthChanged = svgHeight !== prevHeight;
return heightChanged || widthChanged;
}
didPositionChange() {
const {
viewBoxX,
viewBoxY,
viewBoxWidth,
viewBoxHeight,
} = this.props;
return this.viewBoxX !== viewBoxX || this.viewBoxY !== viewBoxY
|| this.viewBoxW !== viewBoxWidth || this.viewBoxH !== viewBoxHeight;
}
panSlide(deltaX, deltaY) {
const {
zoom,
} = this.props;
this.viewBoxX += deltaX;
this.viewBoxY += deltaY;
this.doHeightBoundsDetection();
this.doWidthBoundsDetection();
this.prevZoom = zoom;
this.pushSlideUpdate();
}
toolbarZoom() {
const { zoom } = this.props;
const viewPortCenterX = this.viewBoxW / 2 + this.viewBoxX;
const viewPortCenterY = this.viewBoxH / 2 + this.viewBoxY;
this.doZoomCall(zoom, viewPortCenterX, viewPortCenterY);
}
doWidthBoundsDetection() {
const {
slideWidth,
} = this.props;
const verifyPositionToBound = (this.viewBoxW + this.viewBoxX);
if (this.viewBoxX <= 0) {
this.viewBoxX = 0;
} else if (verifyPositionToBound > slideWidth) {
this.viewBoxX = (slideWidth - this.viewBoxW);
}
}
doHeightBoundsDetection() {
const {
slideHeight,
} = this.props;
const verifyPositionToBound = (this.viewBoxH + this.viewBoxY);
if (this.viewBoxY < 0) {
this.viewBoxY = 0;
} else if (verifyPositionToBound > slideHeight) {
this.viewBoxY = (slideHeight - this.viewBoxH);
}
}
calcViewedRegion() {
const {
slideWidth,
slideHeight,
} = this.props;
this.viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(this.viewBoxW, slideWidth);
this.viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(this.viewBoxH, slideHeight);
this.viewedRegionX = SlideCalcUtil.calcViewedRegionX(this.viewBoxX, slideWidth);
this.viewedRegionY = SlideCalcUtil.calcViewedRegionY(this.viewBoxY, slideHeight);
}
// receives an svg coordinate and changes the values to percentages of the slide's width/height
svgCoordinateToPercentages(svgPoint) {
const {
slideWidth,
slideHeight,
} = this.props;
const point = {
x: (svgPoint.x / slideWidth) * 100,
y: (svgPoint.y / slideHeight) * 100,
};
return point;
}
doZoomCall(zoom, mouseX, mouseY) {
const {
svgWidth,
svgHeight,
slideWidth,
} = this.props;
const relXcoordInViewport = (mouseX - this.viewBoxX) / this.viewBoxW;
const relYcoordInViewport = (mouseY - this.viewBoxY) / this.viewBoxH;
this.viewBoxW = slideWidth / zoom * HUNDRED_PERCENT;
this.viewBoxH = svgHeight / svgWidth * this.viewBoxW;
this.viewBoxX = mouseX - (relXcoordInViewport * this.viewBoxW);
this.viewBoxY = mouseY - (relYcoordInViewport * this.viewBoxH);
this.doWidthBoundsDetection();
this.doHeightBoundsDetection();
this.prevZoom = zoom;
this.pushSlideUpdate();
}
mouseZoomHandler(e) {
const {
zoom,
userIsPresenter,
} = this.props;
if (!userIsPresenter) return;
let newZoom = zoom;
if (e.deltaY < 0) {
newZoom += STEP;
}
if (e.deltaY > 0) {
newZoom -= STEP;
}
if (newZoom <= HUNDRED_PERCENT) {
newZoom = HUNDRED_PERCENT;
} else if (newZoom >= MAX_PERCENT) {
newZoom = MAX_PERCENT;
}
if (newZoom === zoom) return;
const svgPosition = this.getTransformedSvgPoint(e.clientX, e.clientY);
this.doZoomCall(newZoom, svgPosition.x, svgPosition.y);
}
pinchStartHandler(event) {
if (!this.pinchGesture) return;
this.prevDiff = PresentationOverlay.calculateDistance(event.touches);
}
pinchMoveHandler(event) {
const {
zoom,
} = this.props;
if (!this.pinchGesture) return;
if (event.touches.length < 2) return;
const touchCenterPoint = PresentationOverlay.touchCenterPoint(event.touches);
const currDiff = PresentationOverlay.calculateDistance(event.touches);
if (currDiff > 0) {
let newZoom = zoom + (currDiff - this.prevDiff);
if (newZoom <= HUNDRED_PERCENT) {
newZoom = HUNDRED_PERCENT;
} else if (newZoom >= MAX_PERCENT) {
newZoom = MAX_PERCENT;
}
const svgPosition = this.getTransformedSvgPoint(touchCenterPoint.x, touchCenterPoint.y);
this.doZoomCall(newZoom, svgPosition.x, svgPosition.y);
}
this.prevDiff = currDiff;
}
panStartHandler(event) {
if (this.pinchGesture) return;
const touchCenterPoint = PresentationOverlay.touchCenterPoint(event.touches);
this.currentMouseX = touchCenterPoint.x;
this.currentMouseY = touchCenterPoint.y;
}
panMoveHandler(event) {
const {
slideHeight,
physicalSlideHeight,
} = this.props;
if (this.pinchGesture) return;
const touchCenterPoint = PresentationOverlay.touchCenterPoint(event.touches);
const physicalRatio = slideHeight / physicalSlideHeight;
const mouseDeltaX = physicalRatio * (this.currentMouseX - touchCenterPoint.x);
const mouseDeltaY = physicalRatio * (this.currentMouseY - touchCenterPoint.y);
this.currentMouseX = touchCenterPoint.x;
this.currentMouseY = touchCenterPoint.y;
this.panSlide(mouseDeltaX, mouseDeltaY);
}
tapHandler(event) {
const { annotationTool } = this.props;
if (event.touches.length === 2) return;
if (!this.tapedTwice) {
this.tapedTwice = true;
setTimeout(() => (this.tapedTwice = false), 300);
return;
}
event.preventDefault();
const sizeDefault = this.prevZoom === HUNDRED_PERCENT;
if (sizeDefault && annotationTool === HAND_TOOL) {
const touchCenterPoint = PresentationOverlay.touchCenterPoint(event.touches);
this.currentMouseX = touchCenterPoint.x;
this.currentMouseY = touchCenterPoint.y;
this.doZoomCall(200, touchCenterPoint.x, touchCenterPoint.y);
return;
}
this.doZoomCall(HUNDRED_PERCENT, 0, 0);
}
handleTouchStart(event) {
const {
annotationTool,
} = this.props;
if (annotationTool !== HAND_TOOL) return;
// to prevent default behavior (scrolling) on devices (in Safari), when you draw a text box
window.addEventListener('touchend', this.handleTouchEnd, { passive: false });
window.addEventListener('touchmove', this.handleTouchMove, { passive: false });
window.addEventListener('touchcancel', this.handleTouchCancel, true);
this.touchStarted = true;
const numberTouches = event.touches.length;
if (numberTouches === 2) {
this.pinchGesture = true;
this.pinchStartHandler(event);
} else if (numberTouches === 1) {
this.pinchGesture = false;
this.panStartHandler(event);
}
// / TODO Figure out what to do with this later
this.tapHandler(event);
}
handleTouchMove(event) {
const {
annotationTool,
userIsPresenter,
} = this.props;
if (annotationTool !== HAND_TOOL || !userIsPresenter) return;
event.preventDefault();
if (this.pinchGesture) {
this.pinchMoveHandler(event);
} else {
this.panMoveHandler(event);
}
}
handleTouchEnd(event) {
event.preventDefault();
// resetting the touchStarted flag
this.touchStarted = false;
window.removeEventListener('touchend', this.handleTouchEnd, { passive: false });
window.removeEventListener('touchmove', this.handleTouchMove, { passive: false });
window.removeEventListener('touchcancel', this.handleTouchCancel, true);
}
handleTouchCancel(event) {
event.preventDefault();
window.removeEventListener('touchend', this.handleTouchEnd, { passive: false });
window.removeEventListener('touchmove', this.handleTouchMove, { passive: false });
window.removeEventListener('touchcancel', this.handleTouchCancel, true);
}
mouseDownHandler(event) {
const {
annotationTool,
userIsPresenter,
} = this.props;
if (annotationTool !== HAND_TOOL || !userIsPresenter) return;
const isLeftClick = event.button === 0;
if (isLeftClick) {
this.currentMouseX = event.clientX;
this.currentMouseY = event.clientY;
this.setState({
pressed: true,
});
window.addEventListener('mousemove', this.mouseMoveHandler, { passive: false });
window.addEventListener('mouseup', this.mouseUpHandler, { passive: false });
}
}
mouseMoveHandler(event) {
const {
slideHeight,
annotationTool,
physicalSlideHeight,
} = this.props;
const {
pressed,
} = this.state;
if (annotationTool !== HAND_TOOL) return;
if (pressed) {
const mouseDeltaX = slideHeight / physicalSlideHeight * (this.currentMouseX - event.clientX);
const mouseDeltaY = slideHeight / physicalSlideHeight * (this.currentMouseY - event.clientY);
this.currentMouseX = event.clientX;
this.currentMouseY = event.clientY;
this.panSlide(mouseDeltaX, mouseDeltaY);
}
}
mouseUpHandler(event) {
const {
pressed,
} = this.state;
const isLeftClick = event.button === 0;
if (isLeftClick && pressed) {
this.setState({
pressed: false,
});
window.removeEventListener('mousemove', this.mouseMoveHandler);
window.removeEventListener('mouseup', this.mouseUpHandler);
}
}
render() {
const {
viewBoxX,
viewBoxY,
viewBoxWidth,
viewBoxHeight,
slideWidth,
slideHeight,
children,
userIsPresenter,
} = this.props;
const {
pressed,
} = this.state;
this.viewBoxW = viewBoxWidth;
this.viewBoxH = viewBoxHeight;
this.viewBoxX = viewBoxX;
this.viewBoxY = viewBoxY;
const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename + Meteor.settings.public.app.instanceId;
let cursor;
if (!userIsPresenter) {
cursor = undefined;
} else if (pressed) {
cursor = `url('${baseName}/resources/images/whiteboard-cursor/pan-closed.png') 4 8 , default`;
} else {
cursor = `url('${baseName}/resources/images/whiteboard-cursor/pan.png') 4 8, default`;
}
const overlayStyle = {
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: MAX_Z_INDEX,
cursor,
};
return (
<foreignObject
clipPath="url(#viewBox)"
x="0"
y="0"
width={slideWidth}
height={slideHeight}
style={{ zIndex: MAX_Z_INDEX }}
>
<div
role="presentation"
onTouchStart={this.handleTouchStart}
onMouseDown={this.mouseDownHandler}
onWheel={this.mouseZoomHandler}
onBlur={() => {}}
style={overlayStyle}
>
{children}
</div>
</foreignObject>
);
}
}
PresentationOverlay.propTypes = {
// Defines a function which returns a reference to the main svg object
getSvgRef: PropTypes.func.isRequired,
// Defines the current zoom level (100 -> 400)
zoom: PropTypes.number.isRequired,
// Defines the width of the parent SVG. Used with svgHeight for aspect ratio
svgWidth: PropTypes.number.isRequired,
// Defines the height of the parent SVG. Used with svgWidth for aspect ratio
svgHeight: PropTypes.number.isRequired,
// Defines the calculated slide width (in svg coordinate system)
slideWidth: PropTypes.number.isRequired,
// Defines the calculated slide height (in svg coordinate system)
slideHeight: PropTypes.number.isRequired,
// Defines the local X value for the viewbox. Needed for pan/zoom
viewBoxX: PropTypes.number.isRequired,
// Defines the local Y value for the viewbox. Needed for pan/zoom
viewBoxY: PropTypes.number.isRequired,
// Defines the local width of the view box
viewBoxWidth: PropTypes.number.isRequired,
// Defines the local height of the view box
viewBoxHeight: PropTypes.number.isRequired,
// Defines the height of the slide in page coordinates for mouse movement
physicalSlideHeight: PropTypes.number.isRequired,
// Defines whether the local user has rights to change the slide position/dimensions
userIsPresenter: PropTypes.bool.isRequired,
// Defines whether the presentation area is in fitToWidth mode or not
fitToWidth: PropTypes.bool.isRequired,
// Defines the slide data. There's more in there, but we don't need it here
slide: PropTypes.shape({
// Defines the slide id. Used to tell if we changed slides
id: PropTypes.string.isRequired,
}).isRequired,
// Defines a function to send the new viewbox position and size for presenter rendering
updateLocalPosition: PropTypes.func.isRequired,
// Defines a function to send the new percent based position and size to other users
panAndZoomChanger: PropTypes.func.isRequired,
// Defines the currently selected annotation tool. When "hand" we can pan
annotationTool: PropTypes.string.isRequired,
// As a child we expect only a WhiteboardOverlay at this point
children: PropTypes.element.isRequired,
};