import React, { Component } from 'react'; import PropTypes from 'prop-types'; import SlideCalcUtil, { HUNDRED_PERCENT, MAX_PERCENT, MYSTERY_NUM, STEP, } from '/imports/utils/slideCalcUtils'; import WhiteboardToolbarService from '../../whiteboard/whiteboard-toolbar/service'; // 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) const CURSOR_INTERVAL = 16; export default class PresentationOverlay extends Component { constructor(props) { super(props); // last sent coordinates this.lastSentClientX = 0; this.lastSentClientY = 0; // last updated coordinates this.currentClientX = 0; this.currentClientY = 0; // id of the setInterval() this.intervalId = 0; this.state = { zoom: props.zoom, }; // 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 = this.handleTouchMove.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); this.handleTouchCancel = this.handleTouchCancel.bind(this); this.mouseMoveHandler = this.mouseMoveHandler.bind(this); this.checkCursor = this.checkCursor.bind(this); this.mouseEnterHandler = this.mouseEnterHandler.bind(this); this.mouseOutHandler = this.mouseOutHandler.bind(this); this.getTransformedSvgPoint = this.getTransformedSvgPoint.bind(this); this.svgCoordinateToPercentages = this.svgCoordinateToPercentages.bind(this); this.mouseZoomHandler = this.mouseZoomHandler.bind(this); this.zoomCalculation = this.zoomCalculation.bind(this); this.doZoomCall = this.doZoomCall.bind(this); this.tapHandler = this.tapHandler.bind(this); this.zoomCall = this.zoomCall.bind(this); this.panZoom = this.panZoom.bind(this); this.pinchZoom = this.pinchZoom.bind(this); this.toolbarZoom = this.toolbarZoom.bind(this); this.isPortraitDoc = this.isPortraitDoc.bind(this); this.doWidthBoundsDetection = this.doWidthBoundsDetection.bind(this); this.doHeightBoundsDetection = this.doHeightBoundsDetection.bind(this); this.calcViewedRegion = this.calcViewedRegion.bind(this); this.onZoom = this.onZoom.bind(this); const { viewBoxWidth, viewBoxHeight, slideWidth, slideHeight, slide, presentationSize, } = props; this.viewportW = slideWidth; this.viewportH = slideHeight; this.viewedRegionX = slide.xOffset; this.viewedRegionY = slide.yOffset; this.viewedRegionW = (viewBoxWidth / slideWidth) * 100; this.viewedRegionH = (viewBoxHeight / slideHeight) * 100; this.pageOrigW = slideWidth; this.pageOrigH = slideHeight; this.parentW = presentationSize.presentationWidth; this.parentH = presentationSize.presentationHeight; this.calcPageW = this.pageOrigW / (this.viewedRegionW / HUNDRED_PERCENT); this.calcPageH = this.pageOrigH / (this.viewedRegionH / HUNDRED_PERCENT); this.calcPageX = (this.viewedRegionX / HUNDRED_PERCENT) * this.calcPageW; this.calcPageY = (this.viewedRegionY / HUNDRED_PERCENT) * this.calcPageH; this.tapedTwice = false; this.touches = []; } componentDidMount() { const { viewBoxWidth, slideWidth, zoomChanger, } = this.props; const realZoom = (viewBoxWidth / slideWidth) * 100; const zoomPercentage = (Math.round((100 / realZoom) * 100)); const roundedUpToFive = Math.round(zoomPercentage / 5) * 5; zoomChanger(roundedUpToFive); this.doZoomCall(HUNDRED_PERCENT, 0, 0); } componentDidUpdate(prevProps) { const { zoom, delta, touchZoom, presentationSize, slideHeight, slideWidth, fitToWidth, } = this.props; const isDifferent = zoom !== this.state.zoom && !touchZoom; const moveSLide = ((delta.x !== prevProps.delta.x) || (delta.y !== prevProps.delta.y)) && !isDifferent; const isTouchZoom = zoom !== this.state.zoom && touchZoom; if (moveSLide) { this.panZoom(); } if (isTouchZoom) { this.pinchZoom(); } if (isDifferent) { this.toolbarZoom(); } if (fitToWidth) { if (!prevProps.fitToWidth || this.checkResize(prevProps.presentationSize)) { this.parentH = presentationSize.presentationHeight; this.parentW = presentationSize.presentationWidth; this.viewportH = this.parentH; this.viewportW = this.parentW; this.doZoomCall(HUNDRED_PERCENT, 0, 0); } } else if (prevProps.fitToWidth) { this.viewportH = slideHeight; this.viewportW = slideWidth; this.doZoomCall(HUNDRED_PERCENT, 0, 0); } } onZoom(zoomValue, mouseX, mouseY) { let absXcoordInPage = (Math.abs(this.calcPageX) * MYSTERY_NUM) + mouseX; let absYcoordInPage = (Math.abs(this.calcPageY) * MYSTERY_NUM) + mouseY; const relXcoordInPage = absXcoordInPage / this.calcPageW; const relYcoordInPage = absYcoordInPage / this.calcPageH; if (this.isPortraitDoc()) { if (this.props.fitToWidth) { this.calcPageW = (this.viewportW * zoomValue) / HUNDRED_PERCENT; this.calcPageH = (this.calcPageW / this.pageOrigW) * this.pageOrigH; } else { this.calcPageH = (this.viewportH * zoomValue) / HUNDRED_PERCENT; this.calcPageW = (this.pageOrigW / this.pageOrigH) * this.calcPageH; } } else if (this.props.fitToWidth) { this.calcPageW = (this.viewportW * zoomValue) / HUNDRED_PERCENT; this.calcPageH = (this.calcPageW / this.pageOrigW) * this.pageOrigH; } else { this.calcPageW = (this.viewportW * zoomValue) / HUNDRED_PERCENT; this.calcPageH = (this.viewportH * zoomValue) / HUNDRED_PERCENT; } absXcoordInPage = relXcoordInPage * this.calcPageW; absYcoordInPage = relYcoordInPage * this.calcPageH; this.calcPageX = -((absXcoordInPage - mouseX) / MYSTERY_NUM); this.calcPageY = -((absYcoordInPage - mouseY) / MYSTERY_NUM); this.doWidthBoundsDetection(); this.doHeightBoundsDetection(); this.calcViewedRegion(); } getTransformedSvgPoint(clientX, clientY) { const svgObject = this.props.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()); } checkResize(prevPresentationSize) { const { presentationSize } = this.props; const heightChanged = prevPresentationSize.presentationHeight !== presentationSize.presentationHeight; const widthChanged = prevPresentationSize.presentationWidth !== presentationSize.presentationWidth; return heightChanged || widthChanged; } panZoom() { const { delta, } = this.props; this.deltaX = delta.x; this.deltaY = delta.y; this.calcPageX += delta.x * -1; this.calcPageY += delta.y * -1; this.doHeightBoundsDetection(); this.doWidthBoundsDetection(); this.calcViewedRegion(); this.zoomCall( this.state.zoom, this.viewedRegionW, this.viewedRegionH, this.viewedRegionX, this.viewedRegionY, ); } pinchZoom() { const { zoom, } = this.props; const posX = this.touches[0].clientX; const posY = this.touches[0].clientY; this.doZoomCall(zoom, posX, posY); } toolbarZoom() { const { getSvgRef, zoom, } = this.props; const svgRect = getSvgRef().getBoundingClientRect(); const svgCenterX = svgRect.left + (svgRect.width / 2); const svgCenterY = svgRect.top + (svgRect.height / 2); this.doZoomCall(zoom, svgCenterX, svgCenterY); } isPortraitDoc() { return this.pageOrigH > this.pageOrigW; } doWidthBoundsDetection() { const verifyPositionToBound = (this.calcPageW + (this.calcPageX * MYSTERY_NUM)); if (this.calcPageX >= 0) { this.calcPageX = 0; } else if (verifyPositionToBound < this.viewportW) { this.calcPageX = (this.viewportW - this.calcPageW) / MYSTERY_NUM; } } doHeightBoundsDetection() { const verifyPositionToBound = (this.calcPageH + (this.calcPageY * MYSTERY_NUM)); if (this.calcPageY >= 0) { this.calcPageY = 0; } else if (verifyPositionToBound < this.viewportH) { this.calcPageY = (this.viewportH - this.calcPageH) / MYSTERY_NUM; } } calcViewedRegion() { this.viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(this.viewportW, this.calcPageW); this.viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(this.viewportH, this.calcPageH); this.viewedRegionX = SlideCalcUtil.calcViewedRegionX(this.calcPageX, this.calcPageW); this.viewedRegionY = SlideCalcUtil.calcViewedRegionY(this.calcPageY, this.calcPageH); } checkCursor() { // check if the cursor hasn't moved since last check if (this.lastSentClientX !== this.currentClientX || this.lastSentClientY !== this.currentClientY) { const { currentClientX, currentClientY } = this; // retrieving a transformed coordinate let transformedSvgPoint = this.getTransformedSvgPoint(currentClientX, currentClientY); // determining the cursor's coordinates as percentages from the slide's width/height transformedSvgPoint = this.svgCoordinateToPercentages(transformedSvgPoint); // updating last sent raw coordinates this.lastSentClientX = currentClientX; this.lastSentClientY = currentClientY; // sending the update to the server this.props.updateCursor({ xPercent: transformedSvgPoint.x, yPercent: transformedSvgPoint.y, whiteboardId: this.props.whiteboardId, }); } } // receives an svg coordinate and changes the values to percentages of the slide's width/height svgCoordinateToPercentages(svgPoint) { const point = { x: (svgPoint.x / this.props.slideWidth) * 100, y: (svgPoint.y / this.props.slideHeight) * 100, }; return point; } zoomCalculation(zoom, mouseX, mouseY) { const svgPosition = this.getTransformedSvgPoint(mouseX, mouseY); this.onZoom(zoom, svgPosition.x, svgPosition.y); return { viewedRegionW: this.viewedRegionW, viewedRegionH: this.viewedRegionH, viewedRegionX: this.viewedRegionX, viewedRegionY: this.viewedRegionY, }; } zoomCall(zoom, w, h, x, y) { const { zoomSlide, podId, currentSlideNum, isPresenter, } = this.props; if (!isPresenter) return; zoomSlide(currentSlideNum, podId, w, h, x, y); this.setState({ zoom }); this.props.zoomChanger(zoom); } doZoomCall(zoom, mouseX, mouseY) { const zoomData = this.zoomCalculation(zoom, mouseX, mouseY); const { viewedRegionW, viewedRegionH, viewedRegionX, viewedRegionY, } = zoomData; this.zoomCall(zoom, viewedRegionW, viewedRegionH, viewedRegionX, viewedRegionY); } mouseZoomHandler(e) { const { zoom, whiteboardId, updateCursor, } = this.props; 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; } const mouseX = e.clientX; const mouseY = e.clientY; const svgPosition = this.getTransformedSvgPoint(mouseX, mouseY); const svgPercentage = this.svgCoordinateToPercentages(svgPosition); this.doZoomCall(newZoom, mouseX, mouseY); updateCursor({ xPercent: svgPercentage.x, yPercent: svgPercentage.y, whiteboardId, }); } tapHandler(event) { const AnnotationTool = WhiteboardToolbarService .getCurrentDrawSettings().whiteboardAnnotationTool; if (event.touches.length === 2) return; if (!this.tapedTwice) { this.tapedTwice = true; setTimeout(() => this.tapedTwice = false, 300); return; } event.preventDefault(); const sizeDefault = this.state.zoom === HUNDRED_PERCENT; if (sizeDefault && AnnotationTool === 'hand') { this.doZoomCall(200, this.currentClientX, this.currentClientY); return; } this.doZoomCall(HUNDRED_PERCENT, 0, 0); } handleTouchStart(event) { // 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 { clientX, clientY } = event.changedTouches[0]; this.currentClientX = clientX; this.currentClientY = clientY; if (event.touches.length === 2) { this.touches = [...event.touches]; } const intervalId = setInterval(this.checkCursor, CURSOR_INTERVAL); this.intervalId = intervalId; this.tapHandler(event); } handleTouchMove(event) { event.preventDefault(); const { clientX, clientY } = event.changedTouches[0]; this.currentClientX = clientX; this.currentClientY = clientY; } handleTouchEnd(event) { event.preventDefault(); // touch ended, removing the interval clearInterval(this.intervalId); this.intervalId = 0; // resetting the touchStarted flag this.touchStarted = false; // setting the coords to negative values and send the last message (the cursor will disappear) this.currentClientX = -1; this.currentClientY = -1; this.checkCursor(); window.removeEventListener('touchend', this.handleTouchEnd, { passive: false }); window.removeEventListener('touchmove', this.handleTouchMove, { passive: false }); window.removeEventListener('touchcancel', this.handleTouchCancel, true); } handleTouchCancel(event) { event.preventDefault(); // touch was cancelled, removing the interval clearInterval(this.intervalId); this.intervalId = 0; // resetting the touchStarted flag this.touchStarted = false; // setting the coords to negative values and send the last message (the cursor will disappear) this.currentClientX = -1; this.currentClientY = -1; this.checkCursor(); window.removeEventListener('touchend', this.handleTouchEnd, { passive: false }); window.removeEventListener('touchmove', this.handleTouchMove, { passive: false }); window.removeEventListener('touchcancel', this.handleTouchCancel, true); } mouseMoveHandler(event) { if (this.touchStarted) { return; } // for the case where you change settings in one of the lists (which are displayed on the slide) // the mouse starts pointing to the slide right away and mouseEnter doesn't fire // so we call it manually here if (!this.intervalId) { this.mouseEnterHandler(); } this.currentClientX = event.clientX; this.currentClientY = event.clientY; } mouseEnterHandler() { if (this.touchStarted) { return; } this.checkCursor(); } mouseOutHandler() { // mouse left the whiteboard, removing the interval clearInterval(this.intervalId); this.intervalId = 0; // setting the coords to negative values and send the last message (the cursor will disappear) this.currentClientX = -1; this.currentClientY = -1; this.checkCursor(); } render() { return (
{}} style={{ width: '100%', height: '100%', touchAction: 'none' }} > {this.props.children}
); } } PresentationOverlay.propTypes = { whiteboardId: PropTypes.string.isRequired, // Defines a function which returns a reference to the main svg object getSvgRef: PropTypes.func.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, // A function to send a cursor update updateCursor: PropTypes.func.isRequired, // As a child we expect only a WhiteboardOverlay at this point children: PropTypes.element.isRequired, };