628 lines
17 KiB
JavaScript
Executable File
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,
|
|
};
|