Skip to main content

assets/js/athanor.js

// Athanor LiveView hooks.
//
// Two hooks power the page-builder drag-and-drop:
//
//   • AthanorDragSource — marks an element as a drag source. Reads
//     `data-athanor-source` ("palette" | "tree") plus either
//     `data-athanor-type` (palette) or `data-athanor-node-id` (tree)
//     and stuffs them into the dataTransfer payload on `dragstart`.
//
//   • AthanorDropZone — marks an element as a drop target. Reads
//     `data-athanor-target-parent-id` ("root" or a node id),
//     `data-athanor-target-zone` (zone name, default "content"), and
//     optionally `data-athanor-target-index`. When the zone is a *list*
//     of child slots, the hook computes the insertion index from the
//     cursor's vertical midpoint against each direct child.
//
// On drop the hook pushes the LiveView event `athanor:dnd_drop` with:
//   { source, type?, node_id?,
//     target_parent_id, target_zone, target_index }
//
// Wire into your LiveSocket:
//
//   import { AthanorHooks } from "athanor"
//   let liveSocket = new LiveSocket("/live", Socket, {
//     hooks: { ...AthanorHooks }
//   })
//
// No external runtime deps. Uses native HTML5 DnD.

const PAYLOAD_MIME = "application/x-athanor-dnd"
const DROP_INDICATOR_CLASS = "athanor-drop-target"
const INDICATOR_ATTR = "data-athanor-indicator"
const STYLE_ELEMENT_ID = "athanor-dnd-styles"

// Inject the minimal CSS the hooks rely on (drop-zone highlight, source
// drag-ghost opacity, insertion-line indicator). Idempotent — safe to
// call from every hook mount.
function ensureStylesInjected() {
  if (document.getElementById(STYLE_ELEMENT_ID)) return
  const style = document.createElement("style")
  style.id = STYLE_ELEMENT_ID
  style.textContent = `
    .athanor-dragging { opacity: 0.5; }
    .${DROP_INDICATOR_CLASS} {
      outline: 2px dashed var(--color-primary, #3b82f6);
      outline-offset: 2px;
      background-color: color-mix(in srgb, var(--color-primary, #3b82f6) 6%, transparent);
    }
    [${INDICATOR_ATTR}] {
      position: absolute;
      left: 0;
      right: 0;
      height: 3px;
      background: var(--color-primary, #3b82f6);
      box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary, #3b82f6) 35%, transparent);
      border-radius: 2px;
      pointer-events: none;
      transform: translateY(-1.5px);
      display: none;
      z-index: 20;
    }
    [${INDICATOR_ATTR}]::before,
    [${INDICATOR_ATTR}]::after {
      content: "";
      position: absolute;
      top: 50%;
      width: 8px;
      height: 8px;
      border-radius: 9999px;
      background: var(--color-primary, #3b82f6);
      transform: translateY(-50%);
    }
    [${INDICATOR_ATTR}]::before { left: -4px; }
    [${INDICATOR_ATTR}]::after  { right: -4px; }
  `
  document.head.appendChild(style)
}

const AthanorDragSource = {
  mounted() {
    ensureStylesInjected()
    this.el.setAttribute("draggable", "true")
    this.el.addEventListener("dragstart", (e) => {
      const payload = {
        source: this.el.dataset.athanorSource,
      }
      if (payload.source === "palette") {
        payload.type = this.el.dataset.athanorType
      } else if (payload.source === "tree") {
        payload.node_id = this.el.dataset.athanorNodeId
      }
      e.dataTransfer.effectAllowed = "move"
      e.dataTransfer.setData(PAYLOAD_MIME, JSON.stringify(payload))
      this.el.classList.add("athanor-dragging")
    })
    this.el.addEventListener("dragend", () => {
      this.el.classList.remove("athanor-dragging")
    })
  },
}

