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

События мыши

Вы разрабатываете Kanban-доску: карточки нужно перетаскивать между колонками. Или графический редактор: рисование по холсту. Обе задачи строятся на трёх событиях: mousedown → mousemove → mouseup. Понять события мыши — значит уметь строить интерактивные интерфейсы.

Что решает эта тема

Браузер генерирует события мыши при любом взаимодействии с указателем. Объект MouseEvent содержит координаты, нажатую кнопку и удерживаемые модификаторы.

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

  • события: addEventListener, stopPropagation, preventDefault
  • всплытие событий: mouseover/mouseout vs mouseenter/mouseleave
  • формы: события ввода и submit
  • Основные события

    element.addEventListener('mousedown', handler)  // кнопка нажата
    element.addEventListener('mouseup', handler)    // кнопка отпущена
    element.addEventListener('click', handler)      // полный клик (down + up)
    element.addEventListener('dblclick', handler)   // двойной клик
    element.addEventListener('contextmenu', handler) // правая кнопка мыши

    Движение мыши

    element.addEventListener('mousemove', handler)   // мышь двигается над элементом
    element.addEventListener('mouseover', handler)   // мышь вошла в элемент или его потомка
    element.addEventListener('mouseout', handler)    // мышь вышла из элемента или его потомка
    element.addEventListener('mouseenter', handler)  // мышь вошла в элемент (без всплытия)
    element.addEventListener('mouseleave', handler)  // мышь вышла из элемента (без всплытия)

    Свойства MouseEvent

    document.addEventListener('click', (event) => {
      // Какая кнопка нажата
      console.log(event.button)   // 0 — левая, 1 — средняя, 2 — правая
      console.log(event.buttons)  // битовая маска: 1=левая, 2=правая, 4=средняя
    
      // Координаты клика
      console.log(event.clientX, event.clientY) // относительно viewport (видимой области)
      console.log(event.pageX, event.pageY)     // относительно всей страницы (с учётом прокрутки)
      console.log(event.offsetX, event.offsetY) // относительно элемента-цели
    
      // Модификаторы
      console.log(event.ctrlKey)  // удержан Ctrl
      console.log(event.shiftKey) // удержан Shift
      console.log(event.altKey)   // удержан Alt
    })

    mouseover/mouseout vs mouseenter/mouseleave

    Ключевое различие — всплытие (bubbling):

    | Событие | Всплывает | Срабатывает при входе в потомка |

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

    | mouseover | Да | Да |

    | mouseout | Да | Да |

    | mouseenter | Нет | Нет |

    | mouseleave | Нет | Нет |

    // mouseover срабатывает при каждом переходе между дочерними элементами
    // mouseenter — только один раз при входе в родительский элемент
    // Для hover-эффектов предпочитай mouseenter/mouseleave

    Координаты: clientX vs pageX vs offsetX

    // clientX/Y — координаты от левого верхнего угла ВИДИМОЙ области
    // Не меняются при прокрутке страницы
    console.log(event.clientX)  // позиция в пикселях от левого края viewport
    
    // pageX/Y — координаты от левого верхнего угла ДОКУМЕНТА
    // Учитывают вертикальную и горизонтальную прокрутку
    console.log(event.pageX)    // clientX + window.scrollX
    
    // offsetX/Y — координаты относительно ЦЕЛЕВОГО ЭЛЕМЕНТА
    // Удобно для рисования на canvas или drag'n'drop
    console.log(event.offsetX)  // позиция от левого края элемента

    Предотвращение стандартного поведения

    // Отключить контекстное меню (например, для кастомного меню)
    element.addEventListener('contextmenu', (event) => {
      event.preventDefault()
      showCustomContextMenu(event.clientX, event.clientY)
    })
    
    // Запретить выделение текста при drag'n'drop
    element.addEventListener('mousedown', (event) => {
      event.preventDefault()
    })

    Drag'n'Drop через события мыши

    let isDragging = false
    let startX = 0, startY = 0
    let currentX = 0, currentY = 0
    
    draggable.addEventListener('mousedown', (event) => {
      isDragging = true
      startX = event.clientX - currentX
      startY = event.clientY - currentY
      event.preventDefault()  // запретить выделение
    })
    
    document.addEventListener('mousemove', (event) => {
      if (!isDragging) return
      currentX = event.clientX - startX
      currentY = event.clientY - startY
      draggable.style.transform = `translate(${currentX}px, ${currentY}px)`
    })
    
    document.addEventListener('mouseup', () => {
      isDragging = false
    })

    Обработчики mousemove и mouseup вешаются на document, а не на элемент — иначе при быстром движении мыши курсор «выскользнет» из элемента и перетаскивание прервётся.

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

    Ошибка 1: mousemove и mouseup на элементе, а не на document

    // Сломано: при быстром движении мышь выходит за пределы элемента
    draggable.addEventListener('mousemove', handler)  // пропустит события!
    draggable.addEventListener('mouseup', handler)
    
    // Исправлено: глобальные обработчики — мышь перехватывается везде
    document.addEventListener('mousemove', handler)
    document.addEventListener('mouseup', handler)

    Ошибка 2: не вызывают preventDefault при перетаскивании

    // Сломано: при перетаскивании браузер выделяет текст, появляется "призрак"
    draggable.addEventListener('mousedown', (e) => {
      isDragging = true  // не вызвали e.preventDefault()
    })
    
    // Исправлено:
    draggable.addEventListener('mousedown', (e) => {
      e.preventDefault()  // запретить выделение текста
      isDragging = true
    })

    Ошибка 3: используют mouseover вместо mouseenter для hover

    // Сломано: mouseover срабатывает при каждом переходе между дочерними элементами
    list.addEventListener('mouseover', () => list.classList.add('hovered'))
    // Мигает при наведении на пункты списка!
    
    // Исправлено: mouseenter срабатывает только при входе в родительский элемент
    list.addEventListener('mouseenter', () => list.classList.add('hovered'))
    list.addEventListener('mouseleave', () => list.classList.remove('hovered'))

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

  • Drag and Drop: перемещение карточек Kanban, файлов в облаке, элементов сортировки
  • Canvas: рисование, игры — mousemove отслеживает позицию курсора для рисования линий
  • Кастомные контекстные меню: contextmenu + preventDefault + кастомный div
  • Resizable panels: изменение ширины панелей через mousedown на границе
  • Примеры

    Симуляция drag'n'drop логики через mousedown/mousemove/mouseup с отслеживанием позиции

    // Симуляция Drag'n'Drop через чистую логику (без DOM)
    // Воспроизводим паттерн: mousedown → mousemove × N → mouseup
    
    function createDragSession(startX, startY) {
      let currentX = startX
      let currentY = startY
      let isDragging = true
      const BOUNDS = { minX: 0, minY: 0, maxX: 800, maxY: 600 }
    
      function clamp(value, min, max) {
        return Math.max(min, Math.min(max, value))
      }
    
      return {
        // Применить дельту движения (как событие mousemove)
        move(deltaX, deltaY) {
          if (!isDragging) return null
          currentX = clamp(currentX + deltaX, BOUNDS.minX, BOUNDS.maxX)
          currentY = clamp(currentY + deltaY, BOUNDS.minY, BOUNDS.maxY)
          return { x: currentX, y: currentY }
        },
    
        // Завершить перетаскивание (как событие mouseup)
        drop() {
          isDragging = false
          return { x: currentX, y: currentY }
        },
    
        getPosition() {
          return { x: currentX, y: currentY, isDragging }
        }
      }
    }
    
    // Симулируем перетаскивание элемента с (100, 150) к (350, 280)
    console.log('=== Симуляция Drag'n'Drop ===')
    
    const drag = createDragSession(100, 150)
    console.log('mousedown:', drag.getPosition())
    // { x: 100, y: 150, isDragging: true }
    
    // Серия событий mousemove с дельтами
    const moves = [
      { dx: 50, dy: 30 },
      { dx: 80, dy: 50 },
      { dx: 70, dy: 30 },
      { dx: 50, dy: 20 },
    ]
    
    moves.forEach((move, i) => {
      const pos = drag.move(move.dx, move.dy)
      console.log(`mousemove[${i + 1}]: x=${pos.x}, y=${pos.y}`)
    })
    
    // mouseup — завершаем перетаскивание
    const finalPos = drag.drop()
    console.log('mouseup (final):', finalPos)
    // { x: 350, y: 280 }
    
    // Попытка продолжить после drop — игнорируется
    const afterDrop = drag.move(100, 100)
    console.log('Движение после drop:', afterDrop)  // null
    
    // Проверяем clamping к границам
    console.log('\n=== Проверка ограничений (clamping) ===')
    const drag2 = createDragSession(750, 550)
    const clamped = drag2.move(200, 200)  // выходит за границы 800x600
    console.log('Перемещение за границы:', clamped)
    // { x: 800, y: 600 } — зафиксировано на границе
    
    // Реальный паттерн обработчиков в браузере
    console.log('\n=== Паттерн обработчиков ===')
    const mockEvents = {
      mousedown: { clientX: 200, clientY: 300, button: 0 },
      mousemove: [
        { clientX: 220, clientY: 315 },
        { clientX: 240, clientY: 330 },
      ],
      mouseup: { clientX: 250, clientY: 340 },
    }
    
    let startClientX = 0, startClientY = 0, offsetX = 0, offsetY = 0
    let dragging = false
    
    // Симуляция mousedown
    const e = mockEvents.mousedown
    if (e.button === 0) {  // только левая кнопка
      dragging = true
      startClientX = e.clientX
      startClientY = e.clientY
      console.log('mousedown: начало перетаскивания', { startClientX, startClientY })
    }
    
    // Симуляция mousemove
    mockEvents.mousemove.forEach(ev => {
      if (!dragging) return
      offsetX = ev.clientX - startClientX
      offsetY = ev.clientY - startClientY
      console.log(`mousemove: смещение dx=${offsetX}, dy=${offsetY}`)
    })
    
    // Симуляция mouseup
    if (dragging) {
      dragging = false
      console.log('mouseup: перетаскивание завершено, итоговое смещение', { offsetX, offsetY })
    }

    События мыши

    Вы разрабатываете Kanban-доску: карточки нужно перетаскивать между колонками. Или графический редактор: рисование по холсту. Обе задачи строятся на трёх событиях: mousedown → mousemove → mouseup. Понять события мыши — значит уметь строить интерактивные интерфейсы.

    Что решает эта тема

    Браузер генерирует события мыши при любом взаимодействии с указателем. Объект MouseEvent содержит координаты, нажатую кнопку и удерживаемые модификаторы.

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

  • события: addEventListener, stopPropagation, preventDefault
  • всплытие событий: mouseover/mouseout vs mouseenter/mouseleave
  • формы: события ввода и submit
  • Основные события

    element.addEventListener('mousedown', handler)  // кнопка нажата
    element.addEventListener('mouseup', handler)    // кнопка отпущена
    element.addEventListener('click', handler)      // полный клик (down + up)
    element.addEventListener('dblclick', handler)   // двойной клик
    element.addEventListener('contextmenu', handler) // правая кнопка мыши

    Движение мыши

    element.addEventListener('mousemove', handler)   // мышь двигается над элементом
    element.addEventListener('mouseover', handler)   // мышь вошла в элемент или его потомка
    element.addEventListener('mouseout', handler)    // мышь вышла из элемента или его потомка
    element.addEventListener('mouseenter', handler)  // мышь вошла в элемент (без всплытия)
    element.addEventListener('mouseleave', handler)  // мышь вышла из элемента (без всплытия)

    Свойства MouseEvent

    document.addEventListener('click', (event) => {
      // Какая кнопка нажата
      console.log(event.button)   // 0 — левая, 1 — средняя, 2 — правая
      console.log(event.buttons)  // битовая маска: 1=левая, 2=правая, 4=средняя
    
      // Координаты клика
      console.log(event.clientX, event.clientY) // относительно viewport (видимой области)
      console.log(event.pageX, event.pageY)     // относительно всей страницы (с учётом прокрутки)
      console.log(event.offsetX, event.offsetY) // относительно элемента-цели
    
      // Модификаторы
      console.log(event.ctrlKey)  // удержан Ctrl
      console.log(event.shiftKey) // удержан Shift
      console.log(event.altKey)   // удержан Alt
    })

    mouseover/mouseout vs mouseenter/mouseleave

    Ключевое различие — всплытие (bubbling):

    | Событие | Всплывает | Срабатывает при входе в потомка |

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

    | mouseover | Да | Да |

    | mouseout | Да | Да |

    | mouseenter | Нет | Нет |

    | mouseleave | Нет | Нет |

    // mouseover срабатывает при каждом переходе между дочерними элементами
    // mouseenter — только один раз при входе в родительский элемент
    // Для hover-эффектов предпочитай mouseenter/mouseleave

    Координаты: clientX vs pageX vs offsetX

    // clientX/Y — координаты от левого верхнего угла ВИДИМОЙ области
    // Не меняются при прокрутке страницы
    console.log(event.clientX)  // позиция в пикселях от левого края viewport
    
    // pageX/Y — координаты от левого верхнего угла ДОКУМЕНТА
    // Учитывают вертикальную и горизонтальную прокрутку
    console.log(event.pageX)    // clientX + window.scrollX
    
    // offsetX/Y — координаты относительно ЦЕЛЕВОГО ЭЛЕМЕНТА
    // Удобно для рисования на canvas или drag'n'drop
    console.log(event.offsetX)  // позиция от левого края элемента

    Предотвращение стандартного поведения

    // Отключить контекстное меню (например, для кастомного меню)
    element.addEventListener('contextmenu', (event) => {
      event.preventDefault()
      showCustomContextMenu(event.clientX, event.clientY)
    })
    
    // Запретить выделение текста при drag'n'drop
    element.addEventListener('mousedown', (event) => {
      event.preventDefault()
    })

    Drag'n'Drop через события мыши

    let isDragging = false
    let startX = 0, startY = 0
    let currentX = 0, currentY = 0
    
    draggable.addEventListener('mousedown', (event) => {
      isDragging = true
      startX = event.clientX - currentX
      startY = event.clientY - currentY
      event.preventDefault()  // запретить выделение
    })
    
    document.addEventListener('mousemove', (event) => {
      if (!isDragging) return
      currentX = event.clientX - startX
      currentY = event.clientY - startY
      draggable.style.transform = `translate(${currentX}px, ${currentY}px)`
    })
    
    document.addEventListener('mouseup', () => {
      isDragging = false
    })

    Обработчики mousemove и mouseup вешаются на document, а не на элемент — иначе при быстром движении мыши курсор «выскользнет» из элемента и перетаскивание прервётся.

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

    Ошибка 1: mousemove и mouseup на элементе, а не на document

    // Сломано: при быстром движении мышь выходит за пределы элемента
    draggable.addEventListener('mousemove', handler)  // пропустит события!
    draggable.addEventListener('mouseup', handler)
    
    // Исправлено: глобальные обработчики — мышь перехватывается везде
    document.addEventListener('mousemove', handler)
    document.addEventListener('mouseup', handler)

    Ошибка 2: не вызывают preventDefault при перетаскивании

    // Сломано: при перетаскивании браузер выделяет текст, появляется "призрак"
    draggable.addEventListener('mousedown', (e) => {
      isDragging = true  // не вызвали e.preventDefault()
    })
    
    // Исправлено:
    draggable.addEventListener('mousedown', (e) => {
      e.preventDefault()  // запретить выделение текста
      isDragging = true
    })

    Ошибка 3: используют mouseover вместо mouseenter для hover

    // Сломано: mouseover срабатывает при каждом переходе между дочерними элементами
    list.addEventListener('mouseover', () => list.classList.add('hovered'))
    // Мигает при наведении на пункты списка!
    
    // Исправлено: mouseenter срабатывает только при входе в родительский элемент
    list.addEventListener('mouseenter', () => list.classList.add('hovered'))
    list.addEventListener('mouseleave', () => list.classList.remove('hovered'))

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

  • Drag and Drop: перемещение карточек Kanban, файлов в облаке, элементов сортировки
  • Canvas: рисование, игры — mousemove отслеживает позицию курсора для рисования линий
  • Кастомные контекстные меню: contextmenu + preventDefault + кастомный div
  • Resizable panels: изменение ширины панелей через mousedown на границе
  • Примеры

    Симуляция drag'n'drop логики через mousedown/mousemove/mouseup с отслеживанием позиции

    // Симуляция Drag'n'Drop через чистую логику (без DOM)
    // Воспроизводим паттерн: mousedown → mousemove × N → mouseup
    
    function createDragSession(startX, startY) {
      let currentX = startX
      let currentY = startY
      let isDragging = true
      const BOUNDS = { minX: 0, minY: 0, maxX: 800, maxY: 600 }
    
      function clamp(value, min, max) {
        return Math.max(min, Math.min(max, value))
      }
    
      return {
        // Применить дельту движения (как событие mousemove)
        move(deltaX, deltaY) {
          if (!isDragging) return null
          currentX = clamp(currentX + deltaX, BOUNDS.minX, BOUNDS.maxX)
          currentY = clamp(currentY + deltaY, BOUNDS.minY, BOUNDS.maxY)
          return { x: currentX, y: currentY }
        },
    
        // Завершить перетаскивание (как событие mouseup)
        drop() {
          isDragging = false
          return { x: currentX, y: currentY }
        },
    
        getPosition() {
          return { x: currentX, y: currentY, isDragging }
        }
      }
    }
    
    // Симулируем перетаскивание элемента с (100, 150) к (350, 280)
    console.log('=== Симуляция Drag'n'Drop ===')
    
    const drag = createDragSession(100, 150)
    console.log('mousedown:', drag.getPosition())
    // { x: 100, y: 150, isDragging: true }
    
    // Серия событий mousemove с дельтами
    const moves = [
      { dx: 50, dy: 30 },
      { dx: 80, dy: 50 },
      { dx: 70, dy: 30 },
      { dx: 50, dy: 20 },
    ]
    
    moves.forEach((move, i) => {
      const pos = drag.move(move.dx, move.dy)
      console.log(`mousemove[${i + 1}]: x=${pos.x}, y=${pos.y}`)
    })
    
    // mouseup — завершаем перетаскивание
    const finalPos = drag.drop()
    console.log('mouseup (final):', finalPos)
    // { x: 350, y: 280 }
    
    // Попытка продолжить после drop — игнорируется
    const afterDrop = drag.move(100, 100)
    console.log('Движение после drop:', afterDrop)  // null
    
    // Проверяем clamping к границам
    console.log('\n=== Проверка ограничений (clamping) ===')
    const drag2 = createDragSession(750, 550)
    const clamped = drag2.move(200, 200)  // выходит за границы 800x600
    console.log('Перемещение за границы:', clamped)
    // { x: 800, y: 600 } — зафиксировано на границе
    
    // Реальный паттерн обработчиков в браузере
    console.log('\n=== Паттерн обработчиков ===')
    const mockEvents = {
      mousedown: { clientX: 200, clientY: 300, button: 0 },
      mousemove: [
        { clientX: 220, clientY: 315 },
        { clientX: 240, clientY: 330 },
      ],
      mouseup: { clientX: 250, clientY: 340 },
    }
    
    let startClientX = 0, startClientY = 0, offsetX = 0, offsetY = 0
    let dragging = false
    
    // Симуляция mousedown
    const e = mockEvents.mousedown
    if (e.button === 0) {  // только левая кнопка
      dragging = true
      startClientX = e.clientX
      startClientY = e.clientY
      console.log('mousedown: начало перетаскивания', { startClientX, startClientY })
    }
    
    // Симуляция mousemove
    mockEvents.mousemove.forEach(ev => {
      if (!dragging) return
      offsetX = ev.clientX - startClientX
      offsetY = ev.clientY - startClientY
      console.log(`mousemove: смещение dx=${offsetX}, dy=${offsetY}`)
    })
    
    // Симуляция mouseup
    if (dragging) {
      dragging = false
      console.log('mouseup: перетаскивание завершено, итоговое смещение', { offsetX, offsetY })
    }

    Задание

    Напиши функцию dragAndDrop(startX, startY, moves, endX, endY), которая симулирует перетаскивание. Параметр moves — массив объектов { dx, dy }. Функция должна начать с позиции startX/startY, применить каждое смещение, ограничить результат границами [0, endX] по X и [0, endY] по Y, и вернуть итоговую позицию { x, y, path } где path — массив всех промежуточных позиций.

    Подсказка

    x = clamp(x + move.dx, 0, maxX), y = clamp(y + move.dy, 0, maxY). Начни с path = [{ x: startX, y: startY }] и добавляй новую точку после каждого хода.

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