Last active
January 12, 2016 15:07
-
-
Save wenshin/7af05caac1d2b49332db to your computer and use it in GitHub Desktop.
A zoom util for mobile web app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* requesteAnimationFrame 兼容支持 | |
* @reference | |
* http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ | |
* @return {undefined} | |
*/ | |
(function() { | |
var lastTime = 0; | |
var vendors = ['webkit', 'moz']; | |
for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { | |
window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; | |
window.cancelAnimationFrame = | |
window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame']; | |
} | |
if (!window.requestAnimationFrame) { | |
window.requestAnimationFrame = function(callback, element) { | |
var currTime = Date.now(); | |
var timeToCall = Math.max(0, 16 - (currTime - lastTime)); | |
var id = window.setTimeout(function() { callback(currTime + timeToCall); }, | |
timeToCall); | |
lastTime = currTime + timeToCall; | |
return id; | |
}; | |
} | |
if (!window.cancelAnimationFrame) { | |
window.cancelAnimationFrame = function(id) { clearTimeout(id); }; | |
} | |
})(); | |
/** | |
* transition animation | |
* @description 使用 transition 实现动画效果,并且保证动画部分的顺滑,在移动端表现优异 | |
*/ | |
'use strict'; | |
var prefixes = ['', '-webkit-', '-moz-']; | |
var needPrefix = { 'transform': 1, 'transition': 1}; | |
function prefixProp(prop, handleEachProp) { | |
if ( needPrefix[prop] ) { | |
prefixes.forEach(function (prefix) { | |
handleEachProp(prefix + prop); | |
}); | |
} else { | |
handleEachProp(prop); | |
} | |
} | |
function setStyle(elem, prop, value) { | |
prefixProp(prop, function (prefixed) { | |
elem.style.setProperty(prefixed, value); | |
}); | |
} | |
function animate(elem, keyFrames, config, handleFinish) { | |
keyFrames = [].concat(keyFrames); | |
config = config || {}; | |
// NOTE: | |
// 执行完动画后,会修改 config.duration 为 Infinity。 | |
// 暂时不知道什么问题,这里先 Copy config 配置。 | |
var _config = {}; | |
_config.duration = (config.duration || 0.5) / keyFrames.length; | |
_config.timing = config.timing || 'ease-out'; | |
_config.delay = config.delay || 0; | |
if ( _config.delay > 0 ) { | |
setTimeout(function(){ | |
_config.delay = 0; | |
setTransition(elem, _config); | |
_animate(elem, keyFrames, _config, handleFinish); | |
}, _config.delay*1000); | |
return; | |
} | |
setTransition(elem, _config); | |
_animate(elem, keyFrames, _config, handleFinish); | |
} | |
function setTransition (elem, config) { | |
setStyle(elem, 'transition', [ | |
'all' , fmtTime(config.duration), | |
config.timing, fmtTime(config.delay) | |
].join(' ')); | |
if ( !elem.__transitionActive ) { | |
// 由于浏览器会等 JS 运行结束才会使设置的 style 生效, | |
// 这样可以减少重绘的情况。 | |
// 第一次 js 设置 transition 时,由于transition还没有激活,所以用 | |
// elem.getBoundingClientRect() 强制重绘制,立即激活。 | |
elem.getBoundingClientRect(); | |
elem.__transitionActive = true; | |
} | |
} | |
function _animate(elem, keyFrames, config, handleFinish) { | |
if ( !keyFrames.length ) { | |
setStyle(elem, 'transition', ''); | |
handleFinish && handleFinish(); // jshint ignore:line | |
return; | |
} | |
animateToKeyFrame(elem, keyFrames[0], config, function() { | |
_animate(elem, keyFrames.slice(1), config, handleFinish); | |
}); | |
} | |
function animateToKeyFrame(elem, keyFrame, config, handleFrameFinish) { | |
onTransitionend(elem, handleFrameFinish, config.duration); | |
requestAnimationFrame(function () { | |
for ( var style in keyFrame ) { | |
if ( keyFrame.hasOwnProperty(style) ) { | |
setStyle(elem, style, keyFrame[style]); | |
} | |
} | |
}); | |
} | |
function onTransitionend (elem, handle, duration) { | |
var transitionendTriggered = false; | |
var timer = setTimeout(function() { | |
transitionendTriggered || handleTransitionend(); //jshint ignore:line | |
}, | |
duration*1000); | |
function handleTransitionend(e) { | |
clearTimeout(timer); | |
transitionendTriggered = true; | |
offTransitionend(elem, handleTransitionend); | |
handle.call(elem, e); | |
} | |
elem.addEventListener('transitionend', handleTransitionend, false); | |
elem.addEventListener('webkitTransitionend', handleTransitionend, false); | |
} | |
function offTransitionend (elem, handle) { | |
elem.removeEventListener('transitionend', handle); | |
elem.removeEventListener('webkitTransitionend', handle); | |
} | |
function fmtTime(time) { | |
return time ? time + 's' : '0s'; | |
} | |
module.exports = animate; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict'; | |
var animate = require('./tranimation'); | |
/** | |
* 缩放组件。 | |
* 缩放和拖动都是步进的改变,每次步进使用 transition 动画完成,在动画过程中,将不响应触摸事件。 | |
* 这样避免频繁响应带来的性能,代来更顺滑的效果。 | |
* @feature | |
* 1. 只使用 transform 的 translate 和 scale,不会引起页面重排和重绘; | |
* 2. 支持缩略图; | |
* 3. 支持任何类型的元素; | |
* 4. 支持自定义最大和最小缩放比例; | |
* 5. 自动根据父容器水平和垂直居中。 | |
* @desription 响应触摸事件 | |
* event.targetTouches: 触发元素(注意是触发元素),如果是在父级元素上监听事件, | |
* 将不能不能准确判断实际有多少个触点。 | |
* event.changedTouches: touchend 事件时这个属性肯定有值,理论上可以存在多个值, | |
* 但是多点触发 touchend 一般不会同时出现,所以认为只有一个值。 | |
* 其他事件代表当前发生移动的触点,不一定和 targetTouches 相同。 | |
* | |
* @param {DOMElement} elem 缩放的对象 | |
* @param {Object} options 配置信息见 Line:27 | |
* @usage | |
* ``` | |
* let zoom = new Zoom(elem, {firstScale: 3}); | |
* // Bind event listeners | |
* zoom.enable(); | |
* // Unbind touch event listeners | |
* zoom.disable(); | |
* // Recover to init status | |
* zoom.reset(); | |
* ``` | |
*/ | |
function Zoom(elem, options) { | |
this.elem = elem; | |
this.scale = Zoom.INIT_SCALE; | |
this.translate = {x: Zoom.INIT_TRANSLATE_X, y: Zoom.INIT_TRANSLATE_Y}; | |
this.offsetTranslate = {x: Zoom.INIT_TRANSLATE_X, y: Zoom.INIT_TRANSLATE_Y}; | |
this.sourceRect = null; | |
this.parentRect = null; | |
this.centerPosition = null; | |
this.transformOrigin = null; | |
this.scaleActive = false; | |
this.inTransition = false; | |
this.startDragTouch = null; | |
this.cachedDragTouch = null; | |
this.cachedTouchDistance = 0; | |
this.dragTouchmoveCount = 0; | |
options = options || {}; | |
var zoomer = this; | |
// 动画结束时的回调 | |
var animConfig = options.animConfig || {}; | |
zoomer.options = { | |
// 默认在缩放和拖动时均不显示缩略图, | |
// 如果要关闭所有缩略图设置 thumbnail = false, | |
// 如果关闭其中一个使用 thumbnail = { zoom: false, drag: false }。 | |
thumbnail: options.thumbnail || { zoom: false, drag: false }, | |
// 默认根据元素的父元素自动水平和垂直居中,如果设为 false 将不自动处理 | |
autoCenter: options.autoCenter === false ? false : true, | |
// 初始化时,快速缩放到特定的比例 | |
firstScale: options.firstScale, | |
// TODO: 初始化时,快速移动到特定的位置 | |
firstTranslate: options.firstTranslate, | |
// 最小的缩小比例 | |
minScale: options.minScale || 1, | |
// 最大的缩放比例 | |
maxScale: options.maxScale || 10, | |
// 是否禁用动画,默认为 true。设置为 false 则取消动画。这在某些浏览器不支持 | |
// 动画时比较有用 | |
animation: options.animation === false ? false : true, | |
animConfig: { | |
duration: animConfig.duration || 0.2, | |
// transition timing-function | |
timing: animConfig.timing || 'cubic-bezier(.12,.63,.5,.96)', | |
delay: animConfig.delay || 0 | |
}, | |
animFinish: options.animFinish || function(scale, translate) {return [scale, translate];} | |
}; | |
zoomer.init(); | |
} | |
Zoom.INIT_SCALE = 1; | |
Zoom.INIT_TRANSLATE_X = 0; | |
Zoom.INIT_TRANSLATE_Y = 0; | |
Zoom.prototype.init = function () { | |
var srcRect; | |
// transform 不再改变,均通过 translate 来纠正中心点的偏移 | |
setStyle(this.elem, 'display', 'inline-block'); | |
setStyle(this.elem, 'transform-origin', 'center center'); | |
srcRect = this.sourceRect = this.elem.getBoundingClientRect(); | |
var centerX = srcRect.width / 2; | |
var centerY = srcRect.height / 2; | |
// 保留值用于计算时使用 | |
this.transformOrigin = { x: centerX, y: centerY }; | |
var parentElem = this.elem.parentElement; | |
parentElem.style.overflow = 'hidden'; | |
parentElem.style.position = 'relative'; | |
this.parentRect = parentElem.getBoundingClientRect(); | |
if ( this.options.thumbnail.zoom || this.options.thumbnail.drag ) { | |
this.createThumbnail(); | |
} | |
if ( this.options.autoCenter ) { | |
var pRect = this.parentRect; | |
this.offsetTranslate.x = (pRect.width - srcRect.width)/2; | |
this.offsetTranslate.y = (pRect.height - srcRect.height)/2; | |
this.setTransform({translate: this.offsetTranslate, animation: false, limit: false}); | |
} | |
// 为了保持缩放的稳定性,一次缩放会记录第一次的缩放中心点(1倍倍率下的坐标), | |
// 设置 start 为 true 表示缩放开始,结束一次缩放后设置为 false | |
this.centerPosition = { | |
start: false, | |
center: { x: centerX, y: centerY }, | |
offset: { x: 0, y: 0} | |
}; | |
this.enable(); | |
}; | |
Zoom.prototype.demount = function () { | |
setStyle(this.elem, 'display', ''); | |
setStyle(this.elem, 'transform-origin', ''); | |
setStyle(this.elem, 'transform', ''); | |
setStyle(this.elem.parentElement, 'overflow', ''); | |
setStyle(this.elem.parentElement, 'position', ''); | |
}; | |
/** | |
* 创建缩略图。 | |
* 缩略是由一个容器对象(wrapper)、目标对象Clone、一个当前视窗(thumbnail viewport)组成 | |
* @return {undefined} | |
*/ | |
Zoom.prototype.createThumbnail = function () { | |
var wrapper = this.thumbnail = document.createElement('div'); | |
wrapper.style.position = 'absolute'; | |
wrapper.style.backgroundColor = 'rgba(0,0,0,.618)'; | |
wrapper.style.left = wrapper.style.top = 0; | |
// 默认不显示 | |
wrapper.style.opacity = 0; | |
setStyle(wrapper, 'transform', 'scale(0,0)'); | |
setStyle(wrapper, 'transform-origin', '0 0'); | |
// Clone 缩放对象,并且给所有 id 属性添加前缀 | |
wrapper.innerHTML = this.elem.outerHTML.replace(/id="/gi, '$&thumbnail-'); | |
// 阻止链接跳转 | |
wrapper.addEventListener('click', function(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
}, false); | |
setStyle(wrapper.firstChild, 'transform', ''); | |
this.createThumbnailViewport(); | |
this.elem.parentElement.appendChild(wrapper); | |
}; | |
Zoom.prototype.createThumbnailViewport = function () { | |
var viewport = this.viewport = document.createElement('div'); | |
viewport.style.backgroundColor = 'transparent'; | |
viewport.style.position = 'absolute'; | |
viewport.style.top = viewport.style.left = 0; | |
viewport.style.border = '6px solid #f00'; | |
viewport.style.width = viewport.style.height = '100%'; | |
setStyle(viewport, 'box-sizing', 'border-box'); | |
setStyle(viewport, 'transform', | |
'translate('+ this.offsetTranslate.x +'px,'+ this.offsetTranslate.y +'px)'); | |
setStyle(viewport, 'transform-origin', '0 0'); | |
this.thumbnail.appendChild(viewport); | |
}; | |
/** | |
* 更新缩略图视窗的位置 | |
* @return {undefined} | |
*/ | |
Zoom.prototype.refreshThumbnailViewport = function () { | |
// 根据父级元素移动到中心位置时,会执行一次移动, | |
// 这时还没有创建缩略图 | |
if ( !this.viewport ) { return; } | |
var k = 1 / this.scale; | |
var transfScale; | |
var transfTranslate; | |
var pBoundary = this.sourceRect; | |
var eBoundary = this.elem.getBoundingClientRect(); | |
var dx = pBoundary.left - eBoundary.left + this.offsetTranslate.x; | |
var dy = pBoundary.top - eBoundary.top + this.offsetTranslate.y; | |
transfScale = 'scale('+ k +','+ k +')'; | |
// 这里不用缩小,因为本来是一样的,自动等比例缩放 | |
transfTranslate = 'translate('+ dx +'px,'+ dy +'px)'; | |
setStyle(this.viewport, 'transform', transfScale + ' ' + transfTranslate); | |
}; | |
Zoom.prototype.showThumbnail = function (type) { | |
if ( !this.thumbnail ) { return; } | |
if ( !type || this.options.thumbnail[type] ) { | |
this.setThumbnailStyle(1, 'scale(0.3, 0.3)'); | |
} | |
}; | |
Zoom.prototype.hideThumbnail = function () { | |
if ( !this.thumbnail ) { return; } | |
this.setThumbnailStyle(0, 'scale(0, 0)'); | |
}; | |
Zoom.prototype.setThumbnailStyle = function (opacity, transform) { | |
var tn = this.thumbnail; | |
if ( this.options.animation ) { | |
animate(tn, {opacity: opacity}, this.options.animConfig, function() { | |
setStyle(tn, 'transform', transform); | |
}); | |
} else { | |
setStyle(tn, 'opacity', opacity); | |
setStyle(tn, 'transform', transform); | |
} | |
}; | |
Zoom.prototype.enable = function() { | |
if ( this.options.firstScale ) { | |
this.setTransform({scale: this.options.firstScale, animation: true}); | |
} | |
this.bindEvents(); | |
}; | |
Zoom.prototype.disable = function () { | |
this.setTransform({scale: Zoom.INIT_SCALE, animation: true}); | |
this.demount(); | |
this.unbindEvents(); | |
}; | |
Zoom.prototype.reset = function () { | |
this.setTransform({scale: this.options.firstScale || Zoom.INIT_SCALE, animation: true}); | |
}; | |
Zoom.prototype.handleTouchZoom = function(e, touches) { | |
var scaleTo; | |
var distanceSquare = this.calcTouchDistanceSquare(touches); | |
// 这里使用 e.preventDefault 和 e.stopPropagation 要慎重,会影响缩放对象原来的事件 | |
if ( e.type === 'touchstart') { | |
this.scaleActive = true; | |
this.cacheZoomPosition(this.calcCenterPosition(touches)); | |
} | |
if ( e.type === 'touchmove' && e.changedTouches.length ) { | |
this.scaleActive = true; | |
this.preventTouchEvent(e); | |
scaleTo = Math.sqrt(distanceSquare / this.cachedTouchDistance) * this.scale; | |
if ( scaleTo !== this.scale ) { | |
this.showThumbnail('zoom'); | |
this.setTransform({ | |
scale: scaleTo, | |
translate: this.calcScaledTranslate(touches, scaleTo) | |
}); | |
} | |
} | |
// 这里不能移动到前面保存,会影响计算 | |
this.cachedTouchDistance = distanceSquare; | |
}; | |
/** | |
* 判断一个 DOM 节点是否是缩放对象或者其子节点。 | |
* 在响应 touch 时间时,如果触发该事件是缩放对象的子节点, | |
* 那么 TouchEvent.target 将是子节点,并且 TouchEvent.targetTouches | |
* 只包含在 ToucheEvent.target 的 Touch 对象 | |
* @param {DOMElement} target | |
* @return {Boolean} | |
*/ | |
Zoom.prototype.isInZoomArea = function (target) { | |
if ( this.elem.parentElement === target ) { | |
return true; | |
} else if ( !target ) { | |
return false; | |
} else { | |
return this.isInZoomArea(target.parentElement); | |
} | |
}; | |
/** | |
* 计算两个触摸事件的距离, | |
* NOTE: 为了减少运算结果和增加更容易比较,使用距离的平方 | |
* @param {Touch} touches | |
* @return {Number} | |
*/ | |
Zoom.prototype.calcTouchDistanceSquare = function(touches) { | |
var offset = getTouchOffset(touches[0], touches[1]); | |
return Math.pow(offset.x, 2) + Math.pow(offset.y, 2); | |
}; | |
Zoom.prototype.calcScaledTranslate = function (touches, scaleTo) { | |
var position = this.centerPosition.start ? | |
this.centerPosition : | |
this.cacheZoomPosition(this.calcCenterPosition(touches)); | |
var tran = _calcScaledTranslate( | |
scaleTo, position.center, this.sourceRect, | |
offsetAxle(this.offsetTranslate, position.offset)); | |
return tran; | |
}; | |
/** | |
* 计算缩放之后,保持 transform-origin 不变,通过调整 translate 来实现中心点移动 | |
* transform-origin 的修改会导致重绘,而 translate 只会重组,性能要好一些 | |
* | |
* @param {Point} centerPoint1x 1倍倍率的视觉中心点 | |
* @param {Number} scale 缩放到的目标倍率 | |
* @param {ClientRect} rect1x 1倍倍率缩放元素的大小属性,这里只用到 {width: 0, height: 0} | |
* @param {Point} tran1x 1倍倍率缩放元素相对于父元素的 translate 值 | |
* @return {Point} 设置 translate 的 x 值和 y 值 | |
*/ | |
function _calcScaledTranslate (scale, centerPoint1x, rect1x, tran1x) { | |
// 公式1:计算 1 倍率下的 translate 值放大后的实际偏移距离。 | |
// var scaledOffset = { | |
// x: srcTran.x*scale - srcRect.width*(scale-1)/2, | |
// y: srcTran.y*scale - srcRect.height*(scale-1)/2 | |
// }; | |
// 放大后返回到中心点的实际偏移距离 | |
var sOffsetX = tran1x.x - (scale - 1)*centerPoint1x.x; | |
var sOffsetY = tran1x.y - (scale - 1)*centerPoint1x.y; | |
// 利用公式1 反向计算出 1 倍率下设置的 translate 值 | |
return { | |
x: ( rect1x.width*(scale-1)/2 + sOffsetX ) / scale, | |
y: ( rect1x.height*(scale-1)/2 + sOffsetY ) / scale | |
}; | |
} | |
Zoom.prototype.cacheZoomPosition = function (position) { | |
this.centerPosition = position; | |
this.centerPosition.start = true; | |
return this.centerPosition; | |
}; | |
Zoom.prototype.calcCenterPosition = function(touches) { | |
var rect = this.elem.getBoundingClientRect(); | |
var centerTouch = { | |
clientX: (touches[0].clientX + touches[1].clientX)/2, | |
clientY: (touches[0].clientY + touches[1].clientY)/2 | |
}; | |
var center = timesNumberProps( this.getInnerPoint(centerTouch, rect), 1/this.scale ); | |
var offsetCenter = this.getInnerPoint(centerTouch, this.sourceRect, this.offsetTranslate); | |
return { | |
center: center, | |
offset: offsetAxle(offsetCenter, timesNumberProps(center, -1)) | |
}; | |
}; | |
/** | |
* 获取相对于特定元素的坐标系坐标 | |
* @param {Touch} touch 当前的Touch对象,Touch.clientX和Touch.clientY是相对于viewport的坐标 | |
* @param {Object} elemClientRect 用于计算的矩形数据,一般式固定的父辈元素 getBoundingClientRect() | |
* @param {Object} offset 偏移位置,默认偏移为{x: 0, y: 0} | |
* @return {Object} {x:0, y:0} 格式对象 | |
*/ | |
Zoom.prototype.getInnerPoint = function(touch, elemClientRect, offset) { | |
offset = offset || {x: 0, y: 0}; | |
var x = touch.clientX - elemClientRect.left - offset.x; | |
var y = touch.clientY - elemClientRect.top - offset.y; | |
x = x > elemClientRect.width ? elemClientRect.width : (x < 0 ? 0 : x); | |
y = y > elemClientRect.heigth ? elemClientRect.heigth : (y < 0 ? 0 : y); | |
return {x: x, y: y}; | |
}; | |
Zoom.prototype.handleTouchDrag = function(e, touch) { | |
var faster = 1.2; | |
var lastTouch = this.cachedDragTouch; | |
var offset = this.calcTouchOffset(touch, lastTouch); | |
if ( e.type === 'touchstart' ) { | |
this.cacheTouch('startDragTouch', touch); | |
} | |
if ( e.type === 'touchmove' ) { | |
this.preventTouchEvent(e); | |
this.dragTouchmoveCount++; | |
this.showThumbnail('drag'); | |
this.setTransform({touchOffset: offset, animation: false}); | |
} | |
if ( e.type === 'touchend' ) { | |
// 当响应 touchmove 个数 < 4 时可能是双指触摸过程的微小移动导致,影响缩放效果 | |
if ( this.dragTouchmoveCount < 8 && this.dragTouchmoveCount > 4) { | |
this.showThumbnail('drag'); | |
offset = this.calcTouchOffset(touch, this.startDragTouch, faster); | |
this.setTransform({touchOffset: offset, animation: true}); | |
} | |
} | |
}; | |
Zoom.prototype.cacheTouch = function(prop, touch) { | |
if ( touch ) { | |
this[prop] = { clientX: touch.clientX, clientY: touch.clientY }; | |
} | |
}; | |
/** | |
* 计算 translate 的目标坐标 | |
* NOTE: 整个程序要保证 this.translate 不为空,或者异常值 | |
* @param {Touch} touch Touch对象 | |
* @param {Touche} lastTouch 上一次 Touch 对象 | |
* @param {Number} faster 加快移动速度的倍率 | |
* @return {null|Object} 返回{x: 0, y: 0} | |
*/ | |
Zoom.prototype.calcTouchOffset = function(touch, lastTouch, faster) { | |
if ( !touch || !lastTouch ) { return this.translate; } | |
var offset = {}; | |
var offsetTouch = getTouchOffset(touch, lastTouch); | |
faster = faster || 1; | |
offset.x = offsetTouch.x * faster; | |
offset.y = offsetTouch.y * faster; | |
return offset; | |
}; | |
Zoom.prototype.handleTouch = function(e) { | |
if ( this.inTransition ) { | |
this.preventTouchEvent(e); | |
this.doTouchendClean(e); | |
return false; | |
} | |
var touches = this.getOwnTouches(e); | |
if ( e.touches.length === 1 && touches.length === 1 && | |
!this.scaleActive && this.scale > 1 ) { | |
this.handleTouchDrag(e, touches[0]); | |
this.cacheTouch('cachedDragTouch', touches[0]); | |
} else if ( e.touches.length === 2 && touches.length ) { | |
if ( touches.length === 1 ) touches = e.touches; | |
this.handleTouchZoom(e, touches); | |
} | |
if ( e.type === 'touchend' && (this.scaleActive || this.dragTouchmoveCount) ) { | |
this.preventTouchEvent(e); | |
} | |
this.doTouchendClean(e); | |
} | |
Zoom.prototype.preventTouchEvent = function ( event ) { | |
// 'touchstart' 和 'touchend' 事件如果屏蔽掉,会影响元素内的 'click' 事件 | |
event.preventDefault(); | |
event.stopPropagation(); | |
}; | |
Zoom.prototype.getOwnTouches = function (event) { | |
var zoomer = this; | |
var touches = []; | |
function pushTouches(_touches) { | |
for ( var i = 0; i < _touches.length; i++ ) { | |
if ( zoomer.isInZoomArea(_touches[i].target) ) { | |
touches.push(_touches[i]); | |
} | |
} | |
} | |
if ( event.type === 'touchend' ) { | |
pushTouches(event.changedTouches); | |
} else { | |
pushTouches(event.touches); | |
} | |
return touches.length ? touches : null; | |
}; | |
Zoom.prototype.doTouchendClean = function(e) { | |
if ( e.type !== 'touchend' ) { return; } | |
if ( this.scaleActive && !e.touches.length ) { | |
this.scaleActive = false; | |
this.cachedTouchDistance = 0; | |
this.centerPosition.start = false; | |
this.hideThumbnail(); | |
} | |
if ( this.dragTouchmoveCount ) { | |
this.cachedDragTouch = null; | |
this.dragTouchmoveCount = 0; | |
this.hideThumbnail(); | |
} | |
}; | |
Zoom.prototype.bindEvents = function() { | |
var zoomer = this; | |
this.bindTouchEvents(zoomer._handleTouch || function(e) { | |
zoomer.handleTouch(e); | |
}); | |
}; | |
Zoom.prototype.unbindEvents = function() { | |
this.unbindTouchEvents(this._handleTouch); | |
}; | |
Zoom.prototype.bindTouchEvents = function(callback) { | |
this.elem.parentElement.addEventListener('touchstart', callback, false); | |
this.elem.parentElement.addEventListener('touchmove', callback, false); | |
this.elem.parentElement.addEventListener('touchend', callback, false); | |
}; | |
Zoom.prototype.unbindTouchEvents = function(callback) { | |
this.elem.parentElement.removeEventListener('touchstart', callback); | |
this.elem.parentElement.removeEventListener('touchmove', callback); | |
this.elem.parentElement.removeEventListener('touchend', callback); | |
}; | |
/** | |
* 设置 transform 值,可选择是否使用动画 | |
* @param {Object} config { | |
* scale: [Number], | |
* translate: [{x: [Number], y: [Number]}], | |
* animation: [Boolean], // false default | |
* limit: [Boolean] // true default | |
* } | |
* @return {undefined} | |
*/ | |
Zoom.prototype.setTransform = function (config) { | |
var translate; | |
var limit = config.limit === false ? false : true; | |
var animation = config.animation && this.options.animation; | |
var scale = limit ? this.limitScale(config.scale) : scale || this.scale; | |
if ( config.touchOffset ) { | |
translate = this.calcDragTranslate(config.touchOffset, scale); | |
} else { | |
translate = config.translate || this.translate; | |
} | |
translate = limit ? this.limitTranslate(translate, scale) : translate; | |
// console.log('setTransform:', | |
// 'config=', config, ',fixedScale=', scale, | |
// ',fixedTranslate=', translate); | |
if ( this.inTransition || ( | |
this.scale === scale && | |
Math.abs(translate.x - this.translate.x) < 0.0001 && | |
Math.abs(translate.y - this.translate.y < 0.0001)) ) { | |
return; | |
} | |
this.inTransition = true; | |
var transform = this.fmtTransform(scale, translate); | |
if ( animation ) { | |
var zoomer = this; | |
animate( | |
zoomer.elem, | |
{transform: transform}, | |
zoomer.options.animConfig, | |
function handleFinish() { | |
zoomer.handleAnimFinish(scale, translate); | |
} | |
); | |
} else { | |
setStyle(this.elem, 'transform' ,transform); | |
this.handleAnimFinish(scale, translate); | |
} | |
}; | |
Zoom.prototype.fmtTransform = function(scale, translate) { | |
var transform = ''; | |
transform += 'scale('+ scale +','+ scale +') '; | |
transform += translate && 'translate('+ translate.x +'px,'+ translate.y +'px)'; | |
return transform; | |
}; | |
Zoom.prototype.handleAnimFinish = function(scale, translate) { | |
this.inTransition = false; | |
this.scale = scale; | |
this.translate = translate; | |
this.refreshThumbnailViewport(); | |
if ( this.options.animFinish ) { | |
this.options.animFinish.call(this.elem, scale, translate); | |
} | |
}; | |
Zoom.prototype.limitScale = function (scale) { | |
if ( !scale ) { return this.scale; } | |
return range(scale, this.options.minScale, this.options.maxScale); | |
}; | |
/** | |
* 根据触点偏移值,计算 translate 值 | |
* @param {Point} touchOffset 触摸事件的偏移值 | |
* @param {Number} scale 放大值 | |
* @return {Point} | |
*/ | |
Zoom.prototype.calcDragTranslate = function ( touchOffset, scale ) { | |
// 如果 scale 发生变化,原来的 translate 值需要根据最新的 scale 进行比例 k 缩放。 | |
var k = scale / this.scale; | |
// 如果触摸点移动 100px,要想目标也移动 100px。 | |
// 因为 translate 会被 scale 放大或缩小。 | |
// 所以实际 translate 值的偏移量 dx或dy 为 (100/scale)px。 | |
touchOffset = touchOffset || {x: 0, y: 0}; | |
return offsetAxle(timesNumberProps(this.translate, k), timesNumberProps(touchOffset, 1/scale)); | |
}; | |
/** | |
* 求移动的范围,如果移动到了边界将不再移动 | |
* 移动范围的计算公式,前提 transformOrigin: 'center center'。Y 同 X: | |
* xMin: -( (视口宽度*scale - 视口宽度) /scale / 2 + 初始偏移的x值 ) | |
* xMax: (视口宽度*scale - 视口宽度) / scale / 2 + 初始偏移的x值 | |
* @param {Point} translate 计算的 translate 值 | |
* @param {Number} scale 目标放大倍数 | |
* @return {Object} 移动目标坐标 | |
*/ | |
Zoom.prototype.limitTranslate = function (translate, scale) { | |
var ret = {}; | |
var rect = this.elem.getBoundingClientRect(); | |
var srect = this.sourceRect; | |
var prect = this.parentRect; | |
var isDrag = this.scale === scale; | |
var scaledRect = timesNumberProps(this.sourceRect, scale); | |
scaledRect.top = srect.top - (scaledRect.height - srect.height) / 2 + translate.y * scale; | |
scaledRect.bottom = scaledRect.top + scaledRect.height; | |
scaledRect.left = srect.left - (scaledRect.width - srect.width) / 2 + translate.x * scale; | |
scaledRect.right = scaledRect.left + scaledRect.width; | |
var leftTopLimit = _calcScaledTranslate(scale, {x: 0, y: 0}, srect, {x: 0, y: 0}); | |
var rightBottomLimit = _calcScaledTranslate(scale, | |
{x: srect.width, y: srect.height}, srect, | |
{x: this.offsetTranslate.x * 2, y: this.offsetTranslate.y * 2}); | |
// 父级元素的 border 等设置会影响大小比较 | |
var precision = this.elem.parentElement.offsetHeight - this.elem.parentElement.clientHeight + 5; | |
function limit(axle) { | |
var minSide = ({x: 'left', y: 'top'})[axle]; | |
var maxSide = ({x: 'right', y: 'bottom'})[axle]; | |
// 从上往下。或,从左往右 | |
if ( rect[minSide] < scaledRect[minSide] ) { | |
//穿过父容器上边界.或,穿过左边界。 | |
if ( scaledRect[minSide] > prect[minSide] && | |
rect[minSide] - precision <= prect[minSide] ){ | |
ret[axle] = leftTopLimit[axle]; | |
} else { | |
ret[axle] = translate[axle]; | |
} | |
// 从下往上。或,从右往左 | |
} else { | |
//穿过父容器下边界.或,穿过右边界。 | |
if ( scaledRect[maxSide] < prect[maxSide] && | |
rect[maxSide] + precision >= prect[maxSide] ) { | |
ret[axle] = rightBottomLimit[axle]; | |
} else { | |
ret[axle] = translate[axle]; | |
} | |
} | |
} | |
var isNotOverflow = ( | |
// 不包含相等的情况,会影响拖动中对边界的判断 | |
rect.top > prect.top && rect.left > prect.left && | |
rect.bottom < prect.bottom && rect.right < prect.right | |
); | |
if ( scale === Zoom.INIT_SCALE ) { | |
ret = this.offsetTranslate; | |
} else if ( isDrag && !isNotOverflow ) { | |
limit('x'); | |
limit('y'); | |
} else if ( isDrag && isNotOverflow ) { | |
ret = this.translate; | |
} else { | |
ret = translate; | |
} | |
return ret; | |
}; | |
var prefixes = ['-webkit-', '-moz-']; | |
var needPrefix = { 'box-sizing': 1, 'transform': 1, 'transition': 1, 'transform-origin': 1}; | |
function setStyle(elem, prop, value) { | |
if ( needPrefix[prop] ) { | |
prefixes.forEach(function (prefix) { | |
elem.style.setProperty(prefix + prop, value); | |
}); | |
} else { | |
elem.style.setProperty(prop, value); | |
} | |
} | |
/** | |
* 放大给定对象的所有类型为 Number 的属性 | |
* @param {Obj} obj | |
* @param {Number} scale 放大的的倍数 | |
* @return {Obj} | |
*/ | |
function timesNumberProps(obj, scale) { | |
var ret = {}; | |
for ( var p in obj) { | |
if ( typeof obj[p] === 'number' ) { ret[p] = obj[p] * scale; } | |
} | |
return ret; | |
} | |
function getTouchOffset(touch, touchEnd) { | |
return { x: touch.clientX - touchEnd.clientX, y: touch.clientY - touchEnd.clientY }; | |
} | |
function offsetAxle(target, offset) { | |
return { x: target.x + offset.x, y: target.y + offset.y }; | |
} | |
function range(value, min, max, offset) { | |
offset = offset || 0; | |
value = value >= min && value <= max ? value : (value < min ? min : max ); | |
return value + offset; | |
} | |
module.exports = Zoom; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment