Пошук уроків, статей та іншого контенту
Розберете Pointer Events для миші, сенсорного екрана й пера та навчитеся працювати з координатами.
Pointer Events — це універсальний API для роботи з різними вказівними пристроями:
мишею;
сенсорним екраном;
стилусом або пером.
Замість окремих обробників для mousedown, touchstart і подібних подій можна використовувати єдиний набір подій:
element.addEventListener("pointerdown", handler);PointerEventНайчастіше використовують такі події:
pointerover — вказівник зайшов на елемент;
pointerenter — вказівник увійшов на елемент, без спливання від дочірніх елементів;
pointermove — вказівник переміщується;
pointerdown — натиснуто кнопку миші, торкнутося екрана або пера;
pointerup — кнопку відпущено або контакт із сенсорним екраном завершено;
pointercancel — браузер або операційна система перервали взаємодію;
pointerout — вказівник залишив елемент;
pointerleave — вказівник залишив елемент, без спливання від дочірніх елементів.
Події pointerdown, pointermove та pointerup зазвичай утворюють основний цикл взаємодії:
pointerdown → pointermove → pointermove → pointerupДля сенсорного екрана послідовність може виглядати так само, хоча фізично це буде дотик, а не натискання кнопки миші.
Обробник Pointer Event отримує об’єкт події з властивостями, специфічними для вказівника.
element.addEventListener("pointerdown", (event) => {
console.log(event.pointerType);
console.log(event.clientX, event.clientY);
});Властивість pointerType має одне з типових значень:
"mouse" — миша;
"touch" — сенсорний екран;
"pen" — перо або стилус.
element.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse") {
console.log("Взаємодія мишею");
} else if (event.pointerType === "touch") {
console.log("Взаємодія дотиком");
} else if (event.pointerType === "pen") {
console.log("Взаємодія пером");
}
});Не варто будувати всю логіку застосунку лише на pointerType. Основна перевага Pointer Events саме в тому, що одна й та сама логіка може працювати для всіх пристроїв.
pointerId — числовий ідентифікатор конкретного вказівника.
Він особливо важливий під час роботи з кількома одночасними дотиками. Наприклад, два пальці на сенсорному екрані матимуть різні pointerId.
element.addEventListener("pointerdown", (event) => {
console.log(`Почався контакт: ${event.pointerId}`);
});
element.addEventListener("pointerup", (event) => {
console.log(`Завершився контакт: ${event.pointerId}`);
});isPrimary дорівнює true для основного вказівника:
для миші — зазвичай єдиного вказівника;
для першого пальця під час мультитач-взаємодії;
для основного пера.
element.addEventListener("pointerdown", (event) => {
if (event.isPrimary) {
console.log("Це основний вказівник");
}
});Цю властивість можна використати, якщо компонент має реагувати лише на перший контакт ігноруючи додаткові пальці.
Одна з найважливіших частин роботи з Pointer Events — правильний вибір системи координат.
clientX і clientYЦі координати визначають положення вказівника відносно видимої області браузера, тобто viewport.
document.addEventListener("pointermove", (event) => {
console.log(event.clientX, event.clientY);
});Якщо сторінку прокрутити, clientX і clientY не враховують прокручування. Вони показують позицію відносно поточного вікна перегляду.
Це зручно для:
позиціонування елементів поверх viewport;
визначення положення курсора щодо видимої області;
взаємодії з елементом, який також вимірюють через getBoundingClientRect().
pageX і pageYЦі координати визначають положення відносно всієї сторінки та враховують прокручування документа.
document.addEventListener("pointermove", (event) => {
console.log(event.pageX, event.pageY);
});Зазвичай:
pageX = clientX + window.scrollX
pageY = clientY + window.scrollYЯкщо ви малюєте на сторінці, яка може прокручуватися, pageX і pageY часто зручніші для збереження координат у системі документа.
screenX і screenYЦі координати визначають положення вказівника відносно екрана пристрою, а не вебсторінки.
document.addEventListener("pointermove", (event) => {
console.log(event.screenX, event.screenY);
});У звичайних вебкомпонентах вони використовуються рідше.
offsetX і offsetYЦі координати приблизно показують положення вказівника щодо цільового елемента.
element.addEventListener("pointermove", (event) => {
console.log(event.offsetX, event.offsetY);
});Проте offsetX і offsetY можуть поводитися неочікувано, якщо в елемента є дочірні елементи або складна структура. Для точного перетворення координат краще використовувати clientX, clientY і getBoundingClientRect().
Щоб отримати координати всередині елемента, спочатку отримайте його прямокутник:
const rectangle = element.getBoundingClientRect();
const x = event.clientX - rectangle.left;
const y = event.clientY - rectangle.top;Так ми переводимо координати viewport у локальні координати елемента.
Якщо елемент має внутрішні відступи або змінений розмір, координати все одно залишаються відносними до його зовнішнього прямокутника. Для canvas це особливо важливо.
Якщо розмір canvas у CSS відрізняється від його внутрішнього розміру, потрібно врахувати масштаб:
const rectangle = canvas.getBoundingClientRect();
const scaleX = canvas.width / rectangle.width;
const scaleY = canvas.height / rectangle.height;
const x = (event.clientX - rectangle.left) * scaleX;
const y = (event.clientY - rectangle.top) * scaleY;Під час перетягування вказівник може залишити межі елемента. У такому разі елемент може перестати отримувати pointermove або pointerup.
Для цього використовується метод setPointerCapture():
element.setPointerCapture(event.pointerId);Після захоплення всі наступні події цього вказівника надходитимуть вибраному елементу, навіть якщо вказівник перемістився за його межі.
Захоплення зазвичай встановлюють у pointerdown, а відпускають у pointerup або pointercancel:
element.addEventListener("pointerdown", (event) => {
element.setPointerCapture(event.pointerId);
});
element.addEventListener("pointerup", (event) => {
element.releasePointerCapture(event.pointerId);
});На практиці releasePointerCapture() часто можна не викликати вручну: браузер звільняє захоплення після завершення взаємодії. Але явне звільнення робить намір коду зрозумілішим.
touch-actionНа сенсорних пристроях браузер може використовувати жест для прокручування, масштабування або навігації замість передавання подій вашому компоненту.
Властивість CSS touch-action повідомляє браузеру, які жести дозволені на елементі:
.canvas {
touch-action: none;
}Значення none забороняє стандартні сенсорні жести на цьому елементі. Це часто потрібно для:
малювання;
перетягування;
власних жестів;
ігор;
інтерактивних полотен.
Інші корисні значення:
/* Дозволити вертикальне прокручування, але заборонити горизонтальне */
.slider {
touch-action: pan-y;
}
/* Дозволити горизонтальне прокручування */
.carousel {
touch-action: pan-x;
}
/* Дозволити стандартну поведінку браузера */
.default-area {
touch-action: auto;
}touch-action потрібно задавати CSS-правилом заздалегідь. Виклик preventDefault() у pointermove не замінює правильне налаштування touch-action.
Розглянемо повний приклад перетягування блока. Він працює з мишею, дотиком і пером.
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Перетягування через Pointer Events</title>
<style>
body {
margin: 0;
min-height: 100vh;
font-family: sans-serif;
background: #f1f5f9;
}
.workspace {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
touch-action: none;
}
.box {
position: absolute;
width: 120px;
height: 120px;
border-radius: 16px;
background: #2563eb;
cursor: grab;
user-select: none;
}
.box.dragging {
cursor: grabbing;
}
.info {
position: fixed;
top: 16px;
left: 16px;
padding: 8px 12px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 10px rgb(0 0 0 / 15%);
}
</style>
</head>
<body>
<div class="info">Перетягніть синій блок</div>
<div class="workspace">
<div class="box"></div>
</div>
<script>
const workspace = document.querySelector(".workspace");
const box = document.querySelector(".box");
const info = document.querySelector(".info");
let isDragging = false;
let activePointerId = null;
let offsetX = 0;
let offsetY = 0;
box.addEventListener("pointerdown", (event) => {
// Реагуємо лише на основний вказівник
if (!event.isPrimary) {
return;
}
const boxRectangle = box.getBoundingClientRect();
isDragging = true;
activePointerId = event.pointerId;
offsetX = event.clientX - boxRectangle.left;
offsetY = event.clientY - boxRectangle.top;
// Продовжуємо отримувати події, навіть коли вказівник залишить блок
box.setPointerCapture(activePointerId);
box.classList.add("dragging");
info.textContent = `Тип вказівника: ${event.pointerType}`;
});
box.addEventListener("pointermove", (event) => {
if (!isDragging || event.pointerId !== activePointerId) {
return;
}
const workspaceRectangle = workspace.getBoundingClientRect();
const left = event.clientX - workspaceRectangle.left - offsetX;
const top = event.clientY - workspaceRectangle.top - offsetY;
box.style.left = `${left}px`;
box.style.top = `${top}px`;
});
function stopDragging(event) {
if (event.pointerId !== activePointerId) {
return;
}
isDragging = false;
activePointerId = null;
box.classList.remove("dragging");
// Відпускаємо захоплення, якщо воно ще встановлене
if (box.hasPointerCapture(event.pointerId)) {
box.releasePointerCapture(event.pointerId);
}
info.textContent = "Перетягніть синій блок";
}
box.addEventListener("pointerup", stopDragging);
box.addEventListener("pointercancel", stopDragging);
</script>
</body>
</html>У pointerdown зберігається pointerId активного контакту.
Обчислюється відстань від точки натискання до верхнього лівого кута блока.
Встановлюється захоплення вказівника.
У pointermove положення блока оновлюється відповідно до координат вказівника.
У pointerup або pointercancel перетягування завершується.
Збереження offsetX і offsetY потрібне для того, щоб блок не «стрибав» і не переміщував свій верхній лівий кут під курсор після початку перетягування.
Pointer Events зручно використовувати для малювання. У цьому прикладі координати події перетворюються на координати canvas.
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Малювання через Pointer Events</title>
<style>
body {
margin: 0;
padding: 24px;
font-family: sans-serif;
}
canvas {
display: block;
width: min(800px, 100%);
height: 400px;
border: 1px solid #94a3b8;
touch-action: none;
background: white;
}
</style>
</head>
<body>
<canvas id="drawing" width="800" height="400"></canvas>
<script>
const canvas = document.querySelector("#drawing");
const context = canvas.getContext("2d");
let isDrawing = false;
function getCanvasPoint(event) {
const rectangle = canvas.getBoundingClientRect();
// Враховуємо можливу різницю між CSS-розміром і внутрішнім розміром canvas
const scaleX = canvas.width / rectangle.width;
const scaleY = canvas.height / rectangle.height;
return {
x: (event.clientX - rectangle.left) * scaleX,
y: (event.clientY - rectangle.top) * scaleY
};
}
canvas.addEventListener("pointerdown", (event) => {
isDrawing = true;
canvas.setPointerCapture(event.pointerId);
const point = getCanvasPoint(event);
context.beginPath();
context.moveTo(point.x, point.y);
});
canvas.addEventListener("pointermove", (event) => {
if (!isDrawing) {
return;
}
const point = getCanvasPoint(event);
context.lineTo(point.x, point.y);
context.stroke();
});
function stopDrawing(event) {
isDrawing = false;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
}
canvas.addEventListener("pointerup", stopDrawing);
canvas.addEventListener("pointercancel", stopDrawing);
</script>
</body>
</html>Під час малювання на canvas варто враховувати:
canvas.width і canvas.height — внутрішній розмір полотна;
CSS-властивості width і height — розмір, у якому його показано;
координати clientX і clientY спочатку належать viewport;
getBoundingClientRect() дає положення canvas у viewport.
Для пера Pointer Events можуть містити додаткові характеристики:
pressure — тиск, число від 0 до 1;
tangentialPressure — тангенціальний тиск, якщо пристрій його підтримує;
tiltX — нахил пера відносно осі X;
tiltY — нахил пера відносно осі Y;
twist — обертання пера навколо власної осі;
altitudeAngle — кут підняття пера над поверхнею;
azimuthAngle — напрямок пера.
Наприклад, товщину лінії можна пов’язати з тиском:
canvas.addEventListener("pointermove", (event) => {
if (!isDrawing) {
return;
}
const point = getCanvasPoint(event);
// Для миші pressure часто має фіксоване значення
const pressure = event.pressure || 0.5;
context.lineWidth = 2 + pressure * 12;
context.lineTo(point.x, point.y);
context.stroke();
});Значення pressure залежить від пристрою. Тому програма має коректно працювати й тоді, коли пристрій не підтримує визначення тиску.
buttonsВластивість button описує кнопку, яка спричинила поточну подію:
0 — основна кнопка;
1 — середня кнопка;
2 — допоміжна кнопка;
-1 — для подій руху, коли кнопка не змінилася.
Властивість buttons — це бітова маска всіх кнопок, які зараз натиснуті.
Для перевірки основної кнопки можна використати:
element.addEventListener("pointermove", (event) => {
if (event.buttons & 1) {
console.log("Основна кнопка натиснута");
}
});Для більшості компонентів достатньо перевіряти стан, який ви зберегли після pointerdown. Але buttons корисна, коли потрібно реагувати на рух із затиснутою кнопкою.
pointerover і pointerenterЦі події схожі, але мають різну поведінку щодо дочірніх елементів.
pointerover спливає та може виникати знову під час переходу між батьківським і дочірнім елементами.
pointerenter не спливає і зазвичай викликається один раз під час входу в межі елемента.
Аналогічна відмінність існує між:
pointerout;
pointerleave.
Для простого наведення часто зручніше використовувати pointerenter і pointerleave:
const card = document.querySelector(".card");
card.addEventListener("pointerenter", () => {
card.classList.add("active");
});
card.addEventListener("pointerleave", () => {
card.classList.remove("active");
});У деяких сценаріях потрібно скасувати стандартну дію браузера:
element.addEventListener("pointerdown", (event) => {
event.preventDefault();
});Наприклад, це може бути необхідно для власного перетягування або малювання. Проте не слід викликати preventDefault() без потреби: це може перешкодити виділенню тексту, прокручуванню або іншим очікуваним діям.
Для сенсорних жестів спочатку правильно налаштуйте CSS:
.interactive-area {
touch-action: none;
}Старий підхід часто виглядає так:
element.addEventListener("mousedown", start);
element.addEventListener("touchstart", start);
element.addEventListener("mousemove", move);
element.addEventListener("touchmove", move);У ньому потрібно окремо обробляти різні формати подій, а для touch-подій координати мають іншу структуру.
З Pointer Events логіка єдина:
element.addEventListener("pointerdown", start);
element.addEventListener("pointermove", move);
element.addEventListener("pointerup", stop);
element.addEventListener("pointercancel", stop);Це спрощує код і зменшує кількість платформозалежних умов.
pointerupЯкщо елемент не захопив вказівник, користувач може відпустити кнопку вже за межами елемента. Тоді елемент не завжди отримає очікувану подію.
Для перетягування використовуйте:
element.setPointerCapture(event.pointerId);pointercancelВзаємодія може завершитися не через pointerup. Наприклад, операційна система може перервати жест або браузер може змінити обробку вводу.
Стан компонента потрібно очищати і в pointerup, і в pointercancel.
pointerIdПід час мультитач-взаємодії події можуть надходити від кількох вказівників. Якщо компонент працює лише з одним контактом, зберігайте його pointerId і перевіряйте його в наступних подіях.
offsetX і offsetYЦі координати залежать від цільового елемента події та можуть бути незручними для вкладених елементів. Для надійного перетворення використовуйте:
const rectangle = element.getBoundingClientRect();
const x = event.clientX - rectangle.left;
const y = event.clientY - rectangle.top;touch-actionБез touch-action: none сенсорний браузер може сприйняти рух як прокручування сторінки, а не як малювання чи перетягування.
pressure завжди різнийМиша часто не передає реальний тиск. Якщо логіка залежить від pressure, передбачте значення за замовчуванням.
preventDefault()Скасування стандартної поведінки може зробити інтерфейс незручним. Скасовуйте подію лише там, де це справді потрібно.
Створіть інтерактивний слайдер, який:
Переміщується мишею, дотиком і пером.
Захоплює вказівник після pointerdown.
Не втрачає перетягування, коли вказівник виходить за межі повзунка.
Використовує touch-action: none.
Виводить у консоль pointerType, pointerId і поточні координати.
Коректно завершує взаємодію після pointerup та pointercancel.
Pointer Events уніфікують роботу з мишею, сенсорним екраном і пером.
Основні події взаємодії — pointerdown, pointermove, pointerup і pointercancel.
pointerType визначає тип пристрою, а pointerId ідентифікує конкретний контакт.
clientX і clientY задають координати у viewport.
pageX і pageY враховують прокручування сторінки.
Для локальних координат елемента зручно використовувати getBoundingClientRect().
setPointerCapture() допомагає не втрачати події під час перетягування.
Для власних сенсорних жестів потрібно правильно налаштовувати touch-action.
Для пера можна використовувати pressure, tiltX, tiltY та інші властивості.
Обробляйте не лише pointerup, а й pointercancel.