/** * Drag-and-drop reorder for period recipe cards (#recipesList). * Drag starts from .recipe-drag-handle; uses the same API as arrow buttons. * Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line. */ import { cloneTemplateInto } from "./recipe-skeleton.js"; const MOBILE_REORDER_MQ = "(max-width: 768px)"; const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 }; const AUTO_SCROLL_EDGE_PX = 72; const AUTO_SCROLL_MAX_STEP = 28; const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking"; function isMobileReorderUi() { return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false; } function activationThresholdPx() { return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop; } function transferBlocksReorder() { const listRoot = document.getElementById("recipesList"); if ( listRoot?.classList.contains("recipe-list-reorder-active") || document.body.classList.contains("recipe-list-reorder-active") ) { return false; } return ( document.body.classList.contains("recipe-mobile-transfer-armed") || document.body.classList.contains("recipe-period-transfer-active") ); } function removeRecipeDragGhost() { const existing = document.getElementById("recipe-card-drag-ghost-root"); if (existing?.parentNode) { existing.parentNode.removeChild(existing); } } function removeInsertIndicator(listRoot) { listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove(); } function mountRecipeCardSkeletonInner(container) { if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) { container.innerHTML = '
'; } } function getRecipeListItems(listRoot) { return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")]; } function resolveRawInsertIndexAtY(listRoot, clientY) { const items = getRecipeListItems(listRoot); for (let i = 0; i < items.length; i++) { const rect = items[i].getBoundingClientRect(); if (clientY < rect.top + rect.height / 2) { return i; } } return items.length; } function resolveInsertIndexAtY(listRoot, clientY, draggingItem) { const items = getRecipeListItems(listRoot); if (!draggingItem) { return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length)); } const fromIndex = items.indexOf(draggingItem); if (fromIndex < 0) return 0; let toIndex = items.length; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item === draggingItem) continue; const rect = item.getBoundingClientRect(); const inCard = clientY >= rect.top && clientY <= rect.bottom; if (inCard && fromIndex > i) { toIndex = i; break; } if (inCard && fromIndex < i) { toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1; break; } if (clientY < rect.top + rect.height / 2) { toIndex = i; break; } toIndex = i + 1; } if (fromIndex < toIndex) { toIndex -= 1; } return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1))); } function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) { const items = getRecipeListItems(listRoot); const fromIndex = items.indexOf(draggingItem); if (fromIndex < 0) return null; const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem); if (fromIndex === toIndex) return null; return { fromIndex, toIndex }; } function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) { const items = getRecipeListItems(listRoot); const fromIndex = items.indexOf(draggingItem); if (fromIndex < 0 || dropToIndex == null) return null; const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1))); if (fromIndex === toIndex) return null; return { fromIndex, toIndex }; } function mountRecipeCardDragGhost(item, dragEvent) { removeRecipeDragGhost(); const ghost = document.createElement("div"); ghost.id = "recipe-card-drag-ghost-root"; ghost.className = "recipe-card-drag-ghost"; ghost.setAttribute("aria-hidden", "true"); mountRecipeCardSkeletonInner(ghost); const w = item.offsetWidth; ghost.style.cssText = [ "position:fixed", "left:-10000px", "top:0", "width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px", "pointer-events:none", ].join(";"); document.body.appendChild(ghost); void ghost.offsetWidth; const rect = item.getBoundingClientRect(); const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left)); const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top)); try { dragEvent.dataTransfer.setDragImage(ghost, ox, oy); } catch (_) { /* старые движки без кастомного изображения */ } } function sanitizeDragGhostClone(node) { node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action")); node.querySelectorAll("button").forEach((btn) => { btn.disabled = true; btn.tabIndex = -1; }); node.classList.remove("active", "recipe-card-drop-target"); } function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) { removeRecipeDragGhost(); if (isMobileReorderUi()) { const ghost = item.cloneNode(true); ghost.id = "recipe-card-drag-ghost-root"; ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone"; ghost.setAttribute("aria-hidden", "true"); sanitizeDragGhostClone(ghost); const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24)); ghost.style.cssText = [ "position:fixed", "left:0", "top:0", "width:" + width + "px", "margin:0", "transition:none", "transform:none", "z-index:1300", "pointer-events:none", ].join(";"); document.body.appendChild(ghost); positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY); return ghost; } const ghost = document.createElement("div"); ghost.id = "recipe-card-drag-ghost-root"; ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow"; ghost.setAttribute("aria-hidden", "true"); mountRecipeCardSkeletonInner(ghost); ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`; document.body.appendChild(ghost); positionPointerFollowGhost(ghost, clientX, clientY); return ghost; } function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) { if (!ghost) return; if (grabOffsetX != null && grabOffsetY != null) { ghost.style.left = `${clientX - grabOffsetX}px`; ghost.style.top = `${clientY - grabOffsetY}px`; ghost.style.transform = "none"; return; } ghost.style.left = `${clientX}px`; ghost.style.top = `${clientY}px`; ghost.style.transform = "translate(-50%, -50%)"; } function slotPickActive() { return Boolean(document.querySelector(".recipe-transfer-slot")); } function clearDropTargets(listRoot) { listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => { el.classList.remove("recipe-card-drop-target"); }); removeInsertIndicator(listRoot); } function updateInsertIndicator(listRoot, draggingItem, clientY) { if (!isMobileReorderUi()) return; const items = getRecipeListItems(listRoot); if (!items.length) return; let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator"); if (!line) { line = document.createElement("div"); line.className = "recipe-card-insert-indicator"; line.setAttribute("aria-hidden", "true"); listRoot.appendChild(line); } const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY); let yInList; if (insertIndex >= items.length) { const last = items[items.length - 1]; yInList = last.offsetTop + last.offsetHeight + 6; } else { yInList = items[insertIndex].offsetTop - 4; } line.style.top = `${yInList}px`; line.hidden = false; } function highlightDropTarget(listRoot, draggingItem, clientY) { clearDropTargets(listRoot); if (isMobileReorderUi()) { updateInsertIndicator(listRoot, draggingItem, clientY); return; } const items = getRecipeListItems(listRoot); for (const item of items) { if (item === draggingItem) continue; const rect = item.getBoundingClientRect(); if (clientY >= rect.top && clientY <= rect.bottom) { item.classList.add("recipe-card-drop-target"); break; } } } function configureMobileDragHandles(listRoot) { if (!isMobileReorderUi()) return; listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => { handle.setAttribute("draggable", "false"); }); } function listCanScroll(listRoot) { return listRoot.scrollHeight > listRoot.clientHeight + 2; } function getPageScrollElement() { return document.scrollingElement || document.documentElement; } function pageCanScrollUp() { return getPageScrollElement().scrollTop > 0; } function pageCanScrollDown() { const scrollEl = getPageScrollElement(); return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2; } function ensureMobileEdgeScrollHints() { if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return; const wrap = document.createElement("div"); wrap.id = "recipe-reorder-edge-hints"; wrap.className = "recipe-reorder-edge-hints"; wrap.setAttribute("aria-hidden", "true"); wrap.innerHTML = '
' + 'К краю — вверх' + "
" + '
' + 'К краю — вниз' + "
"; document.body.appendChild(wrap); } function updateMobileEdgeScrollHints(clientY) { if (!isMobileReorderUi()) return; const wrap = document.getElementById("recipe-reorder-edge-hints"); if (!wrap) return; const vh = window.innerHeight; wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle( "is-active", clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp() ); wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle( "is-active", clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown() ); } function removeReorderUiChrome() { document.body.classList.remove(REORDER_TRACKING_BODY_CLASS); document.getElementById("recipe-reorder-edge-hints")?.remove(); document.getElementById("recipe-reorder-floating-hint")?.remove(); } function showRecipeReorderHint() { if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return; const hint = document.createElement("div"); hint.id = "recipe-reorder-floating-hint"; hint.className = "recipe-reorder-hint recipe-reorder-hint--floating"; hint.setAttribute("role", "status"); hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка."; document.body.appendChild(hint); requestAnimationFrame(() => hint.classList.add("is-visible")); globalThis.setTimeout(() => { hint.classList.remove("is-visible"); globalThis.setTimeout(() => hint.remove(), 320); }, 3200); } function autoScrollWindowAtEdge(clientY) { const vh = window.innerHeight; const scrollEl = getPageScrollElement(); let delta = 0; if (clientY < AUTO_SCROLL_EDGE_PX) { const speed = Math.min( AUTO_SCROLL_MAX_STEP, Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) ); delta = -Math.max(8, speed); } else if (clientY > vh - AUTO_SCROLL_EDGE_PX) { const distBottom = vh - clientY; const speed = Math.min( AUTO_SCROLL_MAX_STEP, Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) ); delta = Math.max(8, speed); } if (delta === 0) return false; const prev = scrollEl.scrollTop; scrollEl.scrollTop += delta; return scrollEl.scrollTop !== prev; } function autoScrollRecipeList(listRoot, clientY) { if (!listCanScroll(listRoot)) return false; const rect = listRoot.getBoundingClientRect(); const distTop = clientY - rect.top; const distBottom = rect.bottom - clientY; let delta = 0; if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) { const speed = Math.min( AUTO_SCROLL_MAX_STEP, Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) ); delta = -Math.max(8, speed); } else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) { const speed = Math.min( AUTO_SCROLL_MAX_STEP, Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) ); delta = Math.max(8, speed); } if (delta === 0) return false; listRoot.scrollTop += delta; return true; } function autoScrollDuringReorder(listRoot, clientY) { if (isMobileReorderUi()) { return autoScrollWindowAtEdge(clientY); } return autoScrollRecipeList(listRoot, clientY); } function findTouchById(touchList, touchId) { for (let i = 0; i < touchList.length; i++) { if (touchList[i].identifier === touchId) { return touchList[i]; } } return null; } function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) { let touchDrag = null; let dropPending = false; let autoScrollRaf = null; let touchMoveListener = null; let touchEndListener = null; const detachTouchListeners = () => { if (touchMoveListener) { document.removeEventListener("touchmove", touchMoveListener, true); touchMoveListener = null; } if (touchEndListener) { document.removeEventListener("touchend", touchEndListener, true); document.removeEventListener("touchcancel", touchEndListener, true); touchEndListener = null; } }; const stopAutoScroll = () => { if (autoScrollRaf != null) { cancelAnimationFrame(autoScrollRaf); autoScrollRaf = null; } }; const startAutoScroll = () => { stopAutoScroll(); const tick = () => { if (!touchDrag?.active) { stopAutoScroll(); return; } const y = touchDrag.lastY; autoScrollDuringReorder(listRoot, y); updateMobileEdgeScrollHints(y); highlightDropTarget(listRoot, touchDrag.item, y); touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item); autoScrollRaf = requestAnimationFrame(tick); }; autoScrollRaf = requestAnimationFrame(tick); }; const finishTouchDrag = () => { detachTouchListeners(); stopAutoScroll(); if (!touchDrag) return; touchDrag.item.classList.remove("recipe-card-dragging"); listRoot.classList.remove("recipe-list-reorder-active"); removeReorderUiChrome(); touchDrag = null; clearDropTargets(listRoot); removeRecipeDragGhost(); }; const activateTouchDrag = (touch) => { if (!touchDrag || touchDrag.active) return; touchDrag.active = true; touchDrag.item.classList.add("recipe-card-dragging"); listRoot.classList.add("recipe-list-reorder-active"); document.body.classList.add(REORDER_TRACKING_BODY_CLASS); listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true })); ensureMobileEdgeScrollHints(); showRecipeReorderHint(); touchDrag.ghost = mountPointerFollowGhost( touchDrag.item, touch.clientX, touch.clientY, touchDrag.grabOffsetX, touchDrag.grabOffsetY ); startAutoScroll(); globalThis.navigator?.vibrate?.(8); }; const handleTouchMove = (e) => { if (!touchDrag) return; const touch = findTouchById(e.touches, touchDrag.touchId); if (!touch) return; e.preventDefault(); if (transferBlocksReorder()) { finishTouchDrag(); return; } touchDrag.lastY = touch.clientY; const dx = Math.abs(touch.clientX - touchDrag.startX); const dy = Math.abs(touch.clientY - touchDrag.startY); if (!touchDrag.active && dx + dy < activationThresholdPx()) return; if (!touchDrag.active) { activateTouchDrag(touch); } positionPointerFollowGhost( touchDrag.ghost, touch.clientX, touch.clientY, touchDrag.grabOffsetX, touchDrag.grabOffsetY ); updateMobileEdgeScrollHints(touch.clientY); highlightDropTarget(listRoot, touchDrag.item, touch.clientY); touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item); }; const handleTouchEnd = (e) => { if (!touchDrag) return; const touch = findTouchById(e.changedTouches, touchDrag.touchId); if (!touch) return; const { item, active, dropToIndex } = touchDrag; finishTouchDrag(); if (!active || dropPending) return; if (transferBlocksReorder() && slotPickActive()) return; const indices = resolveReorderDropIndex(listRoot, touch, item) || resolveTrackedReorderDropIndex(listRoot, item, dropToIndex); if (!indices) return; void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex); }; listRoot.addEventListener("recipe-transfer-armed", () => { if (touchDrag?.active) { finishTouchDrag(); } else if (touchDrag) { detachTouchListeners(); removeReorderUiChrome(); touchDrag = null; } }); if (typeof MutationObserver !== "undefined") { const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot)); handleObserver.observe(listRoot, { childList: true }); configureMobileDragHandles(listRoot); } listRoot.addEventListener( "touchstart", (e) => { if (e.touches.length !== 1) return; if (transferBlocksReorder()) return; const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !listRoot.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item || item.dataset.index === undefined) return; const touch = e.touches[0]; const handleRect = handle.getBoundingClientRect(); touchDrag = { item, touchId: touch.identifier, startX: touch.clientX, startY: touch.clientY, lastY: touch.clientY, grabOffsetX: touch.clientX - handleRect.left, grabOffsetY: touch.clientY - handleRect.top, active: false, ghost: null, dropToIndex: null, }; e.stopPropagation(); touchMoveListener = handleTouchMove; touchEndListener = handleTouchEnd; document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false }); document.addEventListener("touchend", touchEndListener, { capture: true }); document.addEventListener("touchcancel", touchEndListener, { capture: true }); }, { capture: true, passive: true } ); } function attachPointerReorder(listRoot, { moveRecipe, notyf }) { if (isMobileReorderUi()) { attachMobileTouchReorder(listRoot, { moveRecipe, notyf }); return; } let pointerDrag = null; let dropPending = false; let autoScrollRaf = null; let documentDragMove = null; let documentDragEnd = null; const detachDocumentDragListeners = () => { if (documentDragMove) { document.removeEventListener("pointermove", documentDragMove, true); documentDragMove = null; } if (documentDragEnd) { document.removeEventListener("pointerup", documentDragEnd, true); document.removeEventListener("pointercancel", documentDragEnd, true); documentDragEnd = null; } }; const stopAutoScroll = () => { if (autoScrollRaf != null) { cancelAnimationFrame(autoScrollRaf); autoScrollRaf = null; } }; const startAutoScroll = () => { stopAutoScroll(); const tick = () => { if (!pointerDrag?.active) { stopAutoScroll(); return; } const y = pointerDrag.lastY; autoScrollDuringReorder(listRoot, y); highlightDropTarget(listRoot, pointerDrag.item, y); pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item); autoScrollRaf = requestAnimationFrame(tick); }; autoScrollRaf = requestAnimationFrame(tick); }; const finishPointerDrag = () => { detachDocumentDragListeners(); stopAutoScroll(); if (!pointerDrag) return; pointerDrag.item.classList.remove("recipe-card-dragging"); listRoot.classList.remove("recipe-list-reorder-active"); removeReorderUiChrome(); pointerDrag = null; clearDropTargets(listRoot); removeRecipeDragGhost(); }; const isOverTransferHoverTarget = (clientX, clientY) => { const el = document.elementFromPoint(clientX, clientY); return !!( el?.closest("#periodsList .list-item[data-period-id]") || el?.closest("#dispensersList .list-item[data-dispenser-id]") ); }; const handlePointerMove = (e) => { if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; if (transferBlocksReorder()) { finishPointerDrag(); return; } if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) { pointerDrag.lastY = e.clientY; return; } e.preventDefault(); pointerDrag.lastY = e.clientY; const dx = Math.abs(e.clientX - pointerDrag.startX); const dy = Math.abs(e.clientY - pointerDrag.startY); if (!pointerDrag.active && dx + dy < activationThresholdPx()) return; if (!pointerDrag.active) { pointerDrag.active = true; pointerDrag.item.classList.add("recipe-card-dragging"); listRoot.classList.add("recipe-list-reorder-active"); listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true })); pointerDrag.ghost = mountPointerFollowGhost( pointerDrag.item, e.clientX, e.clientY, pointerDrag.grabOffsetX, pointerDrag.grabOffsetY ); startAutoScroll(); } positionPointerFollowGhost( pointerDrag.ghost, e.clientX, e.clientY, pointerDrag.grabOffsetX, pointerDrag.grabOffsetY ); updateMobileEdgeScrollHints(e.clientY); highlightDropTarget(listRoot, pointerDrag.item, e.clientY); pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item); }; const onPointerEnd = (e) => { if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; const { item, active, dropToIndex } = pointerDrag; finishPointerDrag(); if (!active || dropPending) return; if (transferBlocksReorder() && slotPickActive()) return; const indices = resolveReorderDropIndex(listRoot, e, item) || resolveTrackedReorderDropIndex(listRoot, item, dropToIndex); if (!indices) return; void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex); }; const attachDocumentDragListeners = () => { if (documentDragMove) return; documentDragMove = handlePointerMove; documentDragEnd = onPointerEnd; document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false }); document.addEventListener("pointerup", documentDragEnd, { capture: true }); document.addEventListener("pointercancel", documentDragEnd, { capture: true }); }; const cancelPointerDrag = () => { if (!pointerDrag) return; if (pointerDrag.active) return; finishPointerDrag(); }; listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag); listRoot.addEventListener("recipe-transfer-armed", () => { if (pointerDrag && !pointerDrag.active) { detachDocumentDragListeners(); pointerDrag = null; } else if (pointerDrag?.active) { finishPointerDrag(); } }); listRoot.addEventListener( "pointerdown", (e) => { if (e.pointerType === "mouse" && e.button !== 0) return; if (transferBlocksReorder()) return; const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !listRoot.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item || item.dataset.index === undefined) return; e.stopPropagation(); const handleRect = handle.getBoundingClientRect(); pointerDrag = { item, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, lastY: e.clientY, grabOffsetX: e.clientX - handleRect.left, grabOffsetY: e.clientY - handleRect.top, active: false, ghost: null, dropToIndex: null, }; attachDocumentDragListeners(); }, { capture: true, passive: false } ); } export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) { if (!listRoot || typeof moveRecipe !== "function") return; let draggingItem = null; let dropPending = false; listRoot.addEventListener("dragstart", (e) => { if (isMobileReorderUi()) { e.preventDefault(); return; } if (transferBlocksReorder()) { e.preventDefault(); return; } const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !listRoot.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item || item.dataset.index === undefined) return; e.stopPropagation(); draggingItem = item; item.classList.add("recipe-card-dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", item.dataset.recipeId || ""); mountRecipeCardDragGhost(item, e); }); listRoot.addEventListener("dragend", () => { removeRecipeDragGhost(); if (draggingItem) { draggingItem.classList.remove("recipe-card-dragging"); } draggingItem = null; dropPending = false; clearDropTargets(listRoot); }); listRoot.addEventListener("dragover", (e) => { if (!draggingItem) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const target = e.target.closest(".list-item[data-recipe-id]"); clearDropTargets(listRoot); if (target && target !== draggingItem) { target.classList.add("recipe-card-drop-target"); } }); listRoot.addEventListener("dragleave", (e) => { if (!listRoot.contains(e.relatedTarget)) { clearDropTargets(listRoot); } }); listRoot.addEventListener("drop", async (e) => { if (!draggingItem || dropPending) return; if (transferBlocksReorder() && slotPickActive()) return; e.preventDefault(); e.stopPropagation(); clearDropTargets(listRoot); const indices = resolveReorderDropIndex(listRoot, e, draggingItem); if (!indices) return; void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex); }); const cancelHtml5Drag = () => { if (listRoot.classList.contains("recipe-list-reorder-active")) return; removeRecipeDragGhost(); if (draggingItem) { draggingItem.classList.remove("recipe-card-dragging"); draggingItem = null; } dropPending = false; clearDropTargets(listRoot); }; listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag); if (!listRoot.dataset.recipeDragCancelBound) { listRoot.dataset.recipeDragCancelBound = "1"; document.addEventListener( "keydown", (e) => { if (e.key !== "Escape") return; cancelHtml5Drag(); listRoot.dispatchEvent( new CustomEvent("recipe-list-drag-cancel", { bubbles: true }) ); }, true ); } attachPointerReorder(listRoot, { moveRecipe, notyf }); }