const AthanorDropZone = {
  mounted() {
    ensureStylesInjected()

    // Indicator needs the zone as its positioning context.
    if (getComputedStyle(this.el).position === "static") {
      this.el.style.position = "relative"
    }

    this.indicator = document.createElement("div")
    this.indicator.setAttribute(INDICATOR_ATTR, "true")
    this.el.appendChild(this.indicator)

    this.el.addEventListener("dragover", (e) => {
      // Allow drop. Required — without preventDefault, "drop" never fires.
      e.preventDefault()
      e.dataTransfer.dropEffect = "move"
      this.el.classList.add(DROP_INDICATOR_CLASS)
      updateIndicator(this.el, this.indicator, e.clientY)
    })

    this.el.addEventListener("dragleave", (e) => {
      // Ignore dragleave that's just into a descendant.
      if (this.el.contains(e.relatedTarget)) return
      this.el.classList.remove(DROP_INDICATOR_CLASS)
      this.indicator.style.display = "none"
    })

    this.el.addEventListener("drop", (e) => {
      e.preventDefault()
      this.el.classList.remove(DROP_INDICATOR_CLASS)
      this.indicator.style.display = "none"

      const raw = e.dataTransfer.getData(PAYLOAD_MIME)
      if (!raw) return
      let source
      try {
        source = JSON.parse(raw)
      } catch (_err) {
        return
      }

      const targetParentId =
        this.el.dataset.athanorTargetParentId || "root"
      const targetZone =
        this.el.dataset.athanorTargetZone || "content"

      // If the data attr sets an explicit index, use it. Otherwise
      // compute by cursor Y vs each direct child's midpoint.
      let targetIndex
      if (this.el.dataset.athanorTargetIndex !== undefined) {
        targetIndex = parseInt(this.el.dataset.athanorTargetIndex, 10) || 0
      } else {
        targetIndex = computeDropIndex(this.el, e.clientY)
      }

      // Don't drop a node onto itself (the "I dragged but landed in the
      // same slot" case — server is idempotent but we avoid the round-trip).
      if (
        source.source === "tree" &&
        source.node_id &&
        this.el.dataset.athanorNodeId === source.node_id
      ) {
        return
      }

      this.pushEvent("athanor:dnd_drop", {
        source: source.source,
        type: source.type,
        node_id: source.node_id,
        target_parent_id: targetParentId,
        target_zone: targetZone,
        target_index: targetIndex,
      })
    })
  },

  destroyed() {
    if (this.indicator && this.indicator.parentNode) {
      this.indicator.parentNode.removeChild(this.indicator)
    }
  },
}

// Position the insertion-line indicator inside the zone at the cursor's
// computed insertion point. For an empty zone the indicator is hidden —
// the zone-level highlight (outline + tinted background) carries the
// feedback alone.
function updateIndicator(zoneEl, indicator, clientY) {
  const items = Array.from(
    zoneEl.querySelectorAll(":scope > [data-athanor-drop-item]")
  )
  if (items.length === 0) {
    indicator.style.display = "none"
    return
  }

  const zoneRect = zoneEl.getBoundingClientRect()
  let idx = items.length
  for (let i = 0; i < items.length; i++) {
    const rect = items[i].getBoundingClientRect()
    if (clientY < rect.top + rect.height / 2) {
      idx = i
      break
    }
  }

  let y
  if (idx < items.length) {
    y = items[idx].getBoundingClientRect().top - zoneRect.top
  } else {
    y = items[items.length - 1].getBoundingClientRect().bottom - zoneRect.top
  }

  indicator.style.top = `${y + zoneEl.scrollTop}px`
  indicator.style.display = "block"
}

// Pick an insertion index inside a drop zone based on cursor Y.
// Direct children that themselves carry `data-athanor-drop-item` are
// treated as the slot list. The cursor's vertical position decides
// whether the new item lands before or after each child.
function computeDropIndex(zoneEl, clientY) {
  const items = Array.from(
    zoneEl.querySelectorAll(":scope > [data-athanor-drop-item]")
  )
  if (items.length === 0) return 0

  for (let i = 0; i < items.length; i++) {
    const rect = items[i].getBoundingClientRect()
    const midpoint = rect.top + rect.height / 2
    if (clientY < midpoint) return i
  }
  return items.length
}

export const AthanorHooks = {
  AthanorDragSource,
  AthanorDropZone,
}

export default AthanorHooks