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