← JavaScript/Drag'n'Drop#149 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Drag'n'Drop

Представь: ты строишь Kanban-доску для управления задачами. Пользователь должен перетаскивать карточки между колонками «К выполнению», «В работе», «Готово». Браузер предоставляет встроенный API для этого — без сторонних библиотек, без сложных хаков.

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

Drag'n'Drop API позволяет передавать данные от перетаскиваемого элемента к зоне сброса через dataTransfer. Без него пришлось бы вручную отслеживать mousedown/mousemove/mouseup и вычислять позиции — десятки строк кода вместо нескольких обработчиков событий.

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

  • события мыши — Drag'n'Drop строится поверх мышиных событий, event.preventDefault() используется так же
  • addEventListener — те же паттерны подписки на события
  • Атрибут draggable

    Чтобы сделать элемент перетаскиваемым, нужно добавить атрибут draggable="true":

    // HTML: <div draggable="true" id="card">Задача</div>
    const card = document.getElementById('card')
    // Теперь элемент можно перетаскивать

    События на источнике (элемент, который тащим)

    element.addEventListener('dragstart', (event) => {
      // Срабатывает в начале перетаскивания
      // Здесь записываем данные, которые хотим передать
      event.dataTransfer.setData('text/plain', element.id)
      event.dataTransfer.effectAllowed = 'move'
      element.classList.add('dragging')
    })
    
    element.addEventListener('drag', (event) => {
      // Срабатывает непрерывно во время перетаскивания
      // Редко используется напрямую
    })
    
    element.addEventListener('dragend', (event) => {
      // Срабатывает когда отпустили кнопку мыши
      element.classList.remove('dragging')
    })

    События на цели (куда бросаем)

    dropZone.addEventListener('dragover', (event) => {
      // ВАЖНО: preventDefault() разрешает сброс в эту зону
      // Без него drop не сработает!
      event.preventDefault()
      event.dataTransfer.dropEffect = 'move'
      dropZone.classList.add('drag-over')
    })
    
    dropZone.addEventListener('dragleave', (event) => {
      dropZone.classList.remove('drag-over')
    })
    
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      // Получаем переданные данные
      const id = event.dataTransfer.getData('text/plain')
      const draggedElement = document.getElementById(id)
      dropZone.appendChild(draggedElement)
      dropZone.classList.remove('drag-over')
    })

    dataTransfer — передача данных

    Объект event.dataTransfer — канал передачи данных между источником и целью:

    // На dragstart — записываем
    event.dataTransfer.setData('text/plain', 'card-id-42')
    event.dataTransfer.setData('application/json', JSON.stringify({ id: 42, title: 'Задача' }))
    
    // На drop — читаем
    const id = event.dataTransfer.getData('text/plain')
    const data = JSON.parse(event.dataTransfer.getData('application/json'))
    
    // Типы эффектов
    event.dataTransfer.effectAllowed = 'move'   // только перемещение
    event.dataTransfer.effectAllowed = 'copy'   // только копирование
    event.dataTransfer.effectAllowed = 'copyMove' // оба варианта

    Реальный пример: доска задач

    Классический Kanban — перетаскивание карточек между колонками: "К выполнению" → "В работе" → "Готово".

    // Каждая карточка сохраняет свой id при dragstart
    // Каждая колонка принимает drop и переносит карточку
    // dragover с preventDefault позволяет сбросить
    
    card.addEventListener('dragstart', e => {
      e.dataTransfer.setData('text/plain', card.dataset.id)
    })
    
    column.addEventListener('dragover', e => e.preventDefault())
    
    column.addEventListener('drop', e => {
      e.preventDefault()
      const cardId = e.dataTransfer.getData('text/plain')
      const card = document.querySelector(`[data-id="${cardId}"]`)
      column.querySelector('.cards').appendChild(card)
    })

    Почему нужен preventDefault на dragover

    Браузер по умолчанию запрещает сброс на большинство элементов — чтобы не нарушать стандартное поведение страницы. Вызов event.preventDefault() в обработчике dragover — это явный сигнал браузеру: «эта зона принимает перетаскиваемые элементы».

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

    1. Не вызвать preventDefault в dragover — drop не сработает

    // ПЛОХО — без preventDefault событие drop никогда не придёт
    dropZone.addEventListener('dragover', (event) => {
      // event.preventDefault() забыли!
      dropZone.classList.add('over')
    })
    dropZone.addEventListener('drop', (event) => {
      // Этот обработчик никогда не вызовется
    })
    
    // ХОРОШО — preventDefault разрешает сброс
    dropZone.addEventListener('dragover', (event) => {
      event.preventDefault()  // обязательно!
      dropZone.classList.add('over')
    })

    2. Использовать event.target в drop вместо данных из dataTransfer

    // ПЛОХО — event.target при drop это dropZone, а не перетаскиваемый элемент
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      const dragged = event.target  // НЕПРАВИЛЬНО — это dropZone!
      dropZone.appendChild(dragged)
    })
    
    // ХОРОШО — данные передаются через dataTransfer
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      const id = event.dataTransfer.getData('text/plain')
      const dragged = document.getElementById(id)  // находим по id
      dropZone.appendChild(dragged)
    })

    3. Не очищать стили drag-over при dragleave

    // ПЛОХО — подсветка зоны остаётся после ухода курсора
    dropZone.addEventListener('dragover', (event) => {
      event.preventDefault()
      dropZone.style.border = '2px dashed blue'
    })
    // dragleave не обработан — рамка висит навсегда
    
    // ХОРОШО — убирать стили в dragleave
    dropZone.addEventListener('dragleave', () => {
      dropZone.style.border = ''
    })
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      dropZone.style.border = ''  // тоже убираем при сбросе
    })

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

  • Trello, Jira, Notion: Kanban-доски используют Drag'n'Drop для перемещения карточек между колонками
  • Загрузка файлов: Dropbox, Google Drive — drag файлов с рабочего стола в браузер через то же API (event.dataTransfer.files)
  • Figma, Miro: перетаскивание элементов на холсте реализуется через кастомный DnD поверх Pointer Events
  • Примеры

    Симуляция Kanban-доски: перетаскивание карточек между колонками через mock-объекты (без DOM)

    // Симуляция Kanban-доски с логикой Drag'n'Drop
    // В браузере это были бы реальные DOM-элементы с событиями
    
    // Структура данных доски
    const board = {
      todo:       { title: 'К выполнению', cards: ['Верстка главной', 'API авторизации', 'Тесты'] },
      inProgress: { title: 'В работе',     cards: ['Корзина товаров'] },
      done:       { title: 'Готово',       cards: ['Дизайн макета'] },
    }
    
    function printBoard(board) {
      for (const [key, col] of Object.entries(board)) {
        console.log(`[${col.title}]: ${col.cards.length ? col.cards.join(', ') : '(пусто)'}`)
      }
    }
    
    // --- Mock dataTransfer ---
    function createDataTransfer() {
      const store = {}
      return {
        setData(type, value) { store[type] = value },
        getData(type)        { return store[type] || '' },
        effectAllowed: 'move',
        dropEffect: 'move',
      }
    }
    
    // --- Симуляция событий dragstart / dragover / drop ---
    function simulateDrag(board, fromCol, cardTitle, toCol) {
      // dragstart: запоминаем данные карточки
      const dataTransfer = createDataTransfer()
      const dragStartEvent = { dataTransfer, preventDefault: () => {} }
    
      dataTransfer.setData('text/plain', cardTitle)
      dataTransfer.setData('application/json', JSON.stringify({ card: cardTitle, from: fromCol }))
      console.log(`dragstart: начали тащить "${cardTitle}" из "${board[fromCol].title}"`)
    
      // dragover: preventDefault разрешает drop
      const dragOverEvent = { preventDefault: () => {}, dataTransfer }
      dragOverEvent.preventDefault()
      console.log(`dragover: зона "${board[toCol].title}" приняла перетаскивание`)
    
      // drop: перемещаем карточку
      const dropEvent = { dataTransfer, preventDefault: () => {} }
      dropEvent.preventDefault()
    
      const droppedCard = dropEvent.dataTransfer.getData('text/plain')
      const meta = JSON.parse(dropEvent.dataTransfer.getData('application/json'))
    
      // Убираем из исходной колонки
      board[meta.from].cards = board[meta.from].cards.filter(c => c !== droppedCard)
      // Добавляем в целевую колонку
      board[toCol].cards.push(droppedCard)
    
      console.log(`drop: "${droppedCard}" перемещена в "${board[toCol].title}"`)
    }
    
    console.log('=== Начальное состояние доски ===')
    printBoard(board)
    
    console.log('\n=== Перетаскиваем "API авторизации" → В работе ===')
    simulateDrag(board, 'todo', 'API авторизации', 'inProgress')
    console.log('\nСостояние после:')
    printBoard(board)
    
    console.log('\n=== Перетаскиваем "Корзина товаров" → Готово ===')
    simulateDrag(board, 'inProgress', 'Корзина товаров', 'done')
    console.log('\nФинальное состояние:')
    printBoard(board)
    
    // --- Проверка dropEffect ---
    console.log('\n=== Типы эффектов dataTransfer ===')
    const dt = createDataTransfer()
    dt.effectAllowed = 'copyMove'
    dt.setData('text/plain', 'test-data')
    console.log('effectAllowed:', dt.effectAllowed)
    console.log('getData:', dt.getData('text/plain'))

    Drag'n'Drop

    Представь: ты строишь Kanban-доску для управления задачами. Пользователь должен перетаскивать карточки между колонками «К выполнению», «В работе», «Готово». Браузер предоставляет встроенный API для этого — без сторонних библиотек, без сложных хаков.

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

    Drag'n'Drop API позволяет передавать данные от перетаскиваемого элемента к зоне сброса через dataTransfer. Без него пришлось бы вручную отслеживать mousedown/mousemove/mouseup и вычислять позиции — десятки строк кода вместо нескольких обработчиков событий.

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

  • события мыши — Drag'n'Drop строится поверх мышиных событий, event.preventDefault() используется так же
  • addEventListener — те же паттерны подписки на события
  • Атрибут draggable

    Чтобы сделать элемент перетаскиваемым, нужно добавить атрибут draggable="true":

    // HTML: <div draggable="true" id="card">Задача</div>
    const card = document.getElementById('card')
    // Теперь элемент можно перетаскивать

    События на источнике (элемент, который тащим)

    element.addEventListener('dragstart', (event) => {
      // Срабатывает в начале перетаскивания
      // Здесь записываем данные, которые хотим передать
      event.dataTransfer.setData('text/plain', element.id)
      event.dataTransfer.effectAllowed = 'move'
      element.classList.add('dragging')
    })
    
    element.addEventListener('drag', (event) => {
      // Срабатывает непрерывно во время перетаскивания
      // Редко используется напрямую
    })
    
    element.addEventListener('dragend', (event) => {
      // Срабатывает когда отпустили кнопку мыши
      element.classList.remove('dragging')
    })

    События на цели (куда бросаем)

    dropZone.addEventListener('dragover', (event) => {
      // ВАЖНО: preventDefault() разрешает сброс в эту зону
      // Без него drop не сработает!
      event.preventDefault()
      event.dataTransfer.dropEffect = 'move'
      dropZone.classList.add('drag-over')
    })
    
    dropZone.addEventListener('dragleave', (event) => {
      dropZone.classList.remove('drag-over')
    })
    
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      // Получаем переданные данные
      const id = event.dataTransfer.getData('text/plain')
      const draggedElement = document.getElementById(id)
      dropZone.appendChild(draggedElement)
      dropZone.classList.remove('drag-over')
    })

    dataTransfer — передача данных

    Объект event.dataTransfer — канал передачи данных между источником и целью:

    // На dragstart — записываем
    event.dataTransfer.setData('text/plain', 'card-id-42')
    event.dataTransfer.setData('application/json', JSON.stringify({ id: 42, title: 'Задача' }))
    
    // На drop — читаем
    const id = event.dataTransfer.getData('text/plain')
    const data = JSON.parse(event.dataTransfer.getData('application/json'))
    
    // Типы эффектов
    event.dataTransfer.effectAllowed = 'move'   // только перемещение
    event.dataTransfer.effectAllowed = 'copy'   // только копирование
    event.dataTransfer.effectAllowed = 'copyMove' // оба варианта

    Реальный пример: доска задач

    Классический Kanban — перетаскивание карточек между колонками: "К выполнению" → "В работе" → "Готово".

    // Каждая карточка сохраняет свой id при dragstart
    // Каждая колонка принимает drop и переносит карточку
    // dragover с preventDefault позволяет сбросить
    
    card.addEventListener('dragstart', e => {
      e.dataTransfer.setData('text/plain', card.dataset.id)
    })
    
    column.addEventListener('dragover', e => e.preventDefault())
    
    column.addEventListener('drop', e => {
      e.preventDefault()
      const cardId = e.dataTransfer.getData('text/plain')
      const card = document.querySelector(`[data-id="${cardId}"]`)
      column.querySelector('.cards').appendChild(card)
    })

    Почему нужен preventDefault на dragover

    Браузер по умолчанию запрещает сброс на большинство элементов — чтобы не нарушать стандартное поведение страницы. Вызов event.preventDefault() в обработчике dragover — это явный сигнал браузеру: «эта зона принимает перетаскиваемые элементы».

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

    1. Не вызвать preventDefault в dragover — drop не сработает

    // ПЛОХО — без preventDefault событие drop никогда не придёт
    dropZone.addEventListener('dragover', (event) => {
      // event.preventDefault() забыли!
      dropZone.classList.add('over')
    })
    dropZone.addEventListener('drop', (event) => {
      // Этот обработчик никогда не вызовется
    })
    
    // ХОРОШО — preventDefault разрешает сброс
    dropZone.addEventListener('dragover', (event) => {
      event.preventDefault()  // обязательно!
      dropZone.classList.add('over')
    })

    2. Использовать event.target в drop вместо данных из dataTransfer

    // ПЛОХО — event.target при drop это dropZone, а не перетаскиваемый элемент
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      const dragged = event.target  // НЕПРАВИЛЬНО — это dropZone!
      dropZone.appendChild(dragged)
    })
    
    // ХОРОШО — данные передаются через dataTransfer
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      const id = event.dataTransfer.getData('text/plain')
      const dragged = document.getElementById(id)  // находим по id
      dropZone.appendChild(dragged)
    })

    3. Не очищать стили drag-over при dragleave

    // ПЛОХО — подсветка зоны остаётся после ухода курсора
    dropZone.addEventListener('dragover', (event) => {
      event.preventDefault()
      dropZone.style.border = '2px dashed blue'
    })
    // dragleave не обработан — рамка висит навсегда
    
    // ХОРОШО — убирать стили в dragleave
    dropZone.addEventListener('dragleave', () => {
      dropZone.style.border = ''
    })
    dropZone.addEventListener('drop', (event) => {
      event.preventDefault()
      dropZone.style.border = ''  // тоже убираем при сбросе
    })

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

  • Trello, Jira, Notion: Kanban-доски используют Drag'n'Drop для перемещения карточек между колонками
  • Загрузка файлов: Dropbox, Google Drive — drag файлов с рабочего стола в браузер через то же API (event.dataTransfer.files)
  • Figma, Miro: перетаскивание элементов на холсте реализуется через кастомный DnD поверх Pointer Events
  • Примеры

    Симуляция Kanban-доски: перетаскивание карточек между колонками через mock-объекты (без DOM)

    // Симуляция Kanban-доски с логикой Drag'n'Drop
    // В браузере это были бы реальные DOM-элементы с событиями
    
    // Структура данных доски
    const board = {
      todo:       { title: 'К выполнению', cards: ['Верстка главной', 'API авторизации', 'Тесты'] },
      inProgress: { title: 'В работе',     cards: ['Корзина товаров'] },
      done:       { title: 'Готово',       cards: ['Дизайн макета'] },
    }
    
    function printBoard(board) {
      for (const [key, col] of Object.entries(board)) {
        console.log(`[${col.title}]: ${col.cards.length ? col.cards.join(', ') : '(пусто)'}`)
      }
    }
    
    // --- Mock dataTransfer ---
    function createDataTransfer() {
      const store = {}
      return {
        setData(type, value) { store[type] = value },
        getData(type)        { return store[type] || '' },
        effectAllowed: 'move',
        dropEffect: 'move',
      }
    }
    
    // --- Симуляция событий dragstart / dragover / drop ---
    function simulateDrag(board, fromCol, cardTitle, toCol) {
      // dragstart: запоминаем данные карточки
      const dataTransfer = createDataTransfer()
      const dragStartEvent = { dataTransfer, preventDefault: () => {} }
    
      dataTransfer.setData('text/plain', cardTitle)
      dataTransfer.setData('application/json', JSON.stringify({ card: cardTitle, from: fromCol }))
      console.log(`dragstart: начали тащить "${cardTitle}" из "${board[fromCol].title}"`)
    
      // dragover: preventDefault разрешает drop
      const dragOverEvent = { preventDefault: () => {}, dataTransfer }
      dragOverEvent.preventDefault()
      console.log(`dragover: зона "${board[toCol].title}" приняла перетаскивание`)
    
      // drop: перемещаем карточку
      const dropEvent = { dataTransfer, preventDefault: () => {} }
      dropEvent.preventDefault()
    
      const droppedCard = dropEvent.dataTransfer.getData('text/plain')
      const meta = JSON.parse(dropEvent.dataTransfer.getData('application/json'))
    
      // Убираем из исходной колонки
      board[meta.from].cards = board[meta.from].cards.filter(c => c !== droppedCard)
      // Добавляем в целевую колонку
      board[toCol].cards.push(droppedCard)
    
      console.log(`drop: "${droppedCard}" перемещена в "${board[toCol].title}"`)
    }
    
    console.log('=== Начальное состояние доски ===')
    printBoard(board)
    
    console.log('\n=== Перетаскиваем "API авторизации" → В работе ===')
    simulateDrag(board, 'todo', 'API авторизации', 'inProgress')
    console.log('\nСостояние после:')
    printBoard(board)
    
    console.log('\n=== Перетаскиваем "Корзина товаров" → Готово ===')
    simulateDrag(board, 'inProgress', 'Корзина товаров', 'done')
    console.log('\nФинальное состояние:')
    printBoard(board)
    
    // --- Проверка dropEffect ---
    console.log('\n=== Типы эффектов dataTransfer ===')
    const dt = createDataTransfer()
    dt.effectAllowed = 'copyMove'
    dt.setData('text/plain', 'test-data')
    console.log('effectAllowed:', dt.effectAllowed)
    console.log('getData:', dt.getData('text/plain'))

    Задание

    Реализуй функцию moveCard(board, cardId, fromColumn, toColumn), которая перемещает карточку между колонками Kanban-доски. Если карточка не найдена в исходной колонке — выбрось ошибку. Также реализуй getColumn(board, cardId), которая находит в какой колонке находится карточка.

    Подсказка

    indexOf(cardId), если === -1 то ошибка. splice(index, 1) удаляет элемент. push(cardId) добавляет. includes(cardId) в getColumn.

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