Статьи

Hand.js: полифилл для поддержки событий указателя в любом браузере

Как насчет возможности написания единой базы кода для работы с мышью, пером и касанием на вашем веб-сайте, которая будет работать во всех современных браузерах?
Вот полифилл , который поможет вам использовать Pointer Events и предоставит пользователям отличные возможности на вашем сайте независимо от используемого ими современного браузера.

Еще в сентябре 2012 года Microsoft предложила W3C спецификацию для объединения событий касания, пера и мыши, которая называется «События указателя» и основана на API, доступных сегодня в IE10 для Windows 8. С тех пор W3C создала новую рабочую группу, которая уже опубликовала последний вызов. черновик . Команда Microsoft Open Technologies, Inc. также выпустила первоначальный прототип Pointer Events для Webkit на HTML5Labs, чтобы внести свой вклад в технические обсуждения в рабочей группе W3C Pointer Events. 

Мой умный коллега Дэвид Руссет написал отличную статью на эту тему: http://blogs.msdn.com/b/davrous/archive/2013/02/20/handling-touch-in-your-html5-apps-thanks-to -the-pointer-events-of-ie10-and-windows-8.aspx .

Поскольку эта спецификация не является окончательной, современные браузеры еще не реализуют ее (только Internet Explorer 10 в Windows 8 реализует ее с префиксами поставщиков MSPointerXXX). Чтобы помочь вам подготовить свой код для этого нового стандарта , эта статья покажет вам, как написать полизаполнение , которое позволит вам иметь единый код разметки, который будет работать во всех современных браузерах, даже если они еще не поддерживают спецификацию событий указателя. , 

Чтобы проиллюстрировать, как прост в использовании Hand.js , я взял первый пример в статье Дэвида Руссе, заменил каждый MSPointerXXX на PointerXXX и добавил ссылку на «Hand.js» и вуаля! (просто переместите свой палец или мышь поверх этого iframe):

 

Тестовый код

