internals

Drag and Drop helpers

Общие помощники Pragmatic Drag and Drop (клавиатурный drag, reorder, прозрачный пиксель) — см. ADR-002.

Загрузка превью…

src/lib/dnd/keyboard-drag.ts

/**
 * Общая клавиатурная машина состояний для reui dnd-компонентов
 * (Sortable, Kanban) — рукописная замена dnd-kit `KeyboardSensor` +
 * `sortableKeyboardCoordinates` (см. docs/adr/002-drag-and-drop.md и
 * комментарий в шапке Sortable.vue).
 *
 * Состояния: "не взят" -> Space/Enter -> "взят" -> стрелки двигают на
 * шаг (коммитится немедленно, а не при отпускании) -> Space/Enter
 * подтверждает, Escape отменяет (восстановлением снимка, снятого в
 * момент захвата — снимок и его восстановление остаются на стороне
 * вызывающего компонента, здесь только переходы состояний).
 *
 * Компонент-обёртка (SortableItem.vue / KanbanItemHandle.vue /
 * KanbanColumnHandle.vue) отвечает за:
 *  - решение, можно ли вообще взять элемент (disabled/isOverlay) —
 *    до вызова возвращённого обработчика;
 *  - собственно `grabbed`-флаг и снимок для отмены;
 *  - что означает "подвинуть на шаг" для конкретного контейнера
 *    (соседний элемент в том же списке, соседняя колонка и т.д.).
 */
export interface KeyboardDragCallbacks {
  /** Взят ли сейчас элемент (управляется вызывающим компонентом). */
  isGrabbed: () => boolean
  onGrab: () => void
  onMove: (direction: -1 | 1) => void
  onDrop: () => void
  onCancel: () => void
}

export function createKeyboardDragHandler(
  callbacks: KeyboardDragCallbacks
): (event: KeyboardEvent) => void {
  return function handleKeydown(event: KeyboardEvent): void {
    if (!callbacks.isGrabbed()) {
      if (event.key === " " || event.key === "Enter") {
        event.preventDefault()
        callbacks.onGrab()
      }
      return
    }

    switch (event.key) {
      case "ArrowUp":
      case "ArrowLeft":
        event.preventDefault()
        callbacks.onMove(-1)
        break
      case "ArrowDown":
      case "ArrowRight":
        event.preventDefault()
        callbacks.onMove(1)
        break
      case " ":
      case "Enter":
        event.preventDefault()
        callbacks.onDrop()
        break
      case "Escape":
        event.preventDefault()
        callbacks.onCancel()
        break
    }
  }
}

src/lib/dnd/reorder.ts

/**
 * Общая математика переупорядочивания для reui dnd-компонентов
 * (Sortable, Kanban), см. docs/adr/002-drag-and-drop.md.
 *
 * Та же семантика, что `arrayMove` из `@dnd-kit/sortable` /
 * `@atlaskit/pragmatic-drag-and-drop/reorder`: элемент вынимается с
 * позиции `from` и вставляется на позицию `to` в получившемся (уже
 * укороченном на один элемент) массиве.
 */
export function reorderArray<T>(list: T[], from: number, to: number): T[] {
  const result = list.slice()
  const [moved] = result.splice(from, 1)
  result.splice(to, 0, moved as T)
  return result
}

/**
 * Перемещение элемента между двумя записями `Record<string, T[]>`
 * (или в пределах одной и той же записи) — обобщение `reorderArray` на
 * kanban-подобные многоконтейнерные структуры. `targetIndex` — индекс
 * элемента, оказавшегося под курсором в ЦЕЛЕВОМ контейнере (индекс "over"
 * ДО удаления перемещаемого элемента из исходного) — та же величина, что
 * `overIndex` в `reorderArray`/dnd-kit `arrayMove`. Если контейнер тот же
 * самый, удаление и вставка выполняются как два последовательных `splice`
 * (как в `reorderArray`) — раздельный пересчёт индекса не нужен: `splice`
 * сам корректно "сдвигает" массив между операциями.
 */
export function moveBetweenContainers<T>(
  columns: Record<string, T[]>,
  getItemValue: (item: T) => string,
  fromContainer: string,
  itemValue: string,
  toContainer: string,
  targetIndex: number
): Record<string, T[]> {
  const source = columns[fromContainer]
  if (!source) return columns
  const activeIndex = source.findIndex((item) => getItemValue(item) === itemValue)
  if (activeIndex === -1) return columns

  const isSame = fromContainer === toContainer

  const newSource = source.slice()
  const [moved] = newSource.splice(activeIndex, 1)

  if (isSame) {
    newSource.splice(targetIndex, 0, moved as T)
    return { ...columns, [fromContainer]: newSource }
  }

  const dest = (columns[toContainer] ?? []).slice()
  dest.splice(targetIndex, 0, moved as T)
  return { ...columns, [fromContainer]: newSource, [toContainer]: dest }
}

src/lib/dnd/transparent-pixel.ts

/**
 * Общий прозрачный пиксель для гашения нативной картинки перетаскивания
 * (`nativeSetDragImage`) у `@atlaskit/pragmatic-drag-and-drop` — см.
 * комментарий в шапке Sortable.vue про замену `DragOverlay` на
 * `position: fixed` Teleport-оверлей. Используется и Sortable, и Kanban.
 */
let cachedTransparentPixel: HTMLImageElement | undefined

export function transparentPixel(): HTMLImageElement {
  if (!cachedTransparentPixel) {
    const img = new Image()
    img.src =
      "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7"
    cachedTransparentPixel = img
  }
  return cachedTransparentPixel
}

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/dnd.json

Источник: порт из ReUI (Keenthemes, MIT)