876 lines
27 KiB
JavaScript
876 lines
27 KiB
JavaScript
/**
|
||
* 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 =
|
||
'<div class="dashboard-skeleton-card-inner"><div class="dashboard-skeleton-bar"></div></div>';
|
||
}
|
||
}
|
||
|
||
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 =
|
||
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--top">' +
|
||
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-up" aria-hidden="true"></i><span>К краю — вверх</span></span>' +
|
||
"</div>" +
|
||
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--bottom">' +
|
||
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-down" aria-hidden="true"></i><span>К краю — вниз</span></span>' +
|
||
"</div>";
|
||
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 });
|
||
}
|