Для тестирования Hand.js я разработал простую HTML-страницу (доступна здесь: http://www.catuhe.com/msdn/handjs/index.html ).

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <link href="index.css" rel="stylesheet" />
    <script src="hand.js"></script>
    <script src="index.js"></script>
</head>
<body>
    <div id="title">Hand.js: a framework for pointer events</div>
    <canvas id="plainCanvas"></canvas>
</body>
</html>

Следующий JavaScript используется для рисования внутри холста с использованием нормализованных событий указателя:

var context;
var plainCanvas;
var pointerDown = {};
var lastPositions = {};
var colors = ["rgb(100, 255, 100)", "rgb(255, 0, 0)", "rgb(0, 255, 0)", 
              "rgb(0, 0, 255)", "rgb(0, 255, 100)", "rgb(10, 255, 255)", "rgb(255, 0, 100)"];

var onPointerMove = function(evt) {
    evt.preventDefault();

    if (pointerDown[evt.pointerId]) {

        var color = colors[evt.pointerId % colors.length];

        context.strokeStyle = color;

        context.beginPath();
        context.lineWidth = 2;
        context.moveTo(lastPositions[evt.pointerId].x, lastPositions[evt.pointerId].y);
        context.lineTo(evt.clientX, evt.clientY);
        context.closePath();
        context.stroke();

        lastPositions[evt.pointerId] = { x: evt.clientX, y: evt.clientY};
    }
};

var onPointerOut = function (evt) {
    evt.preventDefault();
    pointerDown[evt.pointerId] = false;
};

var onPointerUp = function (evt) {
    evt.preventDefault();
    pointerDown[evt.pointerId] = false;
};

var onPointerDown = function (evt) {
    evt.preventDefault();
    pointerDown[evt.pointerId] = true;

    lastPositions[evt.pointerId] = { x: evt.clientX, y: evt.clientY};
};

var onload = function() {
    plainCanvas = document.getElementById("plainCanvas");

    plainCanvas.width = plainCanvas.clientWidth;
    plainCanvas.height = plainCanvas.clientHeight;

    context = plainCanvas.getContext("2d");

    context.fillStyle = "rgba(50, 50, 50, 1)";
    context.fillRect(0, 0, plainCanvas.width, plainCanvas.height);

    plainCanvas.addEventListener("PointerDown", onPointerDown, false);
    plainCanvas.addEventListener("PointerMove", onPointerMove, false);
    plainCanvas.addEventListener("PointerUp", onPointerUp, false);
    plainCanvas.addEventListener("PointerOut", onPointerUp, false);
};

document.addEventListener("DOMContentLoaded", onload, false);

Вы заметите, что я использую «PointerXXX», который сейчас не поддерживается в современных браузерах, за исключением Internet Explorer 10 в Windows 8, который поддерживает префиксную версию «MSPointerXXX».

Цель Hand.js — позволить этому коду работать в любом браузере, независимо от того, поддерживает ли он касание или нет, что обеспечивает беспроблемное и унифицированное взаимодействие независимо от используемого метода ввода (касание, мышь или перо).

Переадресация addEventListener

Использование Hand.js должно быть прозрачным для разработчика. Он просто должен обратиться к библиотеке и позволить магии сработать:

<script src="hand.js"></script>

В Hand.js он работает, перехватывая вызовы addEventListener для плавного внедрения нашего кода:

var supportedEventsNames = ["PointerDown", "PointerUp", "PointerMove", "PointerOver", "PointerOut", 
                            "PointerCancel", "PointerEnter", "PointerLeave",
                            "pointerdown", "pointerup", "pointermove", "pointerover", "pointerout", 
                            "pointercancel", "pointerenter", "pointerleave"];
// Intercept addEventListener calls by changing the prototype
var interceptAddEventListener = function (root) {
    var current = root.prototype.addEventListener;

    var customAddEventListener = function (name, func, capture) {
       // Branch when a PointerXXX is used
       if (supportedEventsNames.indexOf(name) != -1) {
           makeTouchAware(this, name);
       }

       current.call(this, name, func, capture);
    };

    root.prototype.addEventListener = customAddEventListener;

    return (root.prototype.addEventListener != customAddEventListener);
};
interceptAddEventListener(HTMLBodyElement);
interceptAddEventListener(HTMLCanvasElement);
interceptAddEventListener(HTMLDivElement);
interceptAddEventListener(HTMLImageElement);
interceptAddEventListener(HTMLSpanElement);       

Функция interceptAddEventListener вызывает пользовательскую функцию (makeTouchAware) для генерации событий указателя на определенных объектах DOM, когда регистрируются стандартизированные события указателя (PointerXXX).

Мы можем сделать это, заменив функцию addEventListener на базовых прототипах.

Почти для всех браузеров нам просто нужно работать с корневым объектом HTMLElement. Но некоторые из них не поддерживают этот способ и заставляют нас работать с определенным корневым элементом DOM (body, canvas, div, image, span и т. Д.)

Поддержка MSPointerXXX

Функция makeTouchAware запускается так:

var makeTouchAware = function (item, eventName) {
    // If item is already touch aware, do nothing
    if (item.onpointerdown !== undefined) {
        return;
    }

    // IE 10
    if (item.onmspointerdown !== undefined) {
        var msEventName;

        if (eventName == eventName.toLowerCase()) {
            var indexOfUpperCase = supportedEventsNames.indexOf(eventName) - 
                      (supportedEventsNames.length / 2);
            msEventName = "MS" + supportedEventsNames[indexOfUpperCase];
        }
        else {
            msEventName = "MS" + eventName;
        }

        item.addEventListener(msEventName, 
           function (evt) { generateTouchClonedEvent(evt, eventName); }, false);

        // We can return because MSPointerXXX integrate mouse support
        return;
    }

Если onmspointerdown не является неопределенным, тогда мы можем просто зарегистрироваться для события с префиксом (MSPointerXXX) и вызвать функцию для вызова стандартизированного события (PointerXXX) при возникновении исходного события (мы вернемся к generateTouchClonedEvent позже).

Поддержка touchstart / touchmove / touchend / touchcancel

Для браузеров, которые не поддерживают события Pointer, но реализуют спецификацию событий Touch , мы просто должны сопоставить имена событий, добавив следующий код:

// Chrome, Firefox
if (item.ontouchstart !== undefined) {
    switch (eventName.toLowerCase()) {
        case "pointerdown":
            item.addEventListener("touchstart", function (evt) { 
                            handleOtherEvent(evt, eventName); }, false);
            break;
        case "pointermove":
            item.addEventListener("touchmove", function (evt) { 
                            handleOtherEvent(evt, eventName); }, false);
            break;
        case "pointerup":
            item.addEventListener("touchend", function (evt) { 
                            handleOtherEvent(evt, eventName); }, false);
            break;
        case "pointercancel":
            item.addEventListener("touchcancel", function (evt) { 
                            handleOtherEvent(evt, eventName); }, false);
            break;
    }
}

Основываясь на существовании функции ontouchstart, мы можем использовать следующую функцию для генерации событий указателя:

var handleOtherEvent = function (eventObject, name) {
    if (eventObject.preventManipulation)
        eventObject.preventManipulation();

    for (var i = 0; i < eventObject.changedTouches.length; ++i) {
        var touchPoint = eventObject.changedTouches[i];
        var touchPointId = touchPoint.identifier + 2; // Just to not override mouse id

        touchPoint.pointerId = touchPointId;
        touchPoint.pointerType = POINTER_TYPE_TOUCH; 
        touchPoint.currentTarget = eventObject.currentTarget;

        if (eventObject.preventDefault !== undefined) {
            touchPoint.preventDefault = function () {
                eventObject.preventDefault();
            };
        }

        generateTouchClonedEvent(touchPoint, name);
    }
};

handleOtherEvent в основном отправляет каждый updatedTouch в событие указателя. Обратите внимание, что добавлена ​​функция с именем protectDefault для маршрутизации вызова от события указателя к событию корневого касания.

Интеграция событий мыши

Спецификация Pointer Event указывает, что события мыши также собираются внутри той же модели. Hand.js обрабатывает события мыши и генерирует соответствующие события Pointer:

// Fallback to mouse
switch (eventName.toLowerCase()) {
    case "pointerdown":
     item.addEventListener("mousedown", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointermove":
     item.addEventListener("mousemove", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerup":
     item.addEventListener("mouseup", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerover":
     item.addEventListener("mouseover", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerout":
     item.addEventListener("mouseout", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerenter":
     item.addEventListener("mousenter", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerleave":
     item.addEventListener("mouseleave", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
}

GenerateMouseProxy создает событие указателя на основе события мыши:

var generateMouseProxy = function (evt, eventName) {
    evt.pointerId = 1;
    evt.pointerType = POINTER_TYPE_MOUSE; //event.POINTER_TYPE_MOUSE
    generateTouchClonedEvent(evt, eventName);
};

Генерация пользовательских событий

Последним недостающим фрагментом кода является функция generateTouchClonedEvent. Эта функция отвечает за имитацию структуры событий указателя:

var POINTER_TYPE_TOUCH = "touch";
var POINTER_TYPE_PEN = "pen";
var POINTER_TYPE_MOUSE = "mouse";
// Touch events
var generateTouchClonedEvent = function (sourceEvent, newName) {
    // Considering touch events are almost like super mouse events
    var evObj = document.createEvent('MouseEvents');
    evObj.initMouseEvent(newName, true, true, window, 1, sourceEvent.screenX, sourceEvent.screenY,
        sourceEvent.clientX, sourceEvent.clientY, sourceEvent.ctrlKey, sourceEvent.altKey,
        sourceEvent.shiftKey, sourceEvent.metaKey, sourceEvent.button, null);

    // offsets
    if (evObj.offsetX === undefined) {
        if (sourceEvent.offsetX !== undefined) {

            // For Opera which creates readonly properties
            if (Object && Object.defineProperty !== undefined) {
                Object.defineProperty(evObj, "offsetX", {
                    writable: true
                });
                Object.defineProperty(evObj, "offsetY", {
                    writable: true
                });
            }

            evObj.offsetX = sourceEvent.offsetX;
            evObj.offsetY = sourceEvent.offsetY;
        }
        else if (sourceEvent.layerX !== undefined) {
            evObj.offsetX = sourceEvent.layerX - sourceEvent.currentTarget.offsetLeft;
            evObj.offsetY = sourceEvent.layerY - sourceEvent.currentTarget.offsetTop;
        }
    }

    // adding missing properties
    evObj.pointerId = sourceEvent.pointerId;
    evObj.pointerType = sourceEvent.pointerType;

    if (sourceEvent.isPrimary !== undefined)
        evObj.isPrimary = sourceEvent.isPrimary;
    else
        evObj.isPrimary = true;

    if (sourceEvent.pressure)
        evObj.pressure = sourceEvent.pressure;
    else {
        var button = 0;

        if (sourceEvent.which !== undefined)
            button = sourceEvent.which;
        else if (sourceEvent.button !== undefined) {
            button = sourceEvent.button;
        }
        evObj.pressure = (button == 0) ? 0 : 0.5;
    }
        

    if (sourceEvent.rotation)
        evObj.rotation = sourceEvent.rotation;
    else
        evObj.rotation = 0;

    // Timestamp
    if (sourceEvent.hwTimestamp)
        evObj.hwTimestamp = sourceEvent.hwTimestamp;
    else
        evObj.hwTimestamp = 0;

    // Tilts
    if (sourceEvent.tiltX)
        evObj.tiltX = sourceEvent.tiltX;
    else
        evObj.tiltX = 0;

    if (sourceEvent.tiltY)
        evObj.tiltY = sourceEvent.tiltY;
    else
        evObj.tiltY = 0;

    // Width and Height
    if (sourceEvent.height)
        evObj.height = sourceEvent.height;
    else
        evObj.height = 0;

    if (sourceEvent.width)
        evObj.width = sourceEvent.width;
    else
        evObj.width = 0;

    // PreventDefault
    evObj.preventDefault = function () {
        if (sourceEvent.preventDefault !== undefined)
            sourceEvent.preventDefault();
    };

    // Constants
    evObj.POINTER_TYPE_TOUCH = POINTER_TYPE_TOUCH;
    evObj.POINTER_TYPE_PEN = POINTER_TYPE_PEN;
    evObj.POINTER_TYPE_MOUSE = POINTER_TYPE_MOUSE;

    // If force preventDefault
    if (sourceEvent.currentTarget.handjs_forcePreventDefault === true)
        evObj.preventDefault();

    // Fire event
    sourceEvent.currentTarget.dispatchEvent(evObj);
};

Используя механизм пользовательских событий JavaScript, мы можем отправить новое событие с помощью функции dispatchEvent. Этот код довольно прост. Он копирует свойства события из источника в место назначения и при необходимости создает значения по умолчанию.

Обратите внимание, что для Opera мы должны обновить свойства offsetX и offsetY, чтобы сделать их доступными для записи, а для Firefox мы должны вычислить offsetX и offsetY.

Уборка вещей

Для завершения мы также должны переопределить removeEventListener, чтобы удалить обработчики событий, введенные ранее.

Прежде всего, мы должны подключить removeEventLister нашей собственной функцией:

// Intercept removeEventListener calls by changing the prototype
var interceptRemoveEventListener = function (root) {
    var current = root.prototype.removeEventListener;

    var customRemoveEventListener = function (name, func, capture) {
        // Branch when a PointerXXX is used
        if (supportedEventsNames.indexOf(name) != –1){
            removeTouchAware(this, name);
        }

        current.call(this, name, func, capture);
    };

    root.prototype.removeEventListener = customRemoveEventListener;
};
// Hooks
interceptAddEventListener(HTMLBodyElement);
interceptAddEventListener(HTMLCanvasElement);
interceptAddEventListener(HTMLDivElement);
interceptAddEventListener(HTMLImageElement);
interceptAddEventListener(HTMLSpanElement);

interceptRemoveEventListener(HTMLBodyElement);
interceptRemoveEventListener(HTMLCanvasElement);
interceptRemoveEventListener(HTMLDivElement);
interceptRemoveEventListener(HTMLImageElement);
interceptRemoveEventListener(HTMLSpanElement);

Функция removeTouchAware аналогична функции makeTouchAware за исключением того, что она удаляет обработчики событий:

var removeTouchAware = function (item, eventName) {
    // If item is already touch aware, do nothing
    if (item.onpointerdown !== undefined) {
        return;
    }

    // IE 10
    if (item.onmspointerdown !== undefined) {
        var msEventName;

        if (eventName == eventName.toLowerCase()) {
            var indexOfUpperCase = supportedEventsNames.indexOf(eventName) - 
                (supportedEventsNames.length / 2);
            msEventName = "MS" + supportedEventsNames[indexOfUpperCase];
        }
        else {
            msEventName = "MS" + eventName;
        }
        item.removeEventListener(msEventName, function (evt) { 
                generateTouchClonedEvent(evt, eventName); });
        return;
    }

    // Chrome, Firefox
    if (iatem.ontouchstart !== undefined) {
        switch (eventName.toLowerCase()) {
            case "pointerdown":
         item.removeEventListener("touchstart", function (evt) { handleOtherEvent(evt, eventName); });
         break;
            case "pointermove":
         item.removeEventListener("touchmove", function (evt) { handleOtherEvent(evt, eventName); });
         break;
            case "pointerup":
         item.removeEventListener("touchend", function (evt) { handleOtherEvent(evt, eventName); });
         break;
            case "pointercancel":
         item.removeEventListener("touchcancel", function (evt) { handleOtherEvent(evt, eventName); });
         break;
        }
    }

    // Fallback to mouse
    switch (eventName.toLowerCase()) {
        case "pointerdown":
         item.removeEventListener("mousedown", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointermove":
         item.removeEventListener("mousemove", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointerup":
         item.removeEventListener("mouseup", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointerover":
         item.removeEventListener("mouseover", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointerout":
         item.removeEventListener("mouseout", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointerenter":
         item.removeEventListener("mouseenter", function (evt) { generateMouseProxy(evt, eventName); });
         break;
        case "pointerleave":
         item.removeEventListener("mouseleave", function (evt) { generateMouseProxy(evt, eventName); });
         break;
    }
};

Расширение объекта Navigator

Согласно спецификации объект навигатора должен обеспечивать два свойства:

  • pointerEnabled, чтобы указать, будет ли браузер запускать события указателя для ввода точки. Для Hand.js он вернет true.
  • maxTouchPoints указывают максимальное количество одновременных сенсорных контактов, поддерживаемых устройством. Увы, только Internet Explorer 10 поддерживает эту функцию. Для других браузеров я не смог найти API для этого значения.

Ради спецификации я добавил следующий код в hand.js :

// Extension to navigator
if (navigator.pointerEnabled === undefined) {

    // Indicates if the browser will fire pointer events for pointing input
    navigator.pointerEnabled = true;

    // IE
    if (navigator.msPointerEnabled) {
        navigator.maxTouchPoints = navigator.msMaxTouchPoints;
    }
}

Интеграция css-правила «touch-action»

Правило CSS сенсорного действия определяет, может ли сенсорный ввод вызывать поведение по умолчанию, предоставляемое пользовательским агентом. Это включает, но не ограничивается ими, такие как панорамирование или масштабирование.

Если вы хотите удалить поведение по умолчанию, вы можете использовать это правило следующим образом (или, очевидно, версию для конкретного поставщика):

#plainCanvas {
    position:absolute;
    left:10%;
    top:10%;
    width:80%;
    height:80%;
    touch-action:none;
}

Hand.js должен определить, определил ли пользователь это правило. Для этого мы должны обрабатывать как встроенные, так и ссылочные стили. И мы должны принять во внимание, что браузеры будут удалять неизвестные правила CSS (например, правило сенсорного действия). Поэтому мы не можем использовать свойство th stylesheets документа (поскольку оно содержит только допустимые правила).

Чтобы получить нефильтрованные стили, мы должны использовать этот код, который загружает исходный файл CSS или получает внутренний HTML для встроенных стилей:

// Handling touch-action css rule
if (document.styleSheets) {
    document.addEventListener("DOMContentLoaded", function () {

        var trim = function (string) {
            return string.replace(/^\s+|\s+$/, '');
        }


        // Looking for touch-action in referenced stylesheets
        for (var index = 0; index < document.styleSheets.length; index++) {
            var sheet = document.styleSheets[index];

            if (sheet.href == undefined) { // it is an inline style
                continue;
            }

            // Loading the original stylesheet
            var xhr = new XMLHttpRequest();
            xhr.open("get", sheet.href, false);
            xhr.send();

            var unfilteredSheet = xhr.responseText.replace(/(\n|\r)/g, "");

            processStylesheet(unfilteredSheet);
        }

        // Looking for touch-action in inline styles
        var styles = document.getElementsByTagName("style");
        for (var index = 0; index < styles.length; index++) {
            var sheet = styles[index];

            var unfilteredSheet = trim(sheet.innerHTML.replace(/(\n|\r)/g, ""));

            processStylesheet(unfilteredSheet);
        }
    }, false);
}

Функция processStylesheet использует регулярные выражения, чтобы определить, присутствует ли строка «touch-action: none» в стиле:

var processStylesheet = function(unfilteredSheet) {
    var globalRegex = new RegExp(".+?{.*?}", "m");
    var selectorRegex = new RegExp(".+?{", "m");

    while (unfilteredSheet != "") {
        var block = globalRegex.exec(unfilteredSheet)[0];
        unfilteredSheet = trim(unfilteredSheet.replace(block, ""));
        var selectorText = trim(selectorRegex.exec(block)[0].replace("{", ""));

        // Checking if the user wanted to deactivate the default behavior
        if (block.replace(/\s/g, "").indexOf("touch-action:none") != -1) {
            var elements = document.querySelectorAll(selectorText);

            for (var elementIndex = 0; elementIndex < elements.length; elementIndex++) {
                var element = elements[elementIndex];

                if (element.style.msTouchAction !== undefined) {
                    element.style.msTouchAction = "none";
                }
                else {
                    element.handjs_forcePreventDefault = true;
                }
            }
        }
    }
}

Handjs_forcePreventDefault — это логическое значение, используемое generateTouchClonedEvent, чтобы определить, нужно ли принудительно устанавливать warnDefault:

// If force preventDefault
if (sourceEvent.currentTarget.handjs_forcePreventDefault === true)
    evObj.preventDefault();

Финальный код

Finally the complete code of Hand.js can be downloaded here: http://handjs.codeplex.com

Feel free to use this code for your own web pages in order to be able to handle touch and mouse in a unified way leveraging the Pointer Events model.

In order to further help the discussions in the W3C Pointer Events Working Group, we would love to hear from you on the specification itself. Do not hesitate to comment on this post and I will forward!