
Столкнувшись с задачей реализовать простой графический редактор в мобильной версии сайта, я обнаружил, что функционал мультитач жестов еще не реализован на уровне браузера.
В 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) и покажу как реализовать аналоги этих жестов в десктопной версии.