/** * Перенос рейса в другой период/оборудование: drag с ручки, удержание 1–2 с над целью, выбор позиции. * Отмена: отпустить не над целью / Escape; другой период или оборудование — снова удержать над нужной карточкой или заново перетащить с ручки. */ const LONG_HOVER_MS = 1400; const MOBILE_TRANSFER_MQ = "(max-width: 768px)"; const MOBILE_TRANSFER_LONG_PRESS_MS = 750; const MOBILE_TRANSFER_MOVE_CANCEL_PX = 14; const TRANSFER_MIME = "application/x-wesp-recipe-transfer"; export function attachRecipePeriodTransfer({ getCurrentDispenserType, getSelectedDispenser, getSelectedPeriod, selectDispenser, selectPeriod, loadRecipes, loadPeriods, notyf, }) { const recipesList = document.getElementById("recipesList"); let session = null; let slotPick = false; let transferInFlight = false; let longHoverState = { kind: null, id: null, since: 0, fired: false }; let rafHoverId = null; let listMutationTimer = null; function clearLongHover() { longHoverState = { kind: null, id: null, since: 0, fired: false }; if (rafHoverId != null) { cancelAnimationFrame(rafHoverId); rafHoverId = null; } } function isTransferAllowed() { return ( typeof getCurrentDispenserType === "function" && getCurrentDispenserType() === "dispenser" && getSelectedPeriod() ); } function clearEligibleHighlights() { document.querySelectorAll(".recipe-transfer-eligible").forEach((el) => { el.classList.remove("recipe-transfer-eligible"); }); } function applyEligibleHighlights() { clearEligibleHighlights(); if (!session || slotPick) return; document.querySelectorAll("#dispensersList .list-item[data-dispenser-id]").forEach((el) => { if (el.dataset.dispenserType === "mill") return; el.classList.add("recipe-transfer-eligible"); }); document.querySelectorAll("#periodsList .list-item[data-period-id]").forEach((el) => { const pid = el.dataset.periodId; if ( String(pid) === String(session.fromPeriodId) && String(getSelectedDispenser()) === String(session.fromDispenserId) ) { return; } el.classList.add("recipe-transfer-eligible"); }); } function startSession(recipeId, fromDispenserId, fromPeriodId) { clearSlotPickUi(); session = { recipeId, fromDispenserId, fromPeriodId }; slotPick = false; /* Подсветку целей не включаем здесь — иначе reorder в том же периоде блокируется с первого mousedown. */ } function setMobileTransferOverlay(active) { if (!globalThis.matchMedia?.("(max-width: 768px)")?.matches) return; document.body.classList.toggle("recipe-transfer-session", !!active); } let pointerCaptureEl = null; let pointerCaptureId = null; let pendingDragItem = null; function releaseTransferPointerCapture() { if (pointerCaptureEl && pointerCaptureId != null) { try { pointerCaptureEl.releasePointerCapture(pointerCaptureId); } catch (_) { /* ignore */ } } pointerCaptureEl = null; pointerCaptureId = null; } function clearTransferSessionState({ emitDragCancel = true } = {}) { if (listMutationTimer != null) { clearTimeout(listMutationTimer); listMutationTimer = null; } clearMobilePressState(); releaseTransferPointerCapture(); pendingDragItem = null; mouseSessionActive = false; session = null; slotPick = false; pointerSessionId = null; document.body.classList.remove("recipe-period-transfer-active"); document.body.classList.remove("recipe-mobile-transfer-armed"); setMobileTransferOverlay(false); clearEligibleHighlights(); clearSlotPickUi(); clearLongHover(); if (emitDragCancel) { recipesList?.dispatchEvent( new CustomEvent("recipe-list-drag-cancel", { bubbles: true }) ); } } function endSession() { clearTransferSessionState({ emitDragCancel: true }); } function clearSlotPickUi() { document.querySelectorAll(".recipe-transfer-slot").forEach((n) => n.remove()); } function isValidDropTarget(toDispenser, toPeriod) { if (!toDispenser || !toPeriod || !session) return false; return !( String(toDispenser) === String(session.fromDispenserId) && String(toPeriod) === String(session.fromPeriodId) ); } /** Позиция вставки по Y относительно карточек (без учёта кнопок-слотов). */ function insertIndexFromRecipesListDrop(listRoot, e) { if (!listRoot) return 0; const y = e.clientY; const items = [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")]; for (let i = 0; i < items.length; i++) { const r = items[i].getBoundingClientRect(); if (y < r.top + r.height / 2) return i; } return items.length; } async function commitTransfer(toIndex) { if (!session || transferInFlight) return; transferInFlight = true; const sess = session; const toDispenser = getSelectedDispenser(); const toPeriod = getSelectedPeriod(); try { if (!sess || !toDispenser || !toPeriod || !isValidDropTarget(toDispenser, toPeriod)) { endSession(); return; } const url = `/api/feed_dispensers/${encodeURIComponent(toDispenser)}/periods/${encodeURIComponent(toPeriod)}/recipes/${encodeURIComponent(sess.recipeId)}/transfer`; const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_dispenser_id: sess.fromDispenserId, from_period_id: sess.fromPeriodId, to_index: toIndex, }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { const msg = j.message || "Ошибка переноса"; if (notyf) { if (r.status === 409) { if (typeof notyf.warning === "function") notyf.warning(msg); else notyf.error(msg); } else { notyf.error(msg); } } clearSlotPickUi(); slotPick = false; if (r.status === 409) { try { if (typeof loadPeriods === "function") await loadPeriods(toDispenser); if (typeof loadRecipes === "function") await loadRecipes(toPeriod); } catch (_) { /* ignore */ } } endSession(); return; } clearSlotPickUi(); slotPick = false; if (notyf) notyf.success(j.message || "Рейс перенесён"); if (typeof loadPeriods === "function") await loadPeriods(toDispenser); if (typeof loadRecipes === "function") await loadRecipes(toPeriod); endSession(); } catch (err) { console.error(err); if (notyf) notyf.error(err.message, { fallback: "Ошибка переноса" }); endSession(); } finally { transferInFlight = false; } } function scheduleHoverCheck() { if (rafHoverId != null) return; const tick = () => { rafHoverId = null; if (!session || longHoverState.kind == null) return; const elapsed = performance.now() - longHoverState.since; if (!longHoverState.fired && elapsed >= LONG_HOVER_MS) { longHoverState.fired = true; void fireLongHover(longHoverState.kind, longHoverState.id); return; } if (!longHoverState.fired && session) { rafHoverId = requestAnimationFrame(tick); } }; rafHoverId = requestAnimationFrame(tick); } function trackLongHover(kind, id) { if (!session) return; if (longHoverState.kind !== kind || longHoverState.id !== id) { longHoverState = { kind, id, since: performance.now(), fired: false }; applyEligibleHighlights(); } setMobileTransferOverlay(true); scheduleHoverCheck(); } async function fireLongHover(kind, id) { if (!session) return; transferHoverCommitPending = true; try { await fireLongHoverImpl(kind, id); } finally { transferHoverCommitPending = false; } } async function fireLongHoverImpl(kind, id) { if (!session) return; if (kind === "dispenser") { if (String(id) === String(getSelectedDispenser())) return; await selectDispenser(id); document.body.classList.add("recipe-period-transfer-active"); applyEligibleHighlights(); return; } if (kind === "period") { if ( String(id) === String(session.fromPeriodId) && String(getSelectedDispenser()) === String(session.fromDispenserId) ) { if (notyf) notyf.error("Выберите другой период"); return; } if (slotPick && String(id) === String(getSelectedPeriod())) return; await selectPeriod(id); document.body.classList.add("recipe-period-transfer-active"); enterSlotPickMode(); } } function enterSlotPickMode() { slotPick = true; clearEligibleHighlights(); clearLongHover(); setMobileTransferOverlay(true); requestAnimationFrame(() => injectSlots()); } /** Список рейсов перерисован (loadRecipes, смена периода, перестановка) — убрать «Вставить здесь», если они уже не в DOM. */ function syncSlotPickAfterListMutation() { if (!session || !slotPick) return; const list = document.getElementById("recipesList"); if (!list || list.querySelector(".recipe-transfer-slot")) return; endSession(); } function scheduleSyncAfterListMutation() { clearTimeout(listMutationTimer); listMutationTimer = setTimeout(() => { listMutationTimer = null; syncSlotPickAfterListMutation(); }, 80); } if (recipesList) { new MutationObserver(() => scheduleSyncAfterListMutation()).observe(recipesList, { childList: true, subtree: false, }); } function injectSlots() { clearSlotPickUi(); const list = document.getElementById("recipesList"); if (!list || !session) return; const items = [...list.querySelectorAll(":scope > .list-item[data-recipe-id]")]; for (let i = 0; i <= items.length; i++) { const slot = document.createElement("button"); slot.type = "button"; slot.className = "recipe-transfer-slot"; slot.dataset.insertIndex = String(i); slot.innerHTML = 'Вставить здесь'; slot.setAttribute("aria-label", `Вставить рейс на позицию ${i + 1}`); slot.addEventListener("click", onSlotClick); if (i < items.length) { list.insertBefore(slot, items[i]); } else { list.appendChild(slot); } } } function onSlotClick(e) { const slot = e.target.closest(".recipe-transfer-slot"); if (!slot || !session) return; const toIndex = Number(slot.dataset.insertIndex); if (Number.isNaN(toIndex)) return; void commitTransfer(toIndex); } function tryBeginSessionFromPendingDrag(clientX, clientY) { if (session || !pendingDragItem) return false; const target = document.elementFromPoint(clientX, clientY); if (target?.closest("#recipesList .list-item[data-recipe-id]")) return false; const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); const pItem = target?.closest("#periodsList .list-item[data-period-id]"); if (!dItem && !pItem) return false; if (dItem?.dataset.dispenserType === "mill") return false; beginHandleSession(pendingDragItem); return true; } function onDocumentDragOver(e) { if (!session) { if (tryBeginSessionFromPendingDrag(e.clientX, e.clientY)) { handleSessionHoverAtPoint(e.clientX, e.clientY, e); } return; } handleSessionHoverAtPoint(e.clientX, e.clientY, e); } function handleSessionHoverAtPoint(clientX, clientY, dragEvent) { if (!session) return; if (slotPick && recipesList && recipesList.contains(document.elementFromPoint(clientX, clientY))) { if (dragEvent?.preventDefault) { dragEvent.preventDefault(); if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; } return; } if (slotPick) return; const target = document.elementFromPoint(clientX, clientY); const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); const pItem = target?.closest("#periodsList .list-item[data-period-id]"); if (dItem) { if (dItem.dataset.dispenserType === "mill") { clearLongHover(); return; } if (dragEvent?.preventDefault) { dragEvent.preventDefault(); if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; } trackLongHover("dispenser", dItem.dataset.dispenserId); return; } if (pItem) { if (dragEvent?.preventDefault) { dragEvent.preventDefault(); if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; } trackLongHover("period", pItem.dataset.periodId); return; } clearLongHover(); } function handleSessionDropAtPoint(clientX, clientY, dragEvent) { if (!session) return false; const target = document.elementFromPoint(clientX, clientY); if (slotPick && recipesList && target && recipesList.contains(target)) { const toDispenser = getSelectedDispenser(); const toPeriod = getSelectedPeriod(); if (!isValidDropTarget(toDispenser, toPeriod)) { dragEvent?.preventDefault?.(); dragEvent?.stopPropagation?.(); endSession(); return true; } dragEvent?.preventDefault?.(); dragEvent?.stopPropagation?.(); const toIndex = insertIndexFromRecipesListDrop(recipesList, { clientY }); void commitTransfer(toIndex); return true; } if (slotPick) return false; const recipeDrop = target?.closest("#recipesList .list-item[data-recipe-id]"); if (recipeDrop) return false; const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); const pItem = target?.closest("#periodsList .list-item[data-period-id]"); if (dItem || pItem) { dragEvent?.preventDefault?.(); return true; } dragEvent?.preventDefault?.(); endSession(); return true; } function onDocumentDropCapture(e) { if (!session) return; handleSessionDropAtPoint(e.clientX, e.clientY, e); } function beginHandleSession(item) { if (!item || item.dataset.index === undefined) return; if (!isTransferAllowed()) return; const recipeId = item.dataset.recipeId; const fromDispenserId = getSelectedDispenser(); const fromPeriodId = getSelectedPeriod(); startSession(recipeId, fromDispenserId, fromPeriodId); } function onDragStartCapture(e) { const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !recipesList || !recipesList.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item) return; /* Сессию transfer не открываем сразу — иначе ломается повторный reorder в том же списке. */ pendingDragItem = item; try { e.dataTransfer.setData( TRANSFER_MIME, JSON.stringify({ recipeId: item.dataset.recipeId, fromDispenserId: getSelectedDispenser(), fromPeriodId: getSelectedPeriod(), }) ); } catch (_) { /* ignore */ } } function onDragEndCapture(e) { const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !recipesList || !recipesList.contains(handle)) return; if (slotPick && session) return; pendingDragItem = null; releaseTransferPointerCapture(); pointerSessionId = null; mouseSessionActive = false; if (recipesList.classList.contains("recipe-list-reorder-active")) { if (!slotPick) clearTransferSessionState({ emitDragCancel: false }); return; } if (session) clearTransferSessionState({ emitDragCancel: true }); } let pointerSessionId = null; let mouseSessionActive = false; let transferHoverCommitPending = false; let mobilePressState = null; function isMobileTransferUi() { return globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches ?? false; } function clearMobilePressState() { if (mobilePressState?.longPressTimer) { clearTimeout(mobilePressState.longPressTimer); } mobilePressState = null; } function cancelMobilePressForReorder() { clearMobilePressState(); } function armMobileTransferSession(item) { if (!item || !isTransferAllowed()) return; beginHandleSession(item); document.body.classList.add("recipe-mobile-transfer-armed"); recipesList?.dispatchEvent(new CustomEvent("recipe-transfer-armed", { bubbles: true })); globalThis.navigator?.vibrate?.(12); if (notyf) { notyf.success("Перетащите рейс на период или оборудование"); } } function onPointerDownCapture(e) { if (e.pointerType === "mouse" && e.button !== 0) return; const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !recipesList || !recipesList.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item) return; if (!isTransferAllowed()) return; if (isMobileTransferUi()) { clearMobilePressState(); mobilePressState = { pointerId: e.pointerId, item, startX: e.clientX, startY: e.clientY, longPressTimer: globalThis.setTimeout(() => { if (!mobilePressState) return; if (recipesList?.classList.contains("recipe-list-reorder-active")) { clearMobilePressState(); return; } mobilePressState.armed = true; pointerSessionId = mobilePressState.pointerId; armMobileTransferSession(mobilePressState.item); }, MOBILE_TRANSFER_LONG_PRESS_MS), }; return; } /* Desktop: reorder владеет ручкой через pointer/HTML5; transfer — через dragstart + hover. */ } function onPointerMoveCapture(e) { if (mobilePressState && e.pointerId === mobilePressState.pointerId && !mobilePressState.armed) { const dx = Math.abs(e.clientX - mobilePressState.startX); const dy = Math.abs(e.clientY - mobilePressState.startY); if (dx + dy >= MOBILE_TRANSFER_MOVE_CANCEL_PX) { clearMobilePressState(); } } if (!session || pointerSessionId == null || e.pointerId !== pointerSessionId) return; if (globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches && e.clientY < window.innerHeight * 0.38) { setMobileTransferOverlay(true); } handleSessionHoverAtPoint(e.clientX, e.clientY, null); } function onPointerUpCapture(e) { if (mobilePressState && e.pointerId === mobilePressState.pointerId) { const wasArmed = mobilePressState.armed; clearMobilePressState(); if (!wasArmed) return; } const ownsPointer = pointerSessionId != null && e.pointerId === pointerSessionId; if (ownsPointer) { releaseTransferPointerCapture(); pointerSessionId = null; mouseSessionActive = false; } if (!session) return; if (!ownsPointer) return; if (transferHoverCommitPending || (slotPick && session)) return; const target = document.elementFromPoint(e.clientX, e.clientY); const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]"); if (overRecipe && !slotPick) { clearTransferSessionState({ emitDragCancel: true }); return; } handleSessionDropAtPoint(e.clientX, e.clientY, e); } function onPointerCancelCapture(e) { if (mobilePressState && e.pointerId === mobilePressState.pointerId) { clearMobilePressState(); } if (pointerSessionId == null || e.pointerId !== pointerSessionId) return; pointerSessionId = null; if (slotPick && session) return; endSession(); } recipesList?.addEventListener("dragstart", onDragStartCapture, true); recipesList?.addEventListener("dragend", onDragEndCapture, true); recipesList?.addEventListener("recipe-reorder-active", () => { cancelMobilePressForReorder(); pendingDragItem = null; /* Не шлём recipe-list-drag-cancel — иначе reorder обрывается в pointer-path. */ if (!slotPick) clearTransferSessionState({ emitDragCancel: false }); }); function onMouseDownCapture(e) { /* Desktop mousedown-сессия ломала повторный reorder; transfer на десктопе — dragstart + dragover. */ if (!isMobileTransferUi()) return; if (e.button !== 0 || pointerSessionId != null) return; const handle = e.target.closest(".recipe-drag-handle"); if (!handle || !recipesList || !recipesList.contains(handle)) return; const item = handle.closest(".list-item[data-recipe-id]"); if (!item || !isTransferAllowed()) return; beginHandleSession(item); mouseSessionActive = true; } function onMouseMoveCapture(e) { if (!session || !mouseSessionActive || slotPick) return; handleSessionHoverAtPoint(e.clientX, e.clientY, null); } function onMouseUpCapture(e) { if (!mouseSessionActive) return; mouseSessionActive = false; if (!session) return; if (transferHoverCommitPending || (slotPick && session)) return; const target = document.elementFromPoint(e.clientX, e.clientY); const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]"); if (overRecipe && !slotPick) { endSession(); return; } handleSessionDropAtPoint(e.clientX, e.clientY, e); } recipesList?.addEventListener("pointerdown", onPointerDownCapture, true); recipesList?.addEventListener("mousedown", onMouseDownCapture, true); document.addEventListener("mousemove", onMouseMoveCapture, true); document.addEventListener("mouseup", onMouseUpCapture, true); document.addEventListener("pointermove", onPointerMoveCapture, true); document.addEventListener("pointerup", onPointerUpCapture, true); document.addEventListener("pointercancel", onPointerCancelCapture, true); document.addEventListener("dragover", onDocumentDragOver, true); document.addEventListener("drop", onDocumentDropCapture, true); document.addEventListener("keydown", (e) => { if (e.key !== "Escape" || !session) return; endSession(); }); return { endSession }; }