Указатели и касания

ввод≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Мышь, палец и стилус делают одно и то же — тыкают в страницу, но исторически рассказывали об этом разными событиями. PointerEvent свёл их в один набор: обработчик пишется один раз, а вопрос «чем именно нажали» решается одним свойством.

Разделы темы:·

Раньше интерфейс писали дважды: mousedown/mousemove/mouseup для десктопа и touchstart/touchmove/touchend для телефона. Логика одна, код разный, а на ноутбуке с сенсорным экраном оба набора приходили одновременно и мешали друг другу.

Pointer-события — общий язык. Указателем считается всё, чем можно ткнуть в экран: курсор мыши, палец, перо. У каждого события есть номер указателя pointerId, тип pointerType, сила нажатия pressure и признак основного указателя isPrimary — на мультитаче первым пальцем.

Pointer-событиеАналог у мышиКогда приходит
pointerdownmousedownпалец коснулся, кнопка нажата, перо опустилось
pointermovemousemoveуказатель поехал по элементу
pointerupmouseupотпустили
pointercancelнет аналогасистема забрала жест: начался скролл, свайп назад, звонок
pointerenter / pointerleavemouseenter / mouseleaveуказатель вошёл в элемент или покинул его
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Любое перетаскивание устроено одинаково: pointerdown запоминает старт, pointermove считает смещение, pointerup закрывает жест. Между down и up держите флаг: без него pointermove будет срабатывать на простое движение мыши над элементом.

Если указатель уезжает за границы элемента, события перестают до него доходить. Лечится это захватом: element.setPointerCapture(event.pointerId) внутри pointerdown — дальше все события этого указателя приходят вашему элементу, где бы палец ни был. Отпускать захват вручную не нужно, браузер снимает его на pointerup.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На телефоне браузер в любой момент может решить, что ваш жест — это скролл или системный свайп. Тогда он забирает указатель себе и присылает pointercancel вместо pointerup. Обработчика на pointerup в этом случае не будет никогда, и элемент останется в состоянии «меня тащат».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило простое: завершающий код вешается сразу на два события — pointerup и pointercancel. Одна общая функция, две подписки.

Свойство pointerType возвращает строку: "mouse", "pen", "touch", а для незнакомого устройства — пустую строку или значение производителя. Поэтому надёжнее проверять на равенство "mouse", чем перечислять все сенсорные варианты: неизвестное устройство почти наверняка ближе к пальцу, чем к курсору.

Разветвлять всю логику по типу не надо — жест один. Разными бывают детали: подсказка про наведение бессмысленна без курсора, зона попадания под палец нужна крупнее, а у пера есть наклон и настоящее давление в pressure.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одно касание разворачивается для страницы в целую очередь. Сначала идут pointer-события, потом — совместимые мышиные, которые браузер придумывает для старого кода, и только в самом конце click. Он приходит последним всегда, и на телефоне между касанием и им может пройти заметная пауза.

Это не повод отказываться от click: он же срабатывает от клавиатуры и от программ чтения с экрана, поэтому для кнопок и ссылок остаётся правильным выбором. А вот мгновенную реакцию — подсветку, начало перетаскивания, звук — вешают на pointerdown. Главное не делать одно и то же действие в обоих обработчиках, иначе оно выполнится дважды.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На ранних мобильных браузерах двойной тап увеличивал масштаб. Чтобы отличить первый тап от начала двойного, браузер после отпускания ждал около 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 — браузеру не придётся ждать вообще.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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 движение переставало учитываться. Подсказка: нужны флаг состояния и общая функция завершения на два события.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Состояние жеста живёт в переменной dragging, а завершение — одна функция на pointerup и pointercancel. Последнее движение приходит уже со снятым флагом и в массив не попадает.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сколько пальцев на экране

Каждый указатель приходит со своим pointerId. Посчитайте, сколько их было прижато к элементу одновременно в пике, и запишите это число в max. Не забудьте, что палец может исчезнуть и через pointercancel.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Идентификаторы удобно держать в Set: добавляем на pointerdown, убираем на pointerup и pointercancel. После первого отпускания на паде остаются указатели 2, 3 и 4 — это и есть пик.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.