/* * Jquery plugin for unified mouse and touch events * * Copyright (c) 2013 Michael S. Mikowski * (mike[dot]mikowski[at]gmail[dotcom]) * * Dual licensed under the MIT or GPL Version 2 * http://jquery.org/license * * Versions * 0.3.0 - Initial jQuery plugin site release * - Replaced scrollwheel zoom with drag motion. * This resolved a conflict with scrollable areas. * 0.3.1 - Change for jQuery plugins site * 0.3.2 - Updated to jQuery 1.9.1. * Confirmed 1.7.0-1.9.1 compatibility. * 0.4.2 - Updated documentation * 0.4.3 - Removed fatal execption possibility if originalEvent * is not defined on event object * */ /*jslint browser : true, continue : true, devel : true, indent : 2, maxerr : 50, newcap : true, plusplus : true, regexp : true, sloppy : true, vars : true, white : true */ /*global jQuery, sl */ (function ($) { //---------------- BEGIN MODULE SCOPE VARIABLES -------------- var $Special = $.event.special, // shortcut for special event motionMapMap = {}, // map of pointer motions by cursor isMoveBound = false, // flag if move handlers bound pxPinchZoom = -1, // distance between pinch-zoom points optionKey = 'ue_bound', // data key for storing options doDisableMouse = false, // flag to discard mouse input defaultOptMap = { // Default option hash bound_ns_map: {}, // namspace hash e.g. bound_ns_map.utap.fred wheel_ratio: 15, // multiplier for mousewheel delta px_radius: 3, // 'distance' dragged before dragstart ignore_class: ':input', // 'not' suppress matching elements tap_time: 200, // millisecond max time to consider tap held_tap_time: 300 // millisecond min time to consider taphold }, callbackList = [], // global callback stack zoomMouseNum = 1, // multiplier for mouse zoom zoomTouchNum = 4, // multiplier for touch zoom boundList, Ue, motionDragId, motionHeldId, motionDzoomId, motion1ZoomId, motion2ZoomId, checkMatchVal, removeListVal, pushUniqVal, makeListPlus, fnHeld, fnMotionStart, fnMotionMove, fnMotionEnd, onMouse, onTouch, onMousewheel ; //----------------- END MODULE SCOPE VARIABLES --------------- //------------------- BEGIN UTILITY METHODS ------------------ // Begin utiltity /makeListPlus/ // Returns an array with much desired methods: // * remove_val(value) : remove element that matches // the provided value. Returns number of elements // removed. // * match_val(value) : shows if a value exists // * push_uniq(value) : pushes a value onto the stack // iff it does not already exist there // Note: the reason I need this is to compare objects to // objects (perhaps jQuery has something similar?) checkMatchVal = function (data) { var match_count = 0, idx; for (idx = this.length; idx; 0) { if (this[--idx] === data) { match_count++; } } return match_count; }; removeListVal = function (data) { var removed_count = 0, idx; for (idx = this.length; idx; 0) { if (this[--idx] === data) { this.splice(idx, 1); removed_count++; idx++; } } return removed_count; }; pushUniqVal = function (data) { if (checkMatchVal.call(this, data)) { return false; } this.push(data); return true; }; // primary utility makeListPlus = function (input_list) { if (input_list && $.isArray(input_list)) { if (input_list.remove_val) { console.warn('The array appears to already have listPlus capabilities'); return input_list; } } else { input_list = []; } input_list.remove_val = removeListVal; input_list.match_val = checkMatchVal; input_list.push_uniq = pushUniqVal; return input_list; }; // End utility /makeListPlus/ //-------------------- END UTILITY METHODS ------------------- //--------------- BEGIN JQUERY SPECIAL EVENTS ---------------- // Unique array for bound objects boundList = makeListPlus(); // Begin define special event handlers Ue = { setup: function (data, a_names, fn_bind) { var this_el = this, $to_bind = $(this_el), seen_map = {}, option_map, idx, namespace_key, ue_namespace_code, namespace_list ; // if previous related event bound do not rebind, but do add to // type of event bound to this element, if not already noted if ($.data(this, optionKey)) { return; } option_map = {}; $.extend(true, option_map, defaultOptMap); $.data(this_el, optionKey, option_map); namespace_list = makeListPlus(a_names.slice(0)); if (!namespace_list.length || namespace_list[0] === "" ) { namespace_list = ["000"]; } NSPACE_00: for (idx = 0; idx < namespace_list.length; idx++) { namespace_key = namespace_list[idx]; if (!namespace_key) { continue NSPACE_00; } if (seen_map.hasOwnProperty(namespace_key)) { continue NSPACE_00; } seen_map[namespace_key] = true; ue_namespace_code = '.__ue' + namespace_key; $to_bind.bind('mousedown' + ue_namespace_code, onMouse); $to_bind.bind('touchstart' + ue_namespace_code, onTouch); $to_bind.bind('mousewheel' + ue_namespace_code, onMousewheel); } boundList.push_uniq(this_el); // record as bound element if (!isMoveBound) { // console.log('first element bound - adding global binds'); $(document).bind('mousemove.__ue', onMouse); $(document).bind('touchmove.__ue', onTouch); $(document).bind('mouseup.__ue', onMouse); $(document).bind('touchend.__ue', onTouch); $(document).bind('touchcancel.__ue', onTouch); isMoveBound = true; } }, // arg_map.type = string - name of event to bind // arg_map.data = poly - whatever (optional) data was passed when binding // arg_map.namespace = string - A sorted, dot-delimited list of namespaces // specified when binding the event // arg_map.handler = fn - the event handler the developer wishes to be bound // to the event. This function should be called whenever the event // is triggered // arg_map.guid = number - unique ID for event handler, provided by jQuery // arg_map.selector = string - selector used by 'delegate' or 'live' jQuery // methods. Only available when these methods are used. // // this - the element to which the event handler is being bound // this always executes immediate after setup (if first binding) add: function (arg_map) { var this_el = this, option_map = $.data(this_el, optionKey), namespace_str = arg_map.namespace, event_type = arg_map.type, bound_ns_map, namespace_list, idx, namespace_key ; if (!option_map) { return; } bound_ns_map = option_map.bound_ns_map; if (!bound_ns_map[event_type]) { // this indicates a non-namespaced entry bound_ns_map[event_type] = {}; } if (!namespace_str) { return; } namespace_list = namespace_str.split('.'); for (idx = 0; idx < namespace_list.length; idx++) { namespace_key = namespace_list[idx]; bound_ns_map[event_type][namespace_key] = true; } }, remove: function (arg_map) { var elem_bound = this, option_map = $.data(elem_bound, optionKey), bound_ns_map = option_map.bound_ns_map, event_type = arg_map.type, namespace_str = arg_map.namespace, namespace_list, idx, namespace_key ; if (!bound_ns_map[event_type]) { return; } // No namespace(s) provided: // Remove complete record for custom event type (e.g. utap) if (!namespace_str) { delete bound_ns_map[event_type]; return; } // Namespace(s) provided: // Remove namespace flags from each custom event typei (e.g. utap) // record. If all claimed namespaces are removed, remove // complete record. namespace_list = namespace_str.split('.'); for (idx = 0; idx < namespace_list.length; idx++) { namespace_key = namespace_list[idx]; if (bound_ns_map[event_type][namespace_key]) { delete bound_ns_map[event_type][namespace_key]; } } if ($.isEmptyObject(bound_ns_map[event_type])) { delete bound_ns_map[event_type]; } }, teardown: function (a_names) { var elem_bound = this, $bound = $(elem_bound), option_map = $.data(elem_bound, optionKey), bound_ns_map = option_map.bound_ns_map, idx, namespace_key, ue_namespace_code, namespace_list ; // do not tear down if related handlers are still bound if (!$.isEmptyObject(bound_ns_map)) { return; } namespace_list = makeListPlus(a_names); namespace_list.push_uniq('000'); NSPACE_01: for (idx = 0; idx < namespace_list.length; idx++) { namespace_key = namespace_list[idx]; if (!namespace_key) { continue NSPACE_01; } ue_namespace_code = '.__ue' + namespace_key; $bound.unbind('mousedown' + ue_namespace_code); $bound.unbind('touchstart' + ue_namespace_code); $bound.unbind('mousewheel' + ue_namespace_code); } $.removeData(elem_bound, optionKey); // Unbind document events only after last element element is removed boundList.remove_val(this); if (boundList.length === 0) { // console.log('last bound element removed - removing global binds'); $(document).unbind('mousemove.__ue'); $(document).unbind('touchmove.__ue'); $(document).unbind('mouseup.__ue'); $(document).unbind('touchend.__ue'); $(document).unbind('touchcancel.__ue'); isMoveBound = false; } } }; // End define special event handlers //--------------- BEGIN JQUERY SPECIAL EVENTS ---------------- //------------------ BEGIN MOTION CONTROLS ------------------- // Begin motion control /fnHeld/ fnHeld = function (arg_map) { var timestamp = +new Date(), motion_id = arg_map.motion_id, motion_map = arg_map.motion_map, bound_ns_map = arg_map.bound_ns_map, event_ue ; delete motion_map.idto_tapheld; if (!motion_map.do_allow_tap) { return; } motion_map.px_end_x = motion_map.px_start_x; motion_map.px_end_y = motion_map.px_start_y; motion_map.ms_timestop = timestamp; motion_map.ms_elapsed = timestamp - motion_map.ms_timestart; if (bound_ns_map.uheld) { event_ue = $.Event('uheld'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); } // remove tracking, as we want no futher action on this motion if (bound_ns_map.uheldstart) { event_ue = $.Event('uheldstart'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); motionHeldId = motion_id; } else { delete motionMapMap[motion_id]; } }; // End motion control /fnHeld/ // Begin motion control /fnMotionStart/ fnMotionStart = function (arg_map) { var motion_id = arg_map.motion_id, event_src = arg_map.event_src, request_dzoom = arg_map.request_dzoom, option_map = $.data(arg_map.elem, optionKey), bound_ns_map = option_map.bound_ns_map, $target = $(event_src.target), do_zoomstart = false, motion_map, cb_map, do_allow_tap, event_ue ; // this should never happen, but it does if (motionMapMap[motion_id]) { return; } if (request_dzoom && !bound_ns_map.uzoomstart) { return; } // :input selector includes text areas if ($target.is(option_map.ignore_class)) { return; } do_allow_tap = bound_ns_map.utap || bound_ns_map.uheld || bound_ns_map.uheldstart ? true : false; cb_map = callbackList.pop(); while (cb_map) { if ($target.is(cb_map.selector_str) || $(arg_map.elem).is(cb_map.selector_str) ) { if (cb_map.callback_match) { cb_map.callback_match(arg_map); } } else { if (cb_map.callback_nomatch) { cb_map.callback_nomatch(arg_map); } } cb_map = callbackList.pop(); } motion_map = { do_allow_tap: do_allow_tap, elem_bound: arg_map.elem, elem_target: event_src.target, ms_elapsed: 0, ms_timestart: event_src.timeStamp, ms_timestop: undefined, option_map: option_map, orig_target: event_src.target, px_current_x: event_src.clientX, px_current_y: event_src.clientY, px_end_x: undefined, px_end_y: undefined, px_start_x: event_src.clientX, px_start_y: event_src.clientY, timeStamp: event_src.timeStamp }; motionMapMap[motion_id] = motion_map; if (bound_ns_map.uzoomstart) { if (request_dzoom) { motionDzoomId = motion_id; } else if (!motion1ZoomId) { motion1ZoomId = motion_id; } else if (!motion2ZoomId) { motion2ZoomId = motion_id; event_ue = $.Event('uzoomstart'); do_zoomstart = true; } if (do_zoomstart) { event_ue = $.Event('uzoomstart'); motion_map.px_delta_zoom = 0; $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); return; } } if (bound_ns_map.uheld || bound_ns_map.uheldstart) { motion_map.idto_tapheld = setTimeout( function () { fnHeld({ motion_id: motion_id, motion_map: motion_map, bound_ns_map: bound_ns_map }); }, option_map.held_tap_time ); } }; // End motion control /fnMotionStart/ // Begin motion control /fnMotionMove/ fnMotionMove = function (arg_map) { var motion_id = arg_map.motion_id, event_src = arg_map.event_src, do_zoommove = false, motion_map, option_map, bound_ns_map, event_ue, px_pinch_zoom, px_delta_zoom, mzoom1_map, mzoom2_map ; if (!motionMapMap[motion_id]) { return; } motion_map = motionMapMap[motion_id]; option_map = motion_map.option_map; bound_ns_map = option_map.bound_ns_map; motion_map.timeStamp = event_src.timeStamp; motion_map.elem_target = event_src.target; motion_map.ms_elapsed = event_src.timeStamp - motion_map.ms_timestart; motion_map.px_delta_x = event_src.clientX - motion_map.px_current_x; motion_map.px_delta_y = event_src.clientY - motion_map.px_current_y; motion_map.px_current_x = event_src.clientX; motion_map.px_current_y = event_src.clientY; // native event object override motion_map.timeStamp = event_src.timeStamp; // disallow tap if outside of zone or time elapsed // we use this for other events, so we do it every time if (motion_map.do_allow_tap) { if (Math.abs(motion_map.px_delta_x) > option_map.px_radius || Math.abs(motion_map.pd_delta_y) > option_map.px_radius || motion_map.ms_elapsed > option_map.tap_time ) { motion_map.do_allow_tap = false; } } if (motion1ZoomId && motion2ZoomId && ( motion_id === motion1ZoomId || motion_id === motion2ZoomId )) { motionMapMap[motion_id] = motion_map; mzoom1_map = motionMapMap[motion1ZoomId]; mzoom2_map = motionMapMap[motion2ZoomId]; px_pinch_zoom = Math.floor( Math.sqrt( Math.pow((mzoom1_map.px_current_x - mzoom2_map.px_current_x), 2) + Math.pow((mzoom1_map.px_current_y - mzoom2_map.px_current_y), 2) ) + 0.5 ); if (pxPinchZoom === -1) { px_delta_zoom = 0; } else { px_delta_zoom = ( px_pinch_zoom - pxPinchZoom ) * zoomTouchNum; } // save value for next iteration delta comparison pxPinchZoom = px_pinch_zoom; do_zoommove = true; } else if (motionDzoomId === motion_id) { if (bound_ns_map.uzoommove) { px_delta_zoom = motion_map.px_delta_y * zoomMouseNum; do_zoommove = true; } } if (do_zoommove) { event_ue = $.Event('uzoommove'); motion_map.px_delta_zoom = px_delta_zoom; $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); return; } if (motionHeldId === motion_id) { if (bound_ns_map.uheldmove) { event_ue = $.Event('uheldmove'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); event_src.preventDefault(); } } else if (motionDragId === motion_id) { if (bound_ns_map.udragmove) { event_ue = $.Event('udragmove'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); event_src.preventDefault(); } } if (!motionDragId && !motionHeldId && bound_ns_map.udragstart && motion_map.do_allow_tap === false ) { motionDragId = motion_id; event_ue = $.Event('udragstart'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); event_src.preventDefault(); if (motion_map.idto_tapheld) { clearTimeout(motion_map.idto_tapheld); delete motion_map.idto_tapheld; } } }; // End motion control /fnMotionMove/ // Begin motion control /fnMotionEnd/ fnMotionEnd = function (arg_map) { var motion_id = arg_map.motion_id, event_src = arg_map.event_src, do_zoomend = false, motion_map, option_map, bound_ns_map, event_ue ; doDisableMouse = false; if (!motionMapMap[motion_id]) { return; } motion_map = motionMapMap[motion_id]; option_map = motion_map.option_map; bound_ns_map = option_map.bound_ns_map; motion_map.elem_target = event_src.target; motion_map.ms_elapsed = event_src.timeStamp - motion_map.ms_timestart; motion_map.ms_timestop = event_src.timeStamp; if (motion_map.px_current_x) { motion_map.px_delta_x = event_src.clientX - motion_map.px_current_x; motion_map.px_delta_y = event_src.clientY - motion_map.px_current_y; } motion_map.px_current_x = event_src.clientX; motion_map.px_current_y = event_src.clientY; motion_map.px_end_x = event_src.clientX; motion_map.px_end_y = event_src.clientY; // native event object override motion_map.timeStamp = event_src.timeStamp ; // clear-out any long-hold tap timer if (motion_map.idto_tapheld) { clearTimeout(motion_map.idto_tapheld); delete motion_map.idto_tapheld; } // trigger utap if (bound_ns_map.utap && motion_map.ms_elapsed <= option_map.tap_time && motion_map.do_allow_tap ) { event_ue = $.Event('utap'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); } // trigger udragend if (motion_id === motionDragId) { if (bound_ns_map.udragend) { event_ue = $.Event('udragend'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); event_src.preventDefault(); } motionDragId = undefined; } // trigger heldend if (motion_id === motionHeldId) { if (bound_ns_map.uheldend) { event_ue = $.Event('uheldend'); $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); } motionHeldId = undefined; } // trigger uzoomend if (motion_id === motionDzoomId) { do_zoomend = true; motionDzoomId = undefined; } // cleanup zoom info else if (motion_id === motion1ZoomId) { if (motion2ZoomId) { motion1ZoomId = motion2ZoomId; motion2ZoomId = undefined; do_zoomend = true; } else { motion1ZoomId = undefined; } pxPinchZoom = -1; } if (motion_id === motion2ZoomId) { motion2ZoomId = undefined; pxPinchZoom = -1; do_zoomend = true; } if (do_zoomend && bound_ns_map.uzoomend) { event_ue = $.Event('uzoomend'); motion_map.px_delta_zoom = 0; $.extend(event_ue, motion_map); $(motion_map.elem_bound).trigger(event_ue); } // remove pointer from consideration delete motionMapMap[motion_id]; }; // End motion control /fnMotionEnd/ //------------------ END MOTION CONTROLS ------------------- //------------------- BEGIN EVENT HANDLERS ------------------- // Begin event handler /onTouch/ for all touch events. // We use the 'type' attribute to dispatch to motion control onTouch = function (event) { var this_el = this, timestamp = +new Date(), o_event = event.originalEvent, touch_list = o_event ? o_event.changedTouches || [] : [], touch_count = touch_list.length, idx, touch_event, motion_id, handler_fn ; doDisableMouse = true; event.timeStamp = timestamp; switch (event.type) { case 'touchstart' : handler_fn = fnMotionStart; event.preventDefault(); break; case 'touchmove' : handler_fn = fnMotionMove; break; case 'touchend' : case 'touchcancel' : handler_fn = fnMotionEnd; break; default : handler_fn = null; } if (!handler_fn) { return; } for (idx = 0; idx < touch_count; idx++) { touch_event = touch_list[idx]; motion_id = 'touch' + String(touch_event.identifier); event.clientX = touch_event.clientX; event.clientY = touch_event.clientY; handler_fn({ elem: this_el, motion_id: motion_id, event_src: event }); } }; // End event handler /onTouch/ // Begin event handler /onMouse/ for all mouse events // We use the 'type' attribute to dispatch to motion control onMouse = function (event) { var this_el = this, motion_id = 'mouse' + String(event.button), request_dzoom = false, handler_fn ; if (doDisableMouse) { event.stopImmediatePropagation(); return; } if (event.shiftKey) { request_dzoom = true; } // skip left or middle clicks if (event.type !== 'mousemove') { if (event.button !== 0) { return true; } } switch (event.type) { case 'mousedown' : handler_fn = fnMotionStart; event.preventDefault(); break; case 'mouseup' : handler_fn = fnMotionEnd; break; case 'mousemove' : handler_fn = fnMotionMove; break; default: handler_fn = null; } if (!handler_fn) { return; } handler_fn({ elem: this_el, event_src: event, request_dzoom: request_dzoom, motion_id: motion_id }); }; // End event handler /onMouse/ //-------------------- END EVENT HANDLERS -------------------- // Export special events through jQuery API $Special.ue = $Special.utap = $Special.uheld = $Special.uzoomstart = $Special.uzoommove = $Special.uzoomend = $Special.udragstart = $Special.udragmove = $Special.udragend = $Special.uheldstart = $Special.uheldmove = $Special.uheldend = Ue ; $.ueSetGlobalCb = function (selector_str, callback_match, callback_nomatch) { callbackList.push({ selector_str: selector_str || '', callback_match: callback_match || null, callback_nomatch: callback_nomatch || null }); }; }(jQuery));