[
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n_site\n.jekyll-cache\n"
  },
  {
    "path": "README.md",
    "content": "# picknplace.js\n\nA proof of concept of a viable drag and drop alternative.\n\n<a href=\"https://jgthms.com/picknplace.js/\"><img width=\"1200\" height=\"630\" alt=\"facebook\" src=\"https://github.com/user-attachments/assets/321c7c2f-c523-408d-884a-d87c4cb9130f\" /></a>\n\n### Why?\n\nI find that the drag and drop experience can quickly become a nightmare, especially on mobile.\nTrying to tap, hold, drag, and scroll, all at the _same time_, is awkward, slow, and error-prone.\nI've long had in mind a simpler 2-step approach: picking an item first, _then_ placing it.\nSo I implemented this basic version to showcase my idea.\n\n### How it works\n\nWhen picking an item, a duplicate of the list is created on top of the original one.\nThe duplicate is interactive and animated, and will update based on the scroll position.\nAt the end, the user can either confirm or cancel the changes.\n\n### Is this a library?\n\nNot exactly. This is merely a proof of concept, to convey what I had in mind.\nYou can however look at the source code, for inspiration.\n"
  },
  {
    "path": "index.css",
    "content": "/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */\nhtml,\nbody,\np,\nol,\nul,\nli,\ndl,\ndt,\ndd,\nblockquote,\nfigure,\nfieldset,\nlegend,\ntextarea,\npre,\niframe,\nhr,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  margin: 0;\n  padding: 0;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  font-size: 100%;\n  font-weight: normal;\n}\nul {\n  list-style: none;\n}\nbutton,\ninput,\nselect {\n  margin: 0;\n}\nhtml {\n  box-sizing: border-box;\n}\n*,\n*::before,\n*::after {\n  box-sizing: inherit;\n}\nimg,\nvideo {\n  height: auto;\n  max-width: 100%;\n}\niframe {\n  border: 0;\n}\ntable {\n  border-collapse: collapse;\n  border-spacing: 0;\n}\ntd,\nth {\n  padding: 0;\n}\n\n:root {\n  --yellow: #fabd1b;\n  --orange: #ff8411;\n  --crimson: #ff6738;\n  --pink: #ff7e75;\n  --purple: #ae9eff;\n  --cyan: #18c6f6;\n  --blue: #5294ff;\n  --teal: #00c2a8;\n  --green: #00d66b;\n  --lime: #b3db00;\n  --keyboardfocus-color: #000;\n}\n\nhtml {\n  font-family: Poppins, Inter, \"Inter Variable\", system-ui, sans-serif;\n  line-height: 1.5;\n  font-weight: 400;\n\n  background-color: #fcf5ef;\n  color: #999999;\n\n  font-synthesis: none;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n\n  padding: 0 3em;\n}\n\nbody,\nheader,\nfooter,\nmain,\n.pnp-list {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n\nh1,\nstrong,\nem,\nbutton {\n  color: #111;\n  font-weight: 500;\n}\n\nem {\n  font-style: normal;\n}\n\nheader,\nfooter {\n  gap: 1em;\n  min-height: 86vh;\n  padding: 6em 0;\n}\n\nfooter {\n  gap: 3em;\n\n  p:not(:first-child) {\n    color: #666;\n    margin-top: 0.25em;\n  }\n}\n\na,\nbutton {\n  color: inherit;\n  cursor: pointer;\n  text-decoration: none;\n}\n\nbutton:hover {\n  text-decoration: underline;\n}\n\na {\n  border-bottom: 2px solid var(--yellow);\n  color: #111;\n  transition-duration: 200ms;\n  transition-property: background-color;\n\n  &:hover {\n    background-color: var(--yellow);\n  }\n}\n\n.accent {\n  color: var(--yellow);\n}\n\n.accent-bg {\n  strong {\n    background-color: var(--yellow);\n    display: inline-flex;\n    padding: 0.125em 0.5em;\n    border-radius: 0.5em;\n  }\n}\n\nkbd {\n  background-color: var(--yellow);\n  background-color: rgba(0, 0, 0, 0.1);\n  color: #333;\n  display: inline-flex;\n  font-family: inherit;\n  padding: 0.125em 0.5em;\n  border-radius: 0.5em;\n}\n\nbody {\n  align-items: center;\n}\n\nbutton {\n  appearance: none;\n  background: none;\n  border: none;\n  color: #111;\n  outline-color: transparent;\n  outline-width: 0;\n  font-family: inherit;\n  font-weight: 500;\n  font-size: 1em;\n  line-height: inherit;\n  margin: 0;\n  padding: 0;\n}\n\nmain {\n  align-items: stretch;\n  flex-grow: 1;\n  max-width: 21em;\n  width: 100%;\n  gap: 0;\n}\n\nh1 {\n  font-size: 1.5em;\n  font-weight: 600;\n  letter-spacing: -0.04em;\n}\n\n.pnp-list {\n  --spacing: 0.5em;\n  padding: var(--spacing);\n  gap: var(--spacing);\n  background-color: #fff;\n  align-items: stretch;\n  border-radius: 1em;\n  list-style: none;\n  position: relative;\n}\n\n.element {\n  background-color: var(--color);\n  display: flex;\n  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06),\n    0 -1px 0 0 rgba(0, 0, 0, 0.1) inset;\n  border-radius: 0.5em;\n  padding: 0.5em 0.75em;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.pnp-list.is-ready {\n  .pnp-clone {\n    transition-duration: 200ms;\n    transition-property: transform;\n    transition-timing-function: ease-out;\n  }\n}\n\n.pnp-picked {\n  opacity: 0;\n}\n\n.pnp-buttons {\n  display: flex;\n  align-items: center;\n  gap: 0.5em;\n  justify-content: end;\n}\n\n.pnp-controls {\n  position: fixed;\n  z-index: 10;\n  left: 0;\n  right: 0;\n  display: flex;\n  align-items: center;\n  padding: 2em;\n  justify-content: center;\n  gap: 1em;\n  top: 100%;\n  opacity: 0;\n  transition-duration: 200ms;\n  transition-property: opacity, transform;\n  pointer-events: none;\n}\n\n.button {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0.5em 1em;\n  border-radius: 9999px;\n\n  &.is-primary {\n    background-color: #111;\n    color: #fff;\n  }\n}\n\n@keyframes animRotate {\n  from {\n    transform: none;\n  }\n\n  to {\n    transform: scale(1.04) rotate(-4deg);\n  }\n}\n\n.element {\n  transform-origin: center;\n  will-change: transform;\n  animation-duration: 100ms;\n  animation-fill-mode: both;\n  animation-timing-function: ease-out;\n}\n\n.pnp-ghost {\n  --offset: 0px;\n  list-style: none;\n  position: fixed;\n  left: 0;\n  top: 0;\n  opacity: 1;\n}\n\n.pnp-list {\n  &[data-mode=\"picking\"] {\n    .pnp-buttons {\n      opacity: 0;\n      pointer-events: none;\n    }\n  }\n}\n.element:focus-within {\n  outline: 2px solid var(--keyboardfocus-color);\n  outline-offset: 2px;\n}\n.pnp-list {\n  &[data-mode=\"picking\"] {\n    .pnp-item:not(.pnp-clone) {\n      opacity: 0;\n      pointer-events: none;\n    }\n  }\n}\n\n/* Desktop */\n@media (hover: hover) {\n}\n\n/* Mobile */\n@media (hover: none) {\n  small {\n    display: none;\n  }\n\n  .pnp-controls {\n    &.is-active {\n      opacity: 1;\n      transform: translateY(-100%);\n      pointer-events: auto;\n    }\n  }\n\n  .pnp-ghost {\n    .pnp-buttons {\n      opacity: 0;\n      pointer-events: none;\n    }\n  }\n}\n\n.element.n1 {\n  --color: var(--yellow);\n}\n.element.n2 {\n  --color: var(--orange);\n}\n.element.n3 {\n  --color: var(--crimson);\n}\n.element.n4 {\n  --color: var(--pink);\n}\n.element.n5 {\n  --color: var(--purple);\n}\n.element.n6 {\n  --color: var(--cyan);\n}\n.element.n7 {\n  --color: var(--blue);\n}\n.element.n8 {\n  --color: var(--teal);\n}\n.element.n9 {\n  --color: var(--green);\n}\n.element.n10 {\n  --color: var(--lime);\n}\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\n    <!-- Open Graph -->\n    <meta property=\"og:site_name\" content=\"picknplace.js\" />\n    <meta property=\"og:title\" content=\"picknplace.js\" />\n    <meta\n      property=\"og:description\"\n      content=\"A proof of concept of a viable drag and drop alternative\"\n    />\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:url\" content=\"https://jgthms.com/picknplace.js/\" />\n\n    <meta\n      property=\"og:image\"\n      content=\"https://jgthms.com/picknplace.js/images/facebook.png\"\n    />\n    <meta property=\"og:image:width\" content=\"1200\" />\n    <meta property=\"og:image:height\" content=\"630\" />\n    <meta property=\"og:image:alt\" content=\"picknplace.js logo\" />\n\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <meta name=\"twitter:title\" content=\"picknplace.js\" />\n    <meta\n      name=\"twitter:description\"\n      content=\"A proof of concept of a viable drag and drop alternative\"\n    />\n    <meta\n      name=\"twitter:image\"\n      content=\"https://jgthms.com/picknplace.js/images/twitter.png\"\n    />\n    <meta name=\"twitter:image:alt\" content=\"picknplace.js logo\" />\n\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link\n      href=\"https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <link href=\"index.css\" rel=\"stylesheet\" />\n\n    <title>picknplace.js, an alternative to drag and drop</title>\n    <script defer src=\"index.js\" type=\"module\"></script>\n  </head>\n  <body>\n    <main>\n      <header>\n        <div>\n          <h1>picknplace<span class=\"accent\">.js</span></h1>\n          <p>an alternative to <strong>drag and drop</strong></p>\n        </div>\n\n        <p class=\"accent-bg\">\n          <em>3</em> steps: <strong>pick</strong> -> <strong>scroll</strong> ->\n          <strong>place</strong>\n        </p>\n\n        <small>\n          Or use <kbd>Enter</kbd> to place, <kbd>Esc</kbd> to cancel\n        </small>\n      </header>\n\n      <ol class=\"pnp-list\">\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n1\">\n            <strong>One</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n2\">\n            <strong>Two</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n3\">\n            <strong>Three</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n4\">\n            <strong>Four</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n5\">\n            <strong>Five</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n6\">\n            <strong>Six</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n7\">\n            <strong>Seven</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n8\">\n            <strong>Eight</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n9\">\n            <strong>Nine</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n        <li class=\"pnp-item pnp-real\">\n          <div class=\"element n10\">\n            <strong>Ten</strong>\n            <div class=\"pnp-buttons\">\n              <button class=\"pnp-pick\" type=\"button\">Pick</button>\n            </div>\n          </div>\n        </li>\n      </ol>\n\n      <div class=\"pnp-controls\">\n        <button class=\"pnp-cancel button\">Cancel</button>\n        <button class=\"pnp-place button is-primary\">Place</button>\n      </div>\n\n      <footer>\n        <div>\n          <p>Why?</p>\n          <p>\n            I find that the drag and drop experience can quickly become a\n            nightmare, especially on mobile.\n          </p>\n          <p>\n            Trying to tap, hold, drag, and scroll, all at the same time, is\n            awkward, slow, and error-prone.\n          </p>\n          <p>\n            I've long had in mind a simpler 2-step approach: picking an item\n            first, then placing it.\n          </p>\n          <p>\n            So I implemented this basic version to showcase my idea.\n          </p>\n        </div>\n\n        <div>\n          <p>How does this work?</p>\n          <p>\n            When picking an item, a duplicate of the list is created on top of\n            the original one.\n          </p>\n          <p>\n            The duplicate is interactive and animated, and will update based on\n            the scroll position.\n          </p>\n          <p>\n            At the end, the user can either confirm or cancel the changes.\n          </p>\n        </div>\n\n        <div>\n          <p>Is this a library?</p>\n          <p>\n            Not exactly. This is merely a proof of concept, to convey what I had\n            in mind.\n          </p>\n          <p>\n            You can however look at the\n            <a href=\"https://github.com/jgthms/picknplace.js/\"\n              >source code</a\n            >, for inspiration.\n          </p>\n        </div>\n\n        <div>\n          <p>Who are you?</p>\n          <p>\n            I'm <a href=\"https://jgthms.com/\">@jgthms</a>, a frontend designer.\n          </p>\n          <p>\n            Find me on <a href=\"https://x.com/jgthms\">Twitter</a> and\n            <a href=\"https://github.com/jgthms\">Github</a>.\n          </p>\n        </div>\n      </footer>\n    </main>\n  </body>\n</html>\n"
  },
  {
    "path": "index.js",
    "content": "import { createPickPlace } from \"./picknplace.js\";\n\nconst pnp = createPickPlace();\npnp.init();\n"
  },
  {
    "path": "picknplace.js",
    "content": "export function createPickPlace(options = {}) {\n  const root = options.root || document;\n  const listSelector = options.listSelector || \".pnp-list\";\n  const itemSelector = options.itemSelector || \".pnp-item\";\n  const controlsSelector = options.controlsSelector || \".pnp-controls\";\n  const buttonsSelector = options.buttonsSelector || \".pnp-buttons\";\n  const pickSelector = options.pickSelector || \".pnp-pick\";\n  const placeSelector = options.placeSelector || \".pnp-place\";\n  const cancelSelector = options.cancelSelector || \".pnp-cancel\";\n  const pickedClass = options.pickedClass || \"pnp-picked\";\n  const realClass = options.realClass || \"pnp-real\";\n  const ghostClass = options.ghostClass || \"pnp-ghost\";\n  const cloneClass = options.cloneClass || \"pnp-clone\";\n\n  let initialized = false;\n  let $ghost = null;\n  let scrollDirY = 0;\n  let lastScrollY = window.scrollY;\n  let targetIndex = null;\n  let $controls = null;\n  let ghostTop = 0;\n  let ghostOffset = 0;\n\n  // State\n  let state = {\n    mode: \"idle\",\n    $list: null,\n    $item: null,\n    originalTop: 0,\n    positions: [],\n    currentIndex: null,\n  };\n\n  const reduce = (state, event) => {\n    switch (event.type) {\n      case \"pick\":\n        return {\n          mode: \"picking\",\n          $item: event.$item,\n          $list: event.$list,\n          originalTop: event.originalTop,\n          positions: event.positions,\n          currentIndex: event.currentIndex,\n        };\n\n      case \"place\":\n        return {\n          mode: \"idle\",\n          $list: null,\n          $item: null,\n          originalTop: 0,\n          positions: [],\n          currentIndex: null,\n        };\n\n      case \"cancel\":\n        return {\n          mode: \"idle\",\n          $list: null,\n          $item: null,\n          originalTop: 0,\n          positions: [],\n          currentIndex: null,\n        };\n\n      default:\n        return state;\n    }\n  };\n\n  const dispatch = (event) => {\n    const prev = state;\n    const next = reduce(state, event);\n\n    if (next === prev) {\n      return;\n    }\n\n    // Store and reset\n    state = next;\n    targetIndex = null;\n\n    // Update the DOM\n    const $list = prev.$list || next.$list;\n\n    if ($list) {\n      $list.dataset.mode = next.mode;\n    }\n\n    if (next.mode === \"picking\") {\n      next.$item?.classList.add(pickedClass);\n      setPickingMode();\n      next.$list?.classList.add(\"is-ready\");\n      createGhost(next.$item);\n\n      if ($controls) {\n        $controls.classList.add(\"is-active\");\n      }\n    }\n\n    if (next.mode === \"idle\") {\n      prev.$item?.classList.remove(pickedClass);\n\n      if (prev.$list) {\n        prev.$list.classList.remove(\"is-ready\");\n        setIdleMode(prev.$list);\n      }\n\n      if ($controls) {\n        $controls.classList.remove(\"is-active\");\n      }\n\n      destroyGhost();\n    }\n\n    if (event.type === \"place\") {\n      sortDomByNewIndices(prev.$list, prev.positions);\n    }\n  };\n\n  // DOM\n  const sortDomByNewIndices = ($list, positions) => {\n    const ordered = positions\n      .slice()\n      .sort((a, b) => a.currentIndex - b.currentIndex);\n\n    const frag = document.createDocumentFragment();\n    for (const p of ordered) frag.appendChild(p.el);\n\n    $list.appendChild(frag);\n  };\n\n  // Ghost\n  const createGhost = (item) => {\n    destroyGhost();\n\n    const clone = item.cloneNode(true);\n    clone.classList.add(ghostClass);\n\n    const rect = item.getBoundingClientRect();\n\n    Object.assign(clone.style, {\n      position: \"fixed\",\n      width: `${rect.width}px`,\n      height: `${rect.height}px`,\n      transform: `translate(${rect.left}px, calc(${rect.top}px + var(--offset))`,\n    });\n    ghostTop = rect.top;\n\n    const buttons = clone.querySelector(buttonsSelector);\n\n    if (buttons) {\n      buttons.innerHTML = `\n        <button class=\"pnp-cancel\" type=\"button\">Cancel</button>\n        <button class=\"pnp-place\" type=\"button\">Place</button>\n      `;\n    }\n\n    document.body.appendChild(clone);\n    $ghost = clone;\n  };\n\n  const destroyGhost = () => {\n    if ($ghost) {\n      $ghost.remove();\n    }\n\n    $ghost = null;\n  };\n\n  // Events\n  const onKeyDown = (event) => {\n    if (event.key === \"Escape\") {\n      return dispatch({\n        type: \"cancel\",\n      });\n    }\n\n    if (event.key === \"Enter\") {\n      return dispatch({\n        type: \"place\",\n      });\n    }\n  };\n\n  const onClick = (event) => {\n    const target = event.target;\n\n    if (!(target instanceof Element)) {\n      return;\n    }\n\n    const cancelBtn = target.closest(cancelSelector);\n\n    if (cancelBtn) {\n      return dispatch({\n        type: \"cancel\",\n      });\n    }\n\n    const placeBtn = target.closest(placeSelector);\n\n    if (placeBtn) {\n      return dispatch({\n        type: \"place\",\n      });\n    }\n\n    const pickBtn = target.closest(pickSelector);\n\n    if (pickBtn) {\n      event.preventDefault();\n      event.stopPropagation();\n\n      if (state.mode === \"picking\") {\n        return dispatch({\n          type: \"cancel\",\n        });\n      }\n\n      const $item = pickBtn.closest(itemSelector);\n      const $list = $item?.closest(listSelector);\n\n      if (!$item || !$list) {\n        return;\n      }\n\n      const listRect = $list.getBoundingClientRect();\n\n      const $items = Array.from($list.children);\n      const currentIndex = $items.indexOf($item);\n\n      const positions = $items.map((el, index) => {\n        const rect = el.getBoundingClientRect();\n\n        return {\n          el,\n          clone: null,\n          originalIndex: index,\n          currentIndex: index,\n          originalTop: rect.top,\n          rect,\n        };\n      });\n\n      return dispatch({\n        type: \"pick\",\n        $list,\n        $item,\n        originalTop: listRect.top,\n        positions,\n        currentIndex,\n      });\n    }\n  };\n\n  const swapByIndex = (positions, indexA, indexB) => {\n    if (indexA === indexB) return;\n\n    const a = positions.find((p) => p.currentIndex === indexA);\n    const b = positions.find((p) => p.currentIndex === indexB);\n\n    if (!a || !b) return;\n\n    a.currentIndex = indexB;\n    b.currentIndex = indexA;\n  };\n\n  let scrollRaf = null;\n\n  const onScroll = (event) => {\n    if (state.mode !== \"picking\" || !$ghost || scrollRaf) {\n      return;\n    }\n\n    const y = window.scrollY;\n    scrollDirY = y - lastScrollY;\n    lastScrollY = y;\n\n    scrollRaf = requestAnimationFrame(() => {\n      scrollRaf = null;\n\n      const ghostRect = $ghost.getBoundingClientRect();\n      const ghostCenter = {\n        x: ghostRect.left + ghostRect.width / 2,\n        y: ghostRect.top + ghostRect.height / 2,\n      };\n\n      const $items = Array.from(state.$list.children).filter(\n        (x) => !x.classList.contains(cloneClass)\n      );\n\n      let newTargetIndex;\n\n      const listRect = state.$list.getBoundingClientRect();\n      const listSpacing = parseFloat(getComputedStyle(state.$list).paddingTop);\n\n      // If the ghost goes above the list\n      if (listRect.top + listSpacing > ghostTop) {\n        ghostOffset = listRect.top - ghostTop + listSpacing;\n      } else if (\n        ghostTop >\n        listRect.top + listRect.height - ghostRect.height - listSpacing\n      ) {\n        const diff =\n          listRect.top + listRect.height - ghostRect.height - listSpacing;\n        ghostOffset = -1 * ghostTop + diff;\n      } else {\n        ghostOffset = 0;\n      }\n\n      $ghost.style.setProperty(\"--offset\", `${ghostOffset}px`);\n\n      if (ghostCenter.y > listRect.top + listRect.height) {\n        newTargetIndex = $items.length - 1;\n      } else if (scrollDirY >= 0) {\n        // Going down: swap when center crosses the top edge\n        for (const [index, $item] of $items.entries()) {\n          const rect = $item.getBoundingClientRect();\n\n          if (ghostCenter.y < rect.top) {\n            newTargetIndex = index - 1;\n            break;\n          }\n\n          newTargetIndex = $items.length - 1;\n        }\n      } else {\n        // Going up: swap when center crosses the bottom edge\n        for (const [index, $item] of $items.entries()) {\n          const rect = $item.getBoundingClientRect();\n\n          if (ghostCenter.y < rect.bottom) {\n            newTargetIndex = index;\n            break;\n          }\n\n          newTargetIndex = 0;\n        }\n      }\n\n      if (newTargetIndex !== targetIndex) {\n        swapByIndex(state.positions, targetIndex, newTargetIndex);\n        transformItems();\n        targetIndex = newTargetIndex;\n      }\n    });\n  };\n\n  // Modes\n  const setPickingMode = () => {\n    const listRect = state.$list.getBoundingClientRect();\n\n    for (const position of state.positions) {\n      const { el, rect } = position;\n      const clone = el.cloneNode(true);\n      clone.classList.remove(realClass);\n      clone.classList.add(cloneClass);\n      position.clone = clone;\n\n      Object.assign(clone.style, {\n        position: \"absolute\",\n        left: \"0px\",\n        top: \"0px\",\n        width: `${rect.width}px`,\n        transform: `translate(\n          ${rect.left - listRect.left}px,\n          ${rect.top - listRect.top}px\n        )`,\n      });\n\n      state.$list.appendChild(clone);\n    }\n  };\n\n  const transformItems = () => {\n    const listRect = state.$list.getBoundingClientRect();\n\n    for (const position of state.positions) {\n      const { clone, currentIndex, rect } = position;\n\n      let top = rect.top;\n\n      const found = state.positions.find(\n        (pos) => currentIndex === pos.originalIndex\n      );\n\n      if (found && found.originalTop) {\n        top = found.originalTop;\n      }\n\n      Object.assign(clone.style, {\n        transform: `translate(\n          ${rect.left - listRect.left}px,\n          ${top - state.originalTop}px\n        )`,\n      });\n    }\n  };\n\n  const setIdleMode = ($list) => {\n    const clones = $list.querySelectorAll(`.${cloneClass}`);\n\n    for (const clone of clones) {\n      clone.remove();\n    }\n  };\n\n  // Lifecyle\n  const init = () => {\n    if (initialized) {\n      return;\n    }\n\n    $controls = root.querySelector(controlsSelector);\n    root.addEventListener(\"click\", onClick, true);\n    root.addEventListener(\"keydown\", onKeyDown, true);\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n\n    initialized = true;\n  };\n\n  return {\n    init,\n  };\n}\n"
  }
]