Указатели и касания
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Мышь, палец и стилус делают одно и то же — тыкают в страницу, но исторически рассказывали об этом разными событиями. PointerEvent свёл их в один набор: обработчик пишется один раз, а вопрос «чем именно нажали» решается одним свойством.
Раньше интерфейс писали дважды: mousedown/mousemove/mouseup для десктопа и touchstart/touchmove/touchend для телефона. Логика одна, код разный, а на ноутбуке с сенсорным экраном оба набора приходили одновременно и мешали друг другу.
Pointer-события — общий язык. Указателем считается всё, чем можно ткнуть в экран: курсор мыши, палец, перо. У каждого события есть номер указателя pointerId, тип pointerType, сила нажатия pressure и признак основного указателя isPrimary — на мультитаче первым пальцем.
| Pointer-событие | Аналог у мыши | Когда приходит |
|---|---|---|
| pointerdown | mousedown | палец коснулся, кнопка нажата, перо опустилось |
| pointermove | mousemove | указатель поехал по элементу |
| pointerup | mouseup | отпустили |
| pointercancel | нет аналога | система забрала жест: начался скролл, свайп назад, звонок |
| pointerenter / pointerleave | mouseenter / mouseleave | указатель вошёл в элемент или покинул его |
Любое перетаскивание устроено одинаково: pointerdown запоминает старт, pointermove считает смещение, pointerup закрывает жест. Между down и up держите флаг: без него pointermove будет срабатывать на простое движение мыши над элементом.
Если указатель уезжает за границы элемента, события перестают до него доходить. Лечится это захватом: element.setPointerCapture(event.pointerId) внутри pointerdown — дальше все события этого указателя приходят вашему элементу, где бы палец ни был. Отпускать захват вручную не нужно, браузер снимает его на pointerup.
На телефоне браузер в любой момент может решить, что ваш жест — это скролл или системный свайп. Тогда он забирает указатель себе и присылает pointercancel вместо pointerup. Обработчика на pointerup в этом случае не будет никогда, и элемент останется в состоянии «меня тащат».
Правило простое: завершающий код вешается сразу на два события — pointerup и pointercancel. Одна общая функция, две подписки.
Свойство pointerType возвращает строку: "mouse", "pen", "touch", а для незнакомого устройства — пустую строку или значение производителя. Поэтому надёжнее проверять на равенство "mouse", чем перечислять все сенсорные варианты: неизвестное устройство почти наверняка ближе к пальцу, чем к курсору.
Разветвлять всю логику по типу не надо — жест один. Разными бывают детали: подсказка про наведение бессмысленна без курсора, зона попадания под палец нужна крупнее, а у пера есть наклон и настоящее давление в pressure.
Одно касание разворачивается для страницы в целую очередь. Сначала идут pointer-события, потом — совместимые мышиные, которые браузер придумывает для старого кода, и только в самом конце click. Он приходит последним всегда, и на телефоне между касанием и им может пройти заметная пауза.
Это не повод отказываться от click: он же срабатывает от клавиатуры и от программ чтения с экрана, поэтому для кнопок и ссылок остаётся правильным выбором. А вот мгновенную реакцию — подсветку, начало перетаскивания, звук — вешают на pointerdown. Главное не делать одно и то же действие в обоих обработчиках, иначе оно выполнится дважды.
На ранних мобильных браузерах двойной тап увеличивал масштаб. Чтобы отличить первый тап от начала двойного, браузер после отпускания ждал около 300 мс и лишь потом слал click. Интерфейс от этого ощущался вязким, и целые библиотеки существовали только ради обхода этой паузы.
Сейчас ждать не нужно: если страница объявляет <meta name="viewport" content="width=device-width"> или элементу задан touch-action: manipulation, масштабирования двойным тапом нет и задержку убирают. Порядок событий при этом не меняется — click по-прежнему последний.
Пока обработчик touchstart, touchmove или wheel не отработал, браузер не знает, не вызовете ли вы preventDefault, и не решается двигать страницу. Один медленный обработчик — и скролл дёргается на каждом кадре.
Третий аргумент addEventListener снимает эту неопределённость: { passive: true } — обещание не отменять действие по умолчанию, и браузер начинает прокрутку сразу. На window, document и body эти события считаются passive по умолчанию, на обычном элементе — нет. Если отмена действительно нужна, ставьте { passive: false } явно, а ещё лучше запретите жест стилем touch-action — браузеру не придётся ждать вообще.
passive: true — это обещание, а не пожелание. Вызванный внутри такого обработчика preventDefault браузер проигнорирует, а в консоль напишет ошибку «Unable to preventDefault inside passive event listener invocation». Нужен запрет — снимайте passive явно.
- Pointer-события покрывают мышь, палец и перо одним набором обработчиков — писать логику дважды больше не нужно.
- Жест — это pointerdown, серия pointermove и pointerup, но вместо pointerup может прийти pointercancel: завершение вешают на оба события.
- pointerType говорит, чем нажали; проверяйте на равенство "mouse", а не перечисляйте сенсорные варианты.
- На касании click приходит последним, после pointer- и совместимых mouse-событий; мгновенную реакцию вешают на pointerdown, а click оставляют ради клавиатуры и скринридеров.
- { passive: true } — обещание не вызывать preventDefault: скролл стартует сразу, а сам preventDefault в таком обработчике молча игнорируется.
1/3Пользователь начал тянуть карточку пальцем, но браузер решил, что это скролл, и забрал жест себе. Какое событие придёт вашему обработчику?
2/3Что вернёт event.pointerType, если по элементу постучали пальцем?
3/3Внутри обработчика, добавленного с { passive: true }, вызвали event.preventDefault(). Что произойдёт?
Перетаскивание, которое переживает pointercancel
Допишите обработчики так, чтобы moves собирал смещения относительно точки старта, а после pointercancel движение переставало учитываться. Подсказка: нужны флаг состояния и общая функция завершения на два события.
Состояние жеста живёт в переменной dragging, а завершение — одна функция на pointerup и pointercancel. Последнее движение приходит уже со снятым флагом и в массив не попадает.
Сколько пальцев на экране
Каждый указатель приходит со своим pointerId. Посчитайте, сколько их было прижато к элементу одновременно в пике, и запишите это число в max. Не забудьте, что палец может исчезнуть и через pointercancel.
Идентификаторы удобно держать в Set: добавляем на pointerdown, убираем на pointerup и pointercancel. После первого отпускания на паде остаются указатели 2, 3 и 4 — это и есть пик.