1 /**
  2  * Copyright (C) 2014 KO GmbH <copyright@kogmbh.com>
  3  *
  4  * @licstart
  5  * This file is part of WebODF.
  6  *
  7  * WebODF is free software: you can redistribute it and/or modify it
  8  * under the terms of the GNU Affero General Public License (GNU AGPL)
  9  * as published by the Free Software Foundation, either version 3 of
 10  * the License, or (at your option) any later version.
 11  *
 12  * WebODF is distributed in the hope that it will be useful, but
 13  * WITHOUT ANY WARRANTY; without even the implied warranty of
 14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 15  * GNU Affero General Public License for more details.
 16  *
 17  * You should have received a copy of the GNU Affero General Public License
 18  * along with WebODF.  If not, see <http://www.gnu.org/licenses/>.
 19  * @licend
 20  *
 21  * @source: http://www.webodf.org/
 22  * @source: https://github.com/kogmbh/WebODF/
 23  */
 24 
 25 /*global runtime, core, gui*/
 26 
 27 (function () {
 28     "use strict";
 29 
 30     /**
 31      * @constructor
 32      * @param {!number} x
 33      * @param {!number} y
 34      */
 35     function Point(x, y) {
 36         var self = this;
 37         /**
 38          * @param {!Point} point
 39          * @return {!number}
 40          */
 41         this.getDistance = function (point) {
 42             var xOffset = self.x - point.x,
 43                 yOffset = self.y - point.y;
 44             return Math.sqrt(xOffset * xOffset + yOffset * yOffset);
 45         };
 46 
 47         /**
 48          * @param {!Point} point
 49          * @return {!Point}
 50          */
 51         this.getCenter = function (point) {
 52             return new Point((self.x + point.x) / 2, (self.y + point.y) / 2);
 53         };
 54 
 55         /**@type{!number}*/
 56         this.x;
 57         /**@type{!number}*/
 58         this.y;
 59         function init() {
 60             self.x = x;
 61             self.y = y;
 62         }
 63         init();
 64     }
 65 
 66     /**
 67      * ZoomHelper handles touch gestures and provides pinch-to-zoom support
 68      * on the sizer element. It also provides some methods to set, get, and
 69      * subscribe to the current zoom level.
 70      * @constructor
 71      * @implements {core.Destroyable}
 72      */
 73     gui.ZoomHelper = function () {
 74         var /**@type{!HTMLElement}*/
 75             zoomableElement,
 76             /**@type{!Point}*/
 77             panPoint,
 78             /**@type{!Point}*/
 79             previousPanPoint,
 80             /**@type{!number}*/
 81             firstPinchDistance,
 82             /**@type{!number}*/
 83             zoom,
 84             /**@type{!number}*/
 85             previousZoom,
 86             maxZoom = 4.0,
 87             /**@type{!HTMLElement}*/
 88             offsetParent,
 89             /**@type{!HTMLElement}*/
 90             parentElement,
 91             events = new core.EventNotifier([gui.ZoomHelper.signalZoomChanged]),
 92             /**@const*/
 93             gestures = {
 94                 NONE: 0,
 95                 SCROLL: 1,
 96                 PINCH: 2
 97             },
 98             /**@type{!number}*/
 99             currentGesture = gestures.NONE,
100             /**
101              * On webkit, which has the ability to style scrollbars
102              * with CSS, `window` has the property 'ontouchstart'.
103              * This can be used as a hint of touch event support,
104              * and we can take advantage of that to decide to show
105              * custom scrollbars (because webkit hides them).
106              * @type{!boolean}
107              */
108             requiresCustomScrollBars = runtime.getWindow().hasOwnProperty('ontouchstart'),
109             /**@type{?string}*/
110             parentOverflow = "";
111 
112         /**
113          * Apply a 3D or 2D CSS transform with the given
114          * x and y offset, and scale.
115          * @param {!number} x
116          * @param {!number} y
117          * @param {!number} scale
118          * @param {!boolean} is3D
119          * @return {undefined}
120          */
121         function applyCSSTransform(x, y, scale, is3D) {
122             var transformCommand;
123 
124             if (is3D) {
125                 transformCommand = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale3d(' + scale + ', ' + scale + ', 1)';
126             } else {
127                 transformCommand = 'translate(' + x + 'px, ' + y + 'px) scale(' + scale + ')';
128             }
129 
130             zoomableElement.style.WebkitTransform = transformCommand;
131             zoomableElement.style.MozTransform = transformCommand;
132             zoomableElement.style.msTransform = transformCommand;
133             zoomableElement.style.OTransform = transformCommand;
134             zoomableElement.style.transform = transformCommand;
135         }
136 
137         /**
138          * Apply the current computed transform
139          * (including pan and zoom)
140          * @param {!boolean} is3D
141          * @return {undefined}
142          */
143         function applyTransform(is3D) {
144             if (is3D) {
145                 applyCSSTransform(-panPoint.x, -panPoint.y, zoom, true);
146             } else {
147                 // It tends to feel smoother (with less flicker)
148                 // if we apply a fast transform first and then a
149                 // detailed transform.
150                 // A detailed (2d) transform is only done 'at rest',
151                 // therefore ignore the panning value, because
152                 // the positioning is handled by scrollbars in that case.
153                 applyCSSTransform(0, 0, zoom, true);
154                 applyCSSTransform(0, 0, zoom, false);
155             }
156         }
157 
158         /**
159          * Applies the current computed zoom & pan
160          * as a quick-and-dirty rendering, to be used
161          * during a gesture or when a flicker needs
162          * to be masked.
163          * @return {undefined}
164          */
165         function applyFastTransform() {
166             applyTransform(true);
167         }
168 
169         /**
170          * Applies the current zoom as a detailed
171          * rendering. This is a slow call to be done
172          * post-gesture.
173          * @return {undefined}
174          */
175         function applyDetailedTransform() {
176             applyTransform(false);
177         }
178 
179         /**
180          * Enable or disable virtual scrollbars on the container.
181          * @param {!boolean} enable
182          * @return {undefined}
183          */
184         function enableScrollBars(enable) {
185             if (!offsetParent || !requiresCustomScrollBars) {
186                 return;
187             }
188 
189             var initialOverflow = offsetParent.style.overflow,
190                 enabled = offsetParent.classList.contains('webodf-customScrollbars');
191 
192             if ((enable && enabled) || (!enable && !enabled)) {
193                 return;
194             }
195 
196             if (enable) {
197                 offsetParent.classList.add('webodf-customScrollbars');
198                 // The custom scrollbar does not appear in webkit unless a full redraw
199                 // of the scrollable area is forced. Therefore attempt to toggle the
200                 // overflow stle of the scrolling container across successive animation
201                 // frames.
202                 offsetParent.style.overflow = 'hidden';
203                 runtime.requestAnimationFrame(function () {
204                     offsetParent.style.overflow = initialOverflow;
205                 });
206             } else {
207                 offsetParent.classList.remove('webodf-customScrollbars');
208             }
209         }
210 
211 
212         /**
213          * Sets the scrolling of the container to (0,0)
214          * so that transforms and event points can be
215          * conveniently computed.
216          * Applies a quick transform to make it look like
217          * this never happened.
218          * @return {undefined}
219          */
220         function removeScroll() {
221             applyCSSTransform(-panPoint.x, -panPoint.y, zoom, true);
222             offsetParent.scrollLeft = 0;
223             offsetParent.scrollTop = 0;
224             parentOverflow = parentElement.style.overflow;
225             parentElement.style.overflow = "visible";
226             enableScrollBars(false);
227         }
228 
229         /**
230          * Restores the scrollTop and scrollLeft of
231          * the container to the x and y pan values.
232          * Applies a quick transform to make it look like
233          * this never happened.
234          * @return {undefined}
235          */
236         function restoreScroll() {
237             applyCSSTransform(0, 0, zoom, true);
238             offsetParent.scrollLeft = panPoint.x;
239             offsetParent.scrollTop = panPoint.y;
240             parentElement.style.overflow = parentOverflow || "";
241             enableScrollBars(true);
242         }
243 
244         /**
245          * Returns a Point instance for a given touch.
246          * @param {!Touch} touch
247          * @return {!Point}
248          */
249         function getPoint(touch) {
250             return new Point(touch.pageX - zoomableElement.offsetLeft, touch.pageY - zoomableElement.offsetTop);
251         }
252 
253         /**
254          * Returns the closest point to the given point
255          * within the boundaries of the zoomable element,
256          * such that it never causes panning outside
257          * the viewport.
258          * @param {!Point} point
259          * @return {!Point}
260          */
261         function sanitizePointForPan(point) {
262             return new Point(
263                 Math.min(Math.max(point.x, zoomableElement.offsetLeft), (zoomableElement.offsetLeft + zoomableElement.offsetWidth) * zoom - offsetParent.clientWidth),
264                 Math.min(Math.max(point.y, zoomableElement.offsetTop), (zoomableElement.offsetTop + zoomableElement.offsetHeight) * zoom - offsetParent.clientHeight)
265             );
266         }
267 
268         /**
269          * Takes a point in page coordinates and pans towards it
270          * @param {!Point} point
271          * @return {undefined}
272          */
273         function processPan(point) {
274             if (previousPanPoint) {
275                 panPoint.x -= point.x - previousPanPoint.x;
276                 panPoint.y -= point.y - previousPanPoint.y;
277                 panPoint = sanitizePointForPan(panPoint);
278             }
279             previousPanPoint = point;
280         }
281 
282         /**
283          * Takes a point and a relative zoom factor,
284          * with which the panPoint is accordingly updated
285          * to reflect the new zoom center, and the current
286          * zoom level is multiplied by the relative factor.
287          * Useful for when the zoom is dynamically being changed
288          * during a gesture.
289          * This does not zoom beyond a minimum reasonable zoom
290          * level. Since we assume that gestures are for a
291          * mobile device, it makes some sense to not allow
292          * shrinking of a document to a width less than the container's
293          * width. Also easier for computation of pan coordinates.
294          * @param {!Point} zoomPoint
295          * @param {!number} incrementalZoom
296          * @return {undefined}
297          */
298         function processZoom(zoomPoint, incrementalZoom) {
299             var originalZoom = zoom,
300                 actuallyIncrementedZoom,
301                 minZoom = Math.min(maxZoom, zoomableElement.offsetParent.clientWidth / zoomableElement.offsetWidth);
302 
303             zoom = previousZoom * incrementalZoom;
304             zoom = Math.min(Math.max(zoom, minZoom), maxZoom);
305             actuallyIncrementedZoom = zoom / originalZoom;
306 
307             panPoint.x += (actuallyIncrementedZoom - 1) * (zoomPoint.x + panPoint.x);
308             panPoint.y += (actuallyIncrementedZoom - 1) * (zoomPoint.y + panPoint.y);
309         }
310 
311         /**
312          * @param {!Point} point1
313          * @param {!Point} point2
314          * @return {undefined}
315          */
316         function processPinch(point1, point2) {
317             var zoomPoint = point1.getCenter(point2),
318                 pinchDistance = point1.getDistance(point2),
319                 incrementalZoom = pinchDistance / firstPinchDistance;
320 
321             processPan(zoomPoint);
322             processZoom(zoomPoint, incrementalZoom);
323         }
324 
325         /**
326          * @param {!TouchEvent} event
327          * @return {undefined}
328          */
329         function prepareGesture(event) {
330             var fingers = event.touches.length,
331                 point1 = fingers > 0 ? getPoint(event.touches[0]) : null,
332                 point2 = fingers > 1 ? getPoint(event.touches[1]) : null;
333 
334             if (point1 && point2) {
335                 // Compute the first pinch distance for later comparison against
336                 // fresh pinch distances during gesture processing, the ratio
337                 // of which represents the relative-to-current zoom level.
338                 firstPinchDistance = point1.getDistance(point2);
339                 previousZoom = zoom;
340                 previousPanPoint = point1.getCenter(point2);
341                 // Assuming this is the start of a pinch gesture,
342                 // therefore scroll to (0,0) for easy computing.
343                 removeScroll();
344                 currentGesture = gestures.PINCH;
345             } else if (point1) {
346                 previousPanPoint = point1;
347                 currentGesture = gestures.SCROLL;
348             }
349         }
350 
351         /**
352          * @param {!TouchEvent} event
353          * @return {undefined}
354          */
355         function processGesture(event) {
356             var fingers = event.touches.length,
357                 point1 = fingers > 0 ? getPoint(event.touches[0]) : null,
358                 point2 = fingers > 1 ? getPoint(event.touches[1]) : null;
359 
360             if (point1 && point2) {
361                 // Prevent default behavior of panning when a pinch is detected
362                 event.preventDefault();
363                 // If the current gesture is a SCROLL (or pan),
364                 // switch that to PINCH and scroll to (0,0)
365                 // for easy computing of transforms
366                 if (currentGesture === gestures.SCROLL) {
367                     currentGesture = gestures.PINCH;
368                     removeScroll();
369                     firstPinchDistance = point1.getDistance(point2);
370                     // Do no more pinch processing for this
371                     // event now that we scrolled, because
372                     // we still have the old coordinates.
373                     // It is fine to waste a couple of events
374                     // in a gesture.
375                     return;
376                 }
377                 processPinch(point1, point2);
378                 applyFastTransform();
379             } else if (point1) {
380                 // If there is a switch from pinch to
381                 // scroll mode, restore the scroll position
382                 // to the current pan coordinates.
383                 if (currentGesture === gestures.PINCH) {
384                     currentGesture = gestures.SCROLL;
385                     restoreScroll();
386                     // Do no more pan processing for this event because
387                     // the scrolling changed the coordinates.
388                     return;
389                 }
390                 // Even when we are doing native scrolling/panning,
391                 // keep track and process the pan (but do not apply
392                 // a transform), so that when there is a switch to
393                 // pinch mode, the new pan coordinates are taken into
394                 // account.
395                 processPan(point1);
396             }
397 
398         }
399 
400 
401         /**
402          * Restores scroll to the current pan position
403          * after the gesture is over.
404          * @return {undefined}
405          */
406         function sanitizeGesture() {
407             if (currentGesture === gestures.PINCH) {
408                 // Here, signalZoomChanged is emitted before restoring the
409                 // scroll, because otherwise scrolling and then changing the
410                 // scroll area's dimensions will cause the view to end up
411                 // in unexpected places. Scrolling later will ensure that
412                 // the scrolled view is set by us and not the browser.
413                 events.emit(gui.ZoomHelper.signalZoomChanged, zoom);
414                 restoreScroll();
415                 applyDetailedTransform();
416             }
417             currentGesture = gestures.NONE;
418         }
419 
420         /**
421          * @param {!string} eventid
422          * @param {!Function} cb
423          * @return {undefined}
424          */
425         this.subscribe = function (eventid, cb) {
426             events.subscribe(eventid, cb);
427         };
428 
429         /**
430          * @param {!string} eventid
431          * @param {!Function} cb
432          * @return {undefined}
433          */
434         this.unsubscribe = function (eventid, cb) {
435             events.unsubscribe(eventid, cb);
436         };
437 
438         /**
439          * @return {!number}
440          */
441         this.getZoomLevel = function () {
442             return zoom;
443         };
444 
445         /**
446          * @param {!number} zoomLevel
447          * @return {undefined}
448          */
449         this.setZoomLevel = function (zoomLevel) {
450             if (zoomableElement) {
451                 zoom = zoomLevel;
452                 applyDetailedTransform();
453                 events.emit(gui.ZoomHelper.signalZoomChanged, zoom);
454             }
455         };
456 
457         /**
458          * Adds touchstart, touchmove, and touchend
459          * event listeners to the element's scrollable
460          * container.
461          * @return {undefined}
462          */
463         function registerGestureListeners() {
464             if (offsetParent) {
465                 // There is no reliable way of detecting if the browser
466                 // supports these touch events. Therefore the only thing
467                 // we can do is simply attach listeners to these events
468                 // as this seems harmless if the events are not supported
469                 // anyway.
470                 offsetParent.addEventListener('touchstart', /**@type{!EventListener}*/(prepareGesture), false);
471                 offsetParent.addEventListener('touchmove', /**@type{!EventListener}*/(processGesture), false);
472                 offsetParent.addEventListener('touchend', /**@type{!EventListener}*/(sanitizeGesture), false);
473             }
474         }
475 
476         /**
477          * @return {undefined}
478          */
479         function unregisterGestureListeners() {
480             if (offsetParent) {
481                 offsetParent.removeEventListener('touchstart', /**@type{!EventListener}*/(prepareGesture), false);
482                 offsetParent.removeEventListener('touchmove', /**@type{!EventListener}*/(processGesture), false);
483                 offsetParent.removeEventListener('touchend', /**@type{!EventListener}*/(sanitizeGesture), false);
484             }
485         }
486 
487         /**
488          * @param {!function(!Error=)} callback, passing an error object in case of error
489          * @return {undefined}
490          */
491         this.destroy = function (callback) {
492             unregisterGestureListeners();
493             enableScrollBars(false);
494             callback();
495         };
496 
497         /**
498          * FIXME: I don't like that we can switch the zoomable
499          * element at runtime, but I don't see any other way to
500          * keep the zoom helper working after an undo.
501          * @param {!HTMLElement} element
502          * @return {undefined}
503          */
504         this.setZoomableElement = function (element) {
505             unregisterGestureListeners();
506             zoomableElement = element;
507             offsetParent = /**@type{!HTMLElement}*/(zoomableElement.offsetParent);
508             parentElement = /**@type{!HTMLElement}*/(zoomableElement.parentNode);
509             // Write out the current transform to the new element.
510             applyDetailedTransform();
511             registerGestureListeners();
512             enableScrollBars(true);
513         };
514 
515         function init() {
516             zoom = 1;
517             previousZoom = 1;
518             panPoint = new Point(0, 0);
519         }
520         init();
521     };
522     /**@const
523      * @type {!string} */
524     gui.ZoomHelper.signalZoomChanged = "zoomChanged";
525 }());
526