Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Перенос рейса в другой период/оборудование: 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 =
|
||||
'<span class="recipe-transfer-slot-inner"><span class="recipe-transfer-slot-label">Вставить здесь</span></span>';
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user