
Столкнувшись с задачей реализовать простой графический редактор в мобильной версии сайта, я обнаружил, что функционал мультитач жестов еще не реализован на уровне браузера.
В mdn я нашел Api gesture events, но оно не поддерживается примерно нигде.

https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent
Начнем мы с вращения и, если тема будет интересна, я распишу, как реализовать scale и drag.
Для реализации этого функционала нам потребуется расчехлить тригонометрию.
Сначала попробуем выяснить, что представляет из себя наш жест с точки зрения js и с какими данными нам нужно работать.
Тестовый стенд
Адекватного способа тестировать мультитач в десктопном браузере я не нашел (если вы такой знаете, напишите в комментах), поэтому пользовался эмулятором iphone c подключенной консолью разработчика safari.
Точно также можно использовать эмулятор android вместе с chrome. Однако я бы советовал не забывать проверять ваше решение на настоящем девайсе.
Напишем немного кода:
HTML
<div id="rect"></div>
CSS
#rect { background-color: red; width: 500px; height: 500px; }
JS
const rect = document.getElementById("rect"); // Начало прикосновения rect.addEventListener("touchstart", (e) => { // Чтобы убрать нежелательное смещение экрана при тачмуве e.preventDefault(); console.log(e); }); // Каждый акт движения пальцами rect.addEventListener("touchmove", (e) => { e.preventDefault(); console.log(e); });
При попытке покрутить наш прямоугольник мы увидем множество событий TouchEvent

Нам необходимо получить список нажатий из каждого TouchEvent, и у нас есть 3 варианта:
.touches - положение всех прикосновений пальцев
.targetTouches - прикосновения внутри нашего div
.changedTouches - прикосновения внутри элемента, которые изменили свое положение
Каждое из этих свойств содержит объект TouchList, состоящий из объектов Touch, которые нас и интересуют
Для нашей задачи лучше всего подходит targetTouches

Каждый объект Touch содержит набор координат вида: clientX, pageY, итп
Разница между client и page в том, что первый отсчитываться от текущего viewport (края экрана), а второй от начала страницы.
В нашем случае особой разницы нет, так как для нас будет важна дельта.
Достанем все полезные данные из targetTouches
rect.addEventListener("touchmove", (e) => { e.preventDefault(); // e.targetTouches это не массив const touches = Array.prototype.map.call(e.targetTouches, (t) => { return { x: t.clientX, y: t.clientY, }; }); console.log(touches); });
targetTouches это не настоящий массив, поэтому чтобы обработать его с помощью map, нам придется совершить маленькую хитрость с помощью call

Итак, вводные данные у нас есть, теперь подключаем математику, чтобы найти решение нашей задачи.
М - математика
Дано: 2 точки, в которых пользователь касается объекта.
[x1,y1] [x2,y2]

Нам нужно найти угол, на который пользователь повернул воображаемую линию между 2мя точками, и спроецировать его на поворачиваемый объект.
На иллюстрации справа у нас образовался прямоугольный треугольник. А это значит, что мы можем пользоваться соответствующими формулами для поиска интересующего нас угла.

Вспоминаем школьную тригонометрию.
Тангенсом острого угла прямоугольного треугольника называется отношение противолежащего этому углу катета к прилежащему катету.
tg B = AC/CB
Чтобы найти B нам нужна обратная тригонометрическая функция тангенса, а именно арктангенс. Однако эта функция имеет некоторые ограничения, поэтому в нашем случае нужно будет взять atan2.
Для тех кто хочет погрузится глубже, в википедии есть вполне подробное объяснение https://en.wikipedia.org/wiki/Atan2
Получем:
B = atan2(AC, AB)
Реализуем вращение
const angle = Math.atan2( touches[1].y - touches[0].y, touches[1].x - touches[0].x );
Добавим проверку, что у нас есть 2 касания на экране и модифицируем стили нашего квадрата добавив трансформацию с поворотом:
rect.addEventListener("touchmove", (e) => { e.preventDefault(); const touches = Array.prototype.map.call(e.targetTouches, (t) => { return { x: t.clientX, y: t.clientY, }; }); if (touches.length > 1) { // считаем угол между точками const angle = Math.atan2( touches[1].y - touches[0].y, touches[1].x - touches[0].x ); console.log(angle); // поворачиваем наш квадрат rect.style.transform = `rotate(${angle}rad)`; } });
Обратите внимание, что функция atan2 возвращает результат в радианах, а не в привычных градусах.
Почти все, но есть один нюанс. При каждой новой попытке повернуть наш квадрат его предыдущий угол будет сбрасываться и вращение будет некрасиво начинаться с 0 радиан, разрушая всю иммерсивность взаимодействия.

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

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

Вынесем код в функции чтобы избежать дублирования
// Подготовить массив нажатий function prepareTouches(e) { return Array.prototype.map.call(e.targetTouches, (t) => { return { x: t.clientX, y: t.clientY, }; }); } // Считаем угол между точками function calculatePointersAngle(touches) { return Math.atan2(touches[1].y - touches[0].y, touches[1].x - touches[0].x); }
Определим переменные, в которых будем хранить промежуточные данные
// Угол поворта квадрата let angle = 0; // Угол между пальцами let pointersAngle = 0;
В момент прикосновения посчитаем изначальный угол между точками
rect.addEventListener("touchstart", (e) => { e.preventDefault(); const touches = prepareTouches(e); if (touches.length > 1) { pointersAngle = calculatePointersAngle(touches); } });
А теперь доработаем нашу функцию обработки touchmove
rect.addEventListener("touchmove", (e) => { e.preventDefault(); const touches = prepareTouches(e); if (touches.length > 1) { // Убираем предыдущий угол между нажатиями из расчета angle -= pointersAngle; // Считаем текущий угол между нажатиями и сохраняем pointersAngle = calculatePointersAngle(touches); // Добавляем текущий угол к предыдущему значению угла angle += pointersAngle; rect.style.transform = `rotate(${angle}rad)`; } });
На этом все, посмотреть весь код и потыкать можно тут.
В следующих частях, если тема будет интересна, я реализую оставшиеся жесты (scale, drag) и покажу как реализовать аналоги этих жестов в десктопной версии.
