← JavaScript/Pointer Events#150 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Pointer Events

Представь: ты делаешь интерактивную карту или редактор с перетаскиванием. На десктопе работает мышь, на планшете — стилус, на телефоне — пальцы. Раньше нужно было писать три набора обработчиков. Pointer Events — унифицированный API, который работает для всех устройств одновременно.

Что решает этот механизм

Pointer Events объединяет mouse-события и touch-события в единый API. Один обработчик pointerdown заменяет mousedown + touchstart. Дополнительно API добавляет захват указателя (setPointerCapture) и поддержку давления стилуса — возможности, которых не было в старых API.

На основе предыдущих уроков

  • события мыши — Pointer Events это надмножество mouse-событий, те же свойства clientX/clientY
  • Drag'n'Drop — setPointerCapture решает проблему «выскальзывания» курсора при перетаскивании
  • Зачем нужны Pointer Events

    // Старый подход: отдельные обработчики для каждого устройства
    element.addEventListener('mousedown', onStart)
    element.addEventListener('touchstart', onStart)  // дублирование!
    
    // Новый подход: один обработчик для всех устройств
    element.addEventListener('pointerdown', onStart)

    Основные события

    element.addEventListener('pointerdown',   handler)  // нажатие (кнопка мыши / касание)
    element.addEventListener('pointermove',   handler)  // движение указателя
    element.addEventListener('pointerup',     handler)  // отпускание
    element.addEventListener('pointercancel', handler)  // отмена (звонок во время touch и т.п.)
    element.addEventListener('pointerenter',  handler)  // вход в элемент (без всплытия)
    element.addEventListener('pointerleave',  handler)  // выход из элемента (без всплытия)

    Свойства PointerEvent

    element.addEventListener('pointerdown', (event) => {
      // Идентификатор указателя — уникален для каждого пальца при мультитач
      console.log(event.pointerId)     // число: 1, 2, 3...
    
      // Тип устройства
      console.log(event.pointerType)   // 'mouse', 'touch', 'pen'
    
      // Давление (0 — нет контакта, 1 — максимальное давление)
      // У мыши обычно 0 или 0.5, у стилуса — плавное значение
      console.log(event.pressure)      // 0.0 ... 1.0
    
      // Является ли основным указателем (первый палец при мультитач)
      console.log(event.isPrimary)     // true / false
    
      // Координаты — те же что у MouseEvent
      console.log(event.clientX, event.clientY)
    })

    setPointerCapture — захват указателя

    При drag'n'drop проблема: пользователь двигает мышь быстро и курсор «выскальзывает» из элемента — события перестают приходить.

    setPointerCapture решает это: все события указателя будут приходить на заданный элемент, даже если указатель ушёл за его пределы.

    element.addEventListener('pointerdown', (event) => {
      // Захватить указатель — все pointermove будут приходить сюда
      element.setPointerCapture(event.pointerId)
    })
    
    element.addEventListener('pointermove', (event) => {
      // Срабатывает даже когда курсор за пределами элемента!
      updatePosition(event.clientX, event.clientY)
    })
    
    element.addEventListener('pointerup', (event) => {
      // Захват снимается автоматически при pointerup
      // Или вручную: element.releasePointerCapture(event.pointerId)
    })

    Мультитач — несколько указателей

    const activePointers = new Map()
    
    element.addEventListener('pointerdown', (event) => {
      activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
      console.log(`Активных касаний: ${activePointers.size}`)
    })
    
    element.addEventListener('pointermove', (event) => {
      if (activePointers.has(event.pointerId)) {
        activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
      }
    })
    
    element.addEventListener('pointerup', (event) => {
      activePointers.delete(event.pointerId)
    })

    touch-action CSS

    Если вы обрабатываете pointer-события самостоятельно, отключите браузерную прокрутку через CSS:

    // CSS: touch-action: none;
    // Это предотвращает прокрутку страницы при касании элемента
    // и позволяет корректно обрабатывать pointercancel

    Преимущества над Mouse + Touch Events

    | Возможность | Mouse Events | Touch Events | Pointer Events |

    |---|---|---|---|

    | Мышь | Да | Нет | Да |

    | Тачскрин | Нет | Да | Да |

    | Стилус | Частично | Нет | Да |

    | Давление | Нет | Нет | Да |

    | Мультитач | Нет | Да | Да |

    | Захват | Нет | Нет | Да |

    Типичные ошибки

    1. Не добавить touch-action: none в CSS — браузер перехватывает скролл

    // ПЛОХО — браузер прокручивает страницу вместо твоего обработчика
    element.addEventListener('pointermove', onMove)
    // Пользователь делает свайп, но страница прокручивается → pointercancel!
    
    // ХОРОШО — отключить браузерный скролл через CSS
    // element.style.touchAction = 'none'  // в реальном коде это CSS свойство
    // Или в CSS: .draggable { touch-action: none; }

    2. Не освобождать захват указателя при pointercancel

    // ПЛОХО — если поступил pointercancel (звонок, переключение окна),
    // захват остаётся, элемент «залипает»
    element.addEventListener('pointerdown', e => element.setPointerCapture(e.pointerId))
    element.addEventListener('pointerup', e => element.releasePointerCapture(e.pointerId))
    // pointercancel не обработан!
    
    // ХОРОШО — обрабатывать и pointercancel
    element.addEventListener('pointercancel', e => {
      element.releasePointerCapture(e.pointerId)
      stopDragging()  // сброс состояния
    })

    3. Смешивать pointer и mouse события — двойные срабатывания

    // ПЛОХО — браузер генерирует и pointer, и mouse события
    element.addEventListener('pointerdown', onStart)
    element.addEventListener('mousedown', onStart)  // вызовется дважды для мыши!
    
    // ХОРОШО — использовать только pointer события
    element.addEventListener('pointerdown', onStart)
    // Pointer Events генерируются для всех устройств, mouse-события больше не нужны

    В реальных проектах

  • Figma: canvas обрабатывает рисование через Pointer Events — поддерживает и мышь, и графический планшет с давлением
  • Google Maps, Яндекс.Карты: жесты масштабирования (pinch-to-zoom) реализованы через мультитач Pointer Events
  • Kindle, Apple Books: рисование заметок на iPad используют event.pressure стилуса для толщины линии
  • Примеры

    Симуляция Pointer Events: мультитач трекинг и захват указателя для перетаскивания

    // Симуляция Pointer Events API через mock-объекты
    // В браузере эти события приходят от реальных устройств
    
    function createPointerEvent(type, options = {}) {
      return {
        type,
        pointerId:   options.pointerId   ?? 1,
        pointerType: options.pointerType ?? 'mouse',
        pressure:    options.pressure    ?? (type === 'pointerdown' ? 0.5 : 0),
        isPrimary:   options.isPrimary   ?? true,
        clientX:     options.clientX     ?? 0,
        clientY:     options.clientY     ?? 0,
        preventDefault: () => {},
      }
    }
    
    // --- Демо 1: Определение типа устройства ---
    console.log('=== Тип устройства и давление ===')
    
    const events = [
      createPointerEvent('pointerdown', { pointerType: 'mouse',  pressure: 0.5,  pointerId: 1 }),
      createPointerEvent('pointerdown', { pointerType: 'touch',  pressure: 0.8,  pointerId: 2, isPrimary: true }),
      createPointerEvent('pointerdown', { pointerType: 'touch',  pressure: 0.6,  pointerId: 3, isPrimary: false }),
      createPointerEvent('pointerdown', { pointerType: 'pen',    pressure: 0.95, pointerId: 4 }),
    ]
    
    events.forEach(e => {
      const device = { mouse: 'Мышь', touch: 'Тачскрин', pen: 'Стилус' }[e.pointerType]
      const primary = e.isPrimary ? '(primary)' : '(secondary)'
      console.log(`${device} ${primary}: pointerId=${e.pointerId}, pressure=${e.pressure}`)
    })
    
    // --- Демо 2: Мультитач трекинг ---
    console.log('\n=== Мультитач трекинг ===')
    
    class PointerTracker {
      constructor() {
        this.activePointers = new Map()
      }
    
      onPointerDown(event) {
        this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
        console.log(`pointerdown id=${event.pointerId}: активных касаний: ${this.activePointers.size}`)
      }
    
      onPointerMove(event) {
        if (!this.activePointers.has(event.pointerId)) return
        const prev = this.activePointers.get(event.pointerId)
        const dx = event.clientX - prev.x
        const dy = event.clientY - prev.y
        this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
        console.log(`pointermove id=${event.pointerId}: dx=${dx}, dy=${dy}`)
      }
    
      onPointerUp(event) {
        this.activePointers.delete(event.pointerId)
        console.log(`pointerup id=${event.pointerId}: активных касаний: ${this.activePointers.size}`)
      }
    }
    
    const tracker = new PointerTracker()
    
    // Симулируем два пальца на тачскрине
    tracker.onPointerDown(createPointerEvent('pointerdown', { pointerId: 1, clientX: 100, clientY: 200, pointerType: 'touch' }))
    tracker.onPointerDown(createPointerEvent('pointerdown', { pointerId: 2, clientX: 300, clientY: 200, pointerType: 'touch', isPrimary: false }))
    
    tracker.onPointerMove(createPointerEvent('pointermove', { pointerId: 1, clientX: 120, clientY: 210, pointerType: 'touch' }))
    tracker.onPointerMove(createPointerEvent('pointermove', { pointerId: 2, clientX: 280, clientY: 215, pointerType: 'touch' }))
    
    tracker.onPointerUp(createPointerEvent('pointerup', { pointerId: 1, pointerType: 'touch' }))
    tracker.onPointerUp(createPointerEvent('pointerup', { pointerId: 2, pointerType: 'touch' }))
    
    // --- Демо 3: setPointerCapture симуляция ---
    console.log('\n=== setPointerCapture симуляция ===')
    
    class DraggableElement {
      constructor(name) {
        this.name = name
        this.x = 0
        this.y = 0
        this.capturedPointerId = null
      }
    
      onPointerDown(event) {
        // В браузере: element.setPointerCapture(event.pointerId)
        this.capturedPointerId = event.pointerId
        this.startX = event.clientX - this.x
        this.startY = event.clientY - this.y
        console.log(`[${this.name}] захват pointerId=${event.pointerId}`)
      }
    
      onPointerMove(event) {
        if (event.pointerId !== this.capturedPointerId) return
        this.x = event.clientX - this.startX
        this.y = event.clientY - this.startY
        console.log(`[${this.name}] позиция: x=${this.x}, y=${this.y}`)
      }
    
      onPointerUp(event) {
        if (event.pointerId !== this.capturedPointerId) return
        this.capturedPointerId = null
        console.log(`[${this.name}] захват снят, финальная позиция: x=${this.x}, y=${this.y}`)
      }
    }
    
    const draggable = new DraggableElement('Карточка')
    draggable.onPointerDown(createPointerEvent('pointerdown', { clientX: 50, clientY: 50 }))
    draggable.onPointerMove(createPointerEvent('pointermove', { clientX: 150, clientY: 120 }))
    draggable.onPointerMove(createPointerEvent('pointermove', { clientX: 250, clientY: 180 }))
    draggable.onPointerUp(createPointerEvent('pointerup', { clientX: 250, clientY: 180 }))

    Pointer Events

    Представь: ты делаешь интерактивную карту или редактор с перетаскиванием. На десктопе работает мышь, на планшете — стилус, на телефоне — пальцы. Раньше нужно было писать три набора обработчиков. Pointer Events — унифицированный API, который работает для всех устройств одновременно.

    Что решает этот механизм

    Pointer Events объединяет mouse-события и touch-события в единый API. Один обработчик pointerdown заменяет mousedown + touchstart. Дополнительно API добавляет захват указателя (setPointerCapture) и поддержку давления стилуса — возможности, которых не было в старых API.

    На основе предыдущих уроков

  • события мыши — Pointer Events это надмножество mouse-событий, те же свойства clientX/clientY
  • Drag'n'Drop — setPointerCapture решает проблему «выскальзывания» курсора при перетаскивании
  • Зачем нужны Pointer Events

    // Старый подход: отдельные обработчики для каждого устройства
    element.addEventListener('mousedown', onStart)
    element.addEventListener('touchstart', onStart)  // дублирование!
    
    // Новый подход: один обработчик для всех устройств
    element.addEventListener('pointerdown', onStart)

    Основные события

    element.addEventListener('pointerdown',   handler)  // нажатие (кнопка мыши / касание)
    element.addEventListener('pointermove',   handler)  // движение указателя
    element.addEventListener('pointerup',     handler)  // отпускание
    element.addEventListener('pointercancel', handler)  // отмена (звонок во время touch и т.п.)
    element.addEventListener('pointerenter',  handler)  // вход в элемент (без всплытия)
    element.addEventListener('pointerleave',  handler)  // выход из элемента (без всплытия)

    Свойства PointerEvent

    element.addEventListener('pointerdown', (event) => {
      // Идентификатор указателя — уникален для каждого пальца при мультитач
      console.log(event.pointerId)     // число: 1, 2, 3...
    
      // Тип устройства
      console.log(event.pointerType)   // 'mouse', 'touch', 'pen'
    
      // Давление (0 — нет контакта, 1 — максимальное давление)
      // У мыши обычно 0 или 0.5, у стилуса — плавное значение
      console.log(event.pressure)      // 0.0 ... 1.0
    
      // Является ли основным указателем (первый палец при мультитач)
      console.log(event.isPrimary)     // true / false
    
      // Координаты — те же что у MouseEvent
      console.log(event.clientX, event.clientY)
    })

    setPointerCapture — захват указателя

    При drag'n'drop проблема: пользователь двигает мышь быстро и курсор «выскальзывает» из элемента — события перестают приходить.

    setPointerCapture решает это: все события указателя будут приходить на заданный элемент, даже если указатель ушёл за его пределы.

    element.addEventListener('pointerdown', (event) => {
      // Захватить указатель — все pointermove будут приходить сюда
      element.setPointerCapture(event.pointerId)
    })
    
    element.addEventListener('pointermove', (event) => {
      // Срабатывает даже когда курсор за пределами элемента!
      updatePosition(event.clientX, event.clientY)
    })
    
    element.addEventListener('pointerup', (event) => {
      // Захват снимается автоматически при pointerup
      // Или вручную: element.releasePointerCapture(event.pointerId)
    })

    Мультитач — несколько указателей

    const activePointers = new Map()
    
    element.addEventListener('pointerdown', (event) => {
      activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
      console.log(`Активных касаний: ${activePointers.size}`)
    })
    
    element.addEventListener('pointermove', (event) => {
      if (activePointers.has(event.pointerId)) {
        activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
      }
    })
    
    element.addEventListener('pointerup', (event) => {
      activePointers.delete(event.pointerId)
    })

    touch-action CSS

    Если вы обрабатываете pointer-события самостоятельно, отключите браузерную прокрутку через CSS:

    // CSS: touch-action: none;
    // Это предотвращает прокрутку страницы при касании элемента
    // и позволяет корректно обрабатывать pointercancel

    Преимущества над Mouse + Touch Events

    | Возможность | Mouse Events | Touch Events | Pointer Events |

    |---|---|---|---|

    | Мышь | Да | Нет | Да |

    | Тачскрин | Нет | Да | Да |

    | Стилус | Частично | Нет | Да |

    | Давление | Нет | Нет | Да |

    | Мультитач | Нет | Да | Да |

    | Захват | Нет | Нет | Да |

    Типичные ошибки

    1. Не добавить touch-action: none в CSS — браузер перехватывает скролл

    // ПЛОХО — браузер прокручивает страницу вместо твоего обработчика
    element.addEventListener('pointermove', onMove)
    // Пользователь делает свайп, но страница прокручивается → pointercancel!
    
    // ХОРОШО — отключить браузерный скролл через CSS
    // element.style.touchAction = 'none'  // в реальном коде это CSS свойство
    // Или в CSS: .draggable { touch-action: none; }

    2. Не освобождать захват указателя при pointercancel

    // ПЛОХО — если поступил pointercancel (звонок, переключение окна),
    // захват остаётся, элемент «залипает»
    element.addEventListener('pointerdown', e => element.setPointerCapture(e.pointerId))
    element.addEventListener('pointerup', e => element.releasePointerCapture(e.pointerId))
    // pointercancel не обработан!
    
    // ХОРОШО — обрабатывать и pointercancel
    element.addEventListener('pointercancel', e => {
      element.releasePointerCapture(e.pointerId)
      stopDragging()  // сброс состояния
    })

    3. Смешивать pointer и mouse события — двойные срабатывания

    // ПЛОХО — браузер генерирует и pointer, и mouse события
    element.addEventListener('pointerdown', onStart)
    element.addEventListener('mousedown', onStart)  // вызовется дважды для мыши!
    
    // ХОРОШО — использовать только pointer события
    element.addEventListener('pointerdown', onStart)
    // Pointer Events генерируются для всех устройств, mouse-события больше не нужны

    В реальных проектах

  • Figma: canvas обрабатывает рисование через Pointer Events — поддерживает и мышь, и графический планшет с давлением
  • Google Maps, Яндекс.Карты: жесты масштабирования (pinch-to-zoom) реализованы через мультитач Pointer Events
  • Kindle, Apple Books: рисование заметок на iPad используют event.pressure стилуса для толщины линии
  • Примеры

    Симуляция Pointer Events: мультитач трекинг и захват указателя для перетаскивания

    // Симуляция Pointer Events API через mock-объекты
    // В браузере эти события приходят от реальных устройств
    
    function createPointerEvent(type, options = {}) {
      return {
        type,
        pointerId:   options.pointerId   ?? 1,
        pointerType: options.pointerType ?? 'mouse',
        pressure:    options.pressure    ?? (type === 'pointerdown' ? 0.5 : 0),
        isPrimary:   options.isPrimary   ?? true,
        clientX:     options.clientX     ?? 0,
        clientY:     options.clientY     ?? 0,
        preventDefault: () => {},
      }
    }
    
    // --- Демо 1: Определение типа устройства ---
    console.log('=== Тип устройства и давление ===')
    
    const events = [
      createPointerEvent('pointerdown', { pointerType: 'mouse',  pressure: 0.5,  pointerId: 1 }),
      createPointerEvent('pointerdown', { pointerType: 'touch',  pressure: 0.8,  pointerId: 2, isPrimary: true }),
      createPointerEvent('pointerdown', { pointerType: 'touch',  pressure: 0.6,  pointerId: 3, isPrimary: false }),
      createPointerEvent('pointerdown', { pointerType: 'pen',    pressure: 0.95, pointerId: 4 }),
    ]
    
    events.forEach(e => {
      const device = { mouse: 'Мышь', touch: 'Тачскрин', pen: 'Стилус' }[e.pointerType]
      const primary = e.isPrimary ? '(primary)' : '(secondary)'
      console.log(`${device} ${primary}: pointerId=${e.pointerId}, pressure=${e.pressure}`)
    })
    
    // --- Демо 2: Мультитач трекинг ---
    console.log('\n=== Мультитач трекинг ===')
    
    class PointerTracker {
      constructor() {
        this.activePointers = new Map()
      }
    
      onPointerDown(event) {
        this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
        console.log(`pointerdown id=${event.pointerId}: активных касаний: ${this.activePointers.size}`)
      }
    
      onPointerMove(event) {
        if (!this.activePointers.has(event.pointerId)) return
        const prev = this.activePointers.get(event.pointerId)
        const dx = event.clientX - prev.x
        const dy = event.clientY - prev.y
        this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
        console.log(`pointermove id=${event.pointerId}: dx=${dx}, dy=${dy}`)
      }
    
      onPointerUp(event) {
        this.activePointers.delete(event.pointerId)
        console.log(`pointerup id=${event.pointerId}: активных касаний: ${this.activePointers.size}`)
      }
    }
    
    const tracker = new PointerTracker()
    
    // Симулируем два пальца на тачскрине
    tracker.onPointerDown(createPointerEvent('pointerdown', { pointerId: 1, clientX: 100, clientY: 200, pointerType: 'touch' }))
    tracker.onPointerDown(createPointerEvent('pointerdown', { pointerId: 2, clientX: 300, clientY: 200, pointerType: 'touch', isPrimary: false }))
    
    tracker.onPointerMove(createPointerEvent('pointermove', { pointerId: 1, clientX: 120, clientY: 210, pointerType: 'touch' }))
    tracker.onPointerMove(createPointerEvent('pointermove', { pointerId: 2, clientX: 280, clientY: 215, pointerType: 'touch' }))
    
    tracker.onPointerUp(createPointerEvent('pointerup', { pointerId: 1, pointerType: 'touch' }))
    tracker.onPointerUp(createPointerEvent('pointerup', { pointerId: 2, pointerType: 'touch' }))
    
    // --- Демо 3: setPointerCapture симуляция ---
    console.log('\n=== setPointerCapture симуляция ===')
    
    class DraggableElement {
      constructor(name) {
        this.name = name
        this.x = 0
        this.y = 0
        this.capturedPointerId = null
      }
    
      onPointerDown(event) {
        // В браузере: element.setPointerCapture(event.pointerId)
        this.capturedPointerId = event.pointerId
        this.startX = event.clientX - this.x
        this.startY = event.clientY - this.y
        console.log(`[${this.name}] захват pointerId=${event.pointerId}`)
      }
    
      onPointerMove(event) {
        if (event.pointerId !== this.capturedPointerId) return
        this.x = event.clientX - this.startX
        this.y = event.clientY - this.startY
        console.log(`[${this.name}] позиция: x=${this.x}, y=${this.y}`)
      }
    
      onPointerUp(event) {
        if (event.pointerId !== this.capturedPointerId) return
        this.capturedPointerId = null
        console.log(`[${this.name}] захват снят, финальная позиция: x=${this.x}, y=${this.y}`)
      }
    }
    
    const draggable = new DraggableElement('Карточка')
    draggable.onPointerDown(createPointerEvent('pointerdown', { clientX: 50, clientY: 50 }))
    draggable.onPointerMove(createPointerEvent('pointermove', { clientX: 150, clientY: 120 }))
    draggable.onPointerMove(createPointerEvent('pointermove', { clientX: 250, clientY: 180 }))
    draggable.onPointerUp(createPointerEvent('pointerup', { clientX: 250, clientY: 180 }))

    Задание

    Напиши класс GestureDetector, который определяет жесты по Pointer Events. Метод onEvent(event) принимает mock-событие. Он должен: при pointerdown начинать отслеживание (запомнить startX/startY), при pointerup вычислять направление свайпа (left/right/up/down) если расстояние > 50px, или фиксировать "tap" если расстояние <= 50px. Метод lastGesture возвращает последний определённый жест.

    Подсказка

    startX = event.clientX, startY = event.clientY при pointerdown. Направление: если Math.abs(dx) > Math.abs(dy), то left/right иначе up/down. dx > 0 — 'right', dy > 0 — 'down'.

    Загружаем среду выполнения...
    Загружаем AI-помощника...