Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
export class AppState {
|
||||
constructor(initialState = {}) {
|
||||
this._state = { ...initialState };
|
||||
this._listeners = new Set();
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this._state[key];
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return { ...this._state };
|
||||
}
|
||||
|
||||
set(patch) {
|
||||
this._state = { ...this._state, ...patch };
|
||||
for (const listener of this._listeners) {
|
||||
listener(this.getAll());
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this._listeners.add(listener);
|
||||
return () => this._listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export class CrossTabSync {
|
||||
constructor(channelName) {
|
||||
this._channel = null;
|
||||
if (typeof BroadcastChannel !== "undefined") {
|
||||
this._channel = new BroadcastChannel(channelName);
|
||||
}
|
||||
}
|
||||
|
||||
post(type, payload = {}) {
|
||||
if (!this._channel) return;
|
||||
this._channel.postMessage({ type, ...payload });
|
||||
}
|
||||
|
||||
onMessage(handler) {
|
||||
if (!this._channel) return () => {};
|
||||
const wrapped = (event) => handler(event.data || {});
|
||||
this._channel.addEventListener("message", wrapped);
|
||||
return () => this._channel.removeEventListener("message", wrapped);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._channel) {
|
||||
this._channel.close();
|
||||
this._channel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
|
||||
*/
|
||||
(function (global) {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatKg(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const rounded = Math.round(n * 100) / 100;
|
||||
return String(rounded).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function renderComponentPickList(items, emptyText) {
|
||||
if (!items.length) {
|
||||
return `<p class="zt-k-hub-plan-replace__empty">${escapeHtml(emptyText)}</p>`;
|
||||
}
|
||||
return items
|
||||
.map(
|
||||
(item) =>
|
||||
`<button type="button" class="zt-k-hub-plan-replace__pick" data-pick-component-id="${escapeHtml(item.id)}" ` +
|
||||
`data-pick-component-name="${escapeHtml(item.name)}">` +
|
||||
`<span class="zt-k-hub-plan-replace__pick-name">${escapeHtml(item.name)}</span>` +
|
||||
`<span class="zt-k-hub-plan-replace__pick-meta">` +
|
||||
`${escapeHtml(item.type || "—")} · СВ ${formatKg(item.dryMatter)}%</span></button>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchComponentAlternatives(componentId, query) {
|
||||
const params = new URLSearchParams({
|
||||
component_id: componentId,
|
||||
q: query || "",
|
||||
limit: "100",
|
||||
});
|
||||
const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось загрузить компоненты");
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function dismissOverlays(host, keepSelectors) {
|
||||
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
|
||||
if (keepSelectors.some((sel) => el.matches(sel))) return;
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.componentId
|
||||
* @param {string} options.label
|
||||
* @param {HTMLElement} [options.overlayHost]
|
||||
* @param {string[]} [options.keepOverlays] — селекторы overlay, которые не закрывать
|
||||
* @param {string} [options.subtitle]
|
||||
* @param {(replacementComponentId: string, replacementName: string) => void|Promise<void>} options.onPick
|
||||
* @param {(message: string) => void} [options.onError]
|
||||
*/
|
||||
async function openReplaceComponentModal(options) {
|
||||
const {
|
||||
componentId,
|
||||
label,
|
||||
overlayHost = document.body,
|
||||
keepOverlays = [],
|
||||
subtitle = "",
|
||||
onPick,
|
||||
onError = () => {},
|
||||
} = options || {};
|
||||
if (!componentId || !onPick) return;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace";
|
||||
const subtitleHtml = subtitle
|
||||
? `<p class="zt-k-hub-plan-replace__lead">${escapeHtml(subtitle)}</p>`
|
||||
: "";
|
||||
overlay.innerHTML =
|
||||
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-replace__dialog" role="dialog" aria-modal="true">' +
|
||||
`<h3 class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-replace__title">Заменить «${escapeHtml(label)}»</h3>` +
|
||||
subtitleHtml +
|
||||
'<label class="zt-k-hub-plan-replace__search-label">Поиск по всем компонентам</label>' +
|
||||
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-replace-search placeholder="Начните вводить название…" autocomplete="off">' +
|
||||
'<div class="zt-k-hub-plan-replace__section">' +
|
||||
'<p class="zt-k-hub-plan-replace__section-title">Похожие по параметрам</p>' +
|
||||
'<div data-replace-similar class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
|
||||
'<div class="zt-k-hub-plan-replace__section">' +
|
||||
'<p class="zt-k-hub-plan-replace__section-title">Все компоненты</p>' +
|
||||
'<div data-replace-results class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
|
||||
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-replace__actions">' +
|
||||
'<button type="button" class="btn btn-secondary" data-replace-cancel>Отмена</button></div></div>';
|
||||
|
||||
dismissOverlays(overlayHost, keepOverlays);
|
||||
overlay.setAttribute("data-daily-plan-overlay", "1");
|
||||
overlayHost.appendChild(overlay);
|
||||
|
||||
const similarEl = overlay.querySelector("[data-replace-similar]");
|
||||
const resultsEl = overlay.querySelector("[data-replace-results]");
|
||||
const searchEl = overlay.querySelector("[data-replace-search]");
|
||||
let catalog = null;
|
||||
|
||||
const close = () => {
|
||||
if (overlay.isConnected) overlay.remove();
|
||||
};
|
||||
|
||||
function filterCatalog(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
similar: catalog?.similar || [],
|
||||
items: catalog?.results || [],
|
||||
q: "",
|
||||
};
|
||||
}
|
||||
const items = (catalog?.results || []).filter((item) =>
|
||||
String(item.name || "").toLowerCase().includes(q)
|
||||
);
|
||||
const similar = items.filter((item) => item.similar);
|
||||
return { similar, items, q };
|
||||
}
|
||||
|
||||
function paintLists(query = "") {
|
||||
if (!overlay.isConnected) return;
|
||||
const { similar, items, q } = filterCatalog(query);
|
||||
if (similarEl) {
|
||||
similarEl.innerHTML = renderComponentPickList(
|
||||
similar,
|
||||
q ? "Нет похожих по запросу" : "Нет похожих компонентов"
|
||||
);
|
||||
}
|
||||
if (resultsEl) {
|
||||
resultsEl.innerHTML = renderComponentPickList(
|
||||
items,
|
||||
q ? "Ничего не найдено" : "Нет доступных компонентов"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
catalog = await fetchComponentAlternatives(componentId, "");
|
||||
paintLists(searchEl?.value.trim() || "");
|
||||
} catch (err) {
|
||||
if (!overlay.isConnected) return;
|
||||
onError(err.message || "Не удалось загрузить компоненты");
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim()));
|
||||
overlay.addEventListener("click", async (event) => {
|
||||
const pick = event.target.closest("[data-pick-component-id]");
|
||||
if (!pick) return;
|
||||
event.preventDefault();
|
||||
const replacementComponentId = pick.dataset.pickComponentId;
|
||||
const replacementName = pick.dataset.pickComponentName || "Компонент";
|
||||
if (!replacementComponentId) return;
|
||||
close();
|
||||
try {
|
||||
await onPick(replacementComponentId, replacementName);
|
||||
} catch (err) {
|
||||
onError(err.message || "Не удалось заменить компонент");
|
||||
}
|
||||
});
|
||||
|
||||
await loadCatalog();
|
||||
searchEl?.focus();
|
||||
}
|
||||
|
||||
global.WespDailyPlanReplaceModal = {
|
||||
open: openReplaceComponentModal,
|
||||
fetchComponentAlternatives,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Диалог «На какой срок…» для skip/replace/adjustment плана на день.
|
||||
*/
|
||||
(function (global) {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function weekEndIso(planDate) {
|
||||
const parts = String(planDate || "").split("-");
|
||||
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
const jsDay = d.getDay();
|
||||
const pyWeekday = jsDay === 0 ? 6 : jsDay - 1;
|
||||
d.setDate(d.getDate() + (6 - pyWeekday));
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dismissOverlays(host, keepSelectors) {
|
||||
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
|
||||
if (keepSelectors.some((sel) => el.matches(sel))) return;
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.planDate YYYY-MM-DD
|
||||
* @param {string} [options.title]
|
||||
* @param {HTMLElement} [options.overlayHost] куда монтировать overlay
|
||||
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
|
||||
* @param {function} [options.onError]
|
||||
* @param {function} [options.beforeAppend] (overlay) => void — dismiss other overlays
|
||||
*/
|
||||
function promptSkipDuration(options) {
|
||||
const planDate = options.planDate || todayIso();
|
||||
const title = options.title || "На какой срок исключить?";
|
||||
const onError = options.onError || function () {};
|
||||
const host = options.overlayHost || document.body;
|
||||
const keepOverlays = options.keepOverlays || [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const weekEnd = weekEndIso(planDate);
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-skip-duration";
|
||||
overlay.innerHTML =
|
||||
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-skip-duration__dialog" role="dialog" aria-modal="true">' +
|
||||
`<p class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-skip-duration__title">${escapeHtml(title)}</p>` +
|
||||
'<div class="zt-k-hub-plan-skip-duration__options">' +
|
||||
'<label class="zt-k-hub-plan-skip-duration__option">' +
|
||||
'<input type="radio" name="skip-duration" value="today" checked> Только сегодня</label>' +
|
||||
'<label class="zt-k-hub-plan-skip-duration__option">' +
|
||||
`<input type="radio" name="skip-duration" value="week"> До конца недели (до ${escapeHtml(weekEnd)})</label>` +
|
||||
'<label class="zt-k-hub-plan-skip-duration__option zt-k-hub-plan-skip-duration__option--date">' +
|
||||
'<input type="radio" name="skip-duration" value="date"> До даты ' +
|
||||
`<input type="date" class="form-control" data-skip-until-date min="${escapeHtml(planDate)}" value="${escapeHtml(planDate)}">` +
|
||||
"</label></div>" +
|
||||
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-skip-duration__actions">' +
|
||||
'<button type="button" class="btn btn-secondary" data-skip-duration-cancel>Отмена</button>' +
|
||||
'<button type="button" class="btn btn-primary" data-skip-duration-confirm>Применить</button>' +
|
||||
"</div></div>";
|
||||
if (typeof options.beforeAppend === "function") {
|
||||
options.beforeAppend(overlay);
|
||||
} else if (keepOverlays.length) {
|
||||
dismissOverlays(host, keepOverlays);
|
||||
}
|
||||
overlay.setAttribute("data-daily-plan-overlay", "1");
|
||||
host.appendChild(overlay);
|
||||
const dateInput = overlay.querySelector("[data-skip-until-date]");
|
||||
const finish = (value) => {
|
||||
if (overlay.isConnected) overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) finish(null);
|
||||
});
|
||||
overlay.querySelector("[data-skip-duration-cancel]")?.addEventListener("click", () => finish(null));
|
||||
overlay.querySelector("[data-skip-duration-confirm]")?.addEventListener("click", () => {
|
||||
const selected = overlay.querySelector('input[name="skip-duration"]:checked');
|
||||
const mode = selected?.value || "today";
|
||||
if (mode === "week") {
|
||||
finish({ duration: "week" });
|
||||
return;
|
||||
}
|
||||
if (mode === "date") {
|
||||
const untilDate = dateInput?.value || planDate;
|
||||
if (untilDate < planDate) {
|
||||
onError("Дата окончания не может быть раньше начала");
|
||||
return;
|
||||
}
|
||||
finish({ duration: "date", untilDate });
|
||||
return;
|
||||
}
|
||||
finish({ duration: "today" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function skipDurationBody(planDate, extra, title, options) {
|
||||
return promptSkipDuration({ planDate, title, ...options }).then((choice) => {
|
||||
if (!choice) return null;
|
||||
return { date: planDate, ...choice, ...(extra || {}) };
|
||||
});
|
||||
}
|
||||
|
||||
global.WespDailyPlanSkipDuration = {
|
||||
weekEndIso,
|
||||
todayIso,
|
||||
promptSkipDuration,
|
||||
skipDurationBody,
|
||||
escapeHtml,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,209 @@
|
||||
const KIOSK_SKELETON_MIN_MS = 300;
|
||||
const KIOSK_SKELETON_EXIT_MS = 160;
|
||||
|
||||
const GRID_CARD_INNER = `
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-title"></div>
|
||||
<div class="kiosk-skeleton-info">
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
|
||||
</div>`;
|
||||
|
||||
const GROUP_ITEM_INNER = `
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>`;
|
||||
|
||||
export function buildGridSkeletonCards(rowCount = 4) {
|
||||
return Array.from({ length: rowCount }, () =>
|
||||
`<div class="dispenser-card kiosk-skeleton-card" aria-hidden="true">${GRID_CARD_INNER}</div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
export function buildDispenserGridSkeletonHtml(rowCount = 4) {
|
||||
return buildGridSkeletonCards(rowCount);
|
||||
}
|
||||
|
||||
export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) {
|
||||
return buildGridSkeletonCards(rowCount);
|
||||
}
|
||||
|
||||
export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) {
|
||||
const items = Array.from({ length: rowCount }, () =>
|
||||
`<div class="kiosk-skeleton-group-item">${GROUP_ITEM_INNER}</div>`
|
||||
).join("");
|
||||
return `<div class="kiosk-skeleton-groups" data-skeleton-variant="groups" aria-busy="true" aria-label="Загрузка групп выгрузки">${items}</div>`;
|
||||
}
|
||||
|
||||
export function buildDuplicatePanelSkeletonHtml() {
|
||||
return `<div class="kiosk-skeleton-panel" data-skeleton-variant="duplicate" aria-busy="true" aria-label="Загрузка данных">
|
||||
<div class="kiosk-skeleton-recipe-header">
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
|
||||
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle"></div>
|
||||
</div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) {
|
||||
const rows = Array.from({ length: rowCount }, () =>
|
||||
`<div class="kiosk-skeleton-table-row">
|
||||
<div class="dashboard-skeleton-bar"></div>
|
||||
<div class="dashboard-skeleton-bar"></div>
|
||||
</div>`
|
||||
).join("");
|
||||
return `<div class="kiosk-skeleton-table" data-skeleton-variant="ingredients" aria-busy="true" aria-label="Загрузка ингредиентов">${rows}</div>`;
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
}
|
||||
|
||||
function isGridSkeletonHost(container) {
|
||||
if (!container) return false;
|
||||
return (
|
||||
container.getAttribute("aria-busy") === "true" &&
|
||||
container.querySelector(":scope > .kiosk-skeleton-card") != null
|
||||
);
|
||||
}
|
||||
|
||||
function findSkeletonRoot(container) {
|
||||
if (!container) return null;
|
||||
if (isGridSkeletonHost(container)) return container;
|
||||
return container.querySelector(
|
||||
":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel"
|
||||
);
|
||||
}
|
||||
|
||||
export function mountKioskSkeleton(container, html, label) {
|
||||
if (!container) return performance.now();
|
||||
container.innerHTML = html;
|
||||
container.classList.remove("kiosk-skeleton-host--exit");
|
||||
if (container.querySelector(":scope > .kiosk-skeleton-card")) {
|
||||
container.setAttribute("aria-busy", "true");
|
||||
if (label) container.setAttribute("aria-label", label);
|
||||
}
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const rest = minMs - elapsed;
|
||||
if (rest > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, rest));
|
||||
}
|
||||
}
|
||||
|
||||
async function fadeOutSkeleton(skel) {
|
||||
if (!skel || prefersReducedMotion()) return;
|
||||
const exitClass = isGridSkeletonHost(skel)
|
||||
? "kiosk-skeleton-host--exit"
|
||||
: skel.classList.contains("kiosk-skeleton-groups")
|
||||
? "kiosk-skeleton-groups--exit"
|
||||
: skel.classList.contains("kiosk-skeleton-table")
|
||||
? "kiosk-skeleton-table--exit"
|
||||
: "kiosk-skeleton-panel--exit";
|
||||
skel.classList.add(exitClass);
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
skel.removeEventListener("transitionend", onEnd);
|
||||
resolve();
|
||||
};
|
||||
const onEnd = (event) => {
|
||||
if (event.target === skel && event.propertyName === "opacity") finish();
|
||||
};
|
||||
skel.addEventListener("transitionend", onEnd);
|
||||
globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSkeletonHostAttrs(container) {
|
||||
if (!container) return;
|
||||
container.classList.remove("kiosk-skeleton-host--exit");
|
||||
container.removeAttribute("aria-busy");
|
||||
container.removeAttribute("aria-label");
|
||||
}
|
||||
|
||||
export async function replaceKioskContent(container, html, options = {}) {
|
||||
if (!container) return;
|
||||
const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options;
|
||||
const skel = findSkeletonRoot(container);
|
||||
if (skel) {
|
||||
await awaitKioskSkeletonMin(startedAt, minMs);
|
||||
await fadeOutSkeleton(skel);
|
||||
} else {
|
||||
await awaitKioskSkeletonMin(startedAt, minMs);
|
||||
}
|
||||
container.innerHTML = html;
|
||||
clearSkeletonHostAttrs(container);
|
||||
}
|
||||
|
||||
export function clearStaleKioskSkeleton(container) {
|
||||
if (!container) return;
|
||||
if (findSkeletonRoot(container)) {
|
||||
container.innerHTML = "";
|
||||
clearSkeletonHostAttrs(container);
|
||||
}
|
||||
}
|
||||
|
||||
export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
|
||||
const html = buildDuplicatePanelSkeletonHtml();
|
||||
const startedAt = performance.now();
|
||||
if (recipeInfoEl) {
|
||||
recipeInfoEl.style.display = "block";
|
||||
recipeInfoEl.innerHTML = html;
|
||||
}
|
||||
if (weightSectionEl) {
|
||||
weightSectionEl.classList.add("kiosk-skeleton-host");
|
||||
const host = weightSectionEl.querySelector(".weight-content");
|
||||
if (host) {
|
||||
host.dataset.kioskSkeletonBackup = host.innerHTML;
|
||||
host.innerHTML = `<div class="kiosk-skeleton-panel" aria-busy="true" aria-hidden="true">
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
|
||||
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
|
||||
if (recipeInfoEl) {
|
||||
const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel");
|
||||
if (skel) {
|
||||
recipeInfoEl.style.display = "none";
|
||||
recipeInfoEl.innerHTML = `
|
||||
<h3>Рецепт: <span id="recipeName"></span></h3>
|
||||
<p>Компонент <span id="currentIndex">0</span> из <span id="totalComponents">0</span></p>`;
|
||||
}
|
||||
}
|
||||
if (weightSectionEl) {
|
||||
weightSectionEl.classList.remove("kiosk-skeleton-host");
|
||||
const host = weightSectionEl.querySelector(".weight-content");
|
||||
if (host?.dataset.kioskSkeletonBackup) {
|
||||
host.innerHTML = host.dataset.kioskSkeletonBackup;
|
||||
delete host.dataset.kioskSkeletonBackup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
buildGridSkeletonCards,
|
||||
buildDispenserGridSkeletonHtml,
|
||||
buildPeriodRecipeGridSkeletonHtml,
|
||||
buildUnloadingGroupsSkeletonHtml,
|
||||
buildDuplicatePanelSkeletonHtml,
|
||||
buildRecipeIngredientsSkeletonHtml,
|
||||
mountKioskSkeleton,
|
||||
replaceKioskContent,
|
||||
awaitKioskSkeletonMin,
|
||||
clearStaleKioskSkeleton,
|
||||
mountDuplicatePanelSkeleton,
|
||||
clearDuplicatePanelSkeleton,
|
||||
};
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.WespKioskSkeleton = api;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export class DispenserSelector {
|
||||
constructor(handlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
async handleAction(action, event, actionEl) {
|
||||
const recipeId = actionEl.dataset.recipeId;
|
||||
const periodId = actionEl.dataset.periodId;
|
||||
const dispenserId = actionEl.dataset.dispenserId;
|
||||
const index = Number(actionEl.dataset.index);
|
||||
|
||||
switch (action) {
|
||||
case "select-dispenser":
|
||||
if (dispenserId) await this.handlers.selectDispenser(dispenserId);
|
||||
return true;
|
||||
case "select-recipe":
|
||||
if (recipeId) await this.handlers.selectRecipe(recipeId);
|
||||
return true;
|
||||
case "select-period":
|
||||
if (periodId) await this.handlers.selectPeriod(periodId);
|
||||
return true;
|
||||
case "copy-recipe":
|
||||
event.stopPropagation();
|
||||
if (recipeId) await this.handlers.copyRecipe(recipeId);
|
||||
return true;
|
||||
case "delete-recipe":
|
||||
event.stopPropagation();
|
||||
if (recipeId) await this.handlers.deleteRecipe(recipeId);
|
||||
return true;
|
||||
case "paste-recipe":
|
||||
event.stopPropagation();
|
||||
if (periodId) await this.handlers.pasteRecipe(periodId);
|
||||
return true;
|
||||
case "create-recipe":
|
||||
event.stopPropagation();
|
||||
if (periodId) await this.handlers.createNewRecipe(periodId);
|
||||
return true;
|
||||
case "move-recipe-up":
|
||||
event.stopPropagation();
|
||||
if (recipeId && !Number.isNaN(index)) {
|
||||
await this.handlers.moveRecipeUp(recipeId, index);
|
||||
}
|
||||
return true;
|
||||
case "move-recipe-down":
|
||||
event.stopPropagation();
|
||||
if (recipeId && !Number.isNaN(index)) {
|
||||
await this.handlers.moveRecipeDown(recipeId, index);
|
||||
}
|
||||
return true;
|
||||
case "unskip-recipe-today":
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (recipeId && typeof this.handlers.unskipRecipeToday === "function") {
|
||||
await this.handlers.unskipRecipeToday(recipeId);
|
||||
}
|
||||
return true;
|
||||
case "unskip-ingredient-parts-today":
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (recipeId && typeof this.handlers.unskipIngredientPartsToday === "function") {
|
||||
await this.handlers.unskipIngredientPartsToday(recipeId);
|
||||
}
|
||||
return true;
|
||||
case "unskip-group-parts-today":
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (recipeId && typeof this.handlers.unskipGroupPartsToday === "function") {
|
||||
await this.handlers.unskipGroupPartsToday(recipeId);
|
||||
}
|
||||
return true;
|
||||
case "recipe-drag-handle":
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export class RecipeEditor {
|
||||
constructor(handlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
async handleAction(action, event, actionEl) {
|
||||
switch (action) {
|
||||
case "add-ingredient":
|
||||
this.handlers.addIngredient();
|
||||
return true;
|
||||
case "toggle-unloading-link":
|
||||
this.handlers.toggleUnloadingLink();
|
||||
return true;
|
||||
case "add-unloading-group":
|
||||
this.handlers.addUnloadingGroup();
|
||||
return true;
|
||||
case "show-recipe-edit-off":
|
||||
this.handlers.showRecipeEdit(false);
|
||||
return true;
|
||||
case "print-all-recipes":
|
||||
this.handlers.printAllRecipesInPeriod();
|
||||
return true;
|
||||
case "print-recipe":
|
||||
this.handlers.printRecipe();
|
||||
return true;
|
||||
case "save-and-exit":
|
||||
await this.handlers.saveRecipe(null, true);
|
||||
return true;
|
||||
case "move-group-up":
|
||||
this.handlers.moveGroupUp(actionEl);
|
||||
return true;
|
||||
case "move-group-down":
|
||||
this.handlers.moveGroupDown(actionEl);
|
||||
return true;
|
||||
case "remove-unloading-group":
|
||||
this.handlers.removeUnloadingGroup(actionEl);
|
||||
return true;
|
||||
case "move-ingredient-up":
|
||||
this.handlers.moveIngredientUp(actionEl);
|
||||
return true;
|
||||
case "move-ingredient-down":
|
||||
this.handlers.moveIngredientDown(actionEl);
|
||||
return true;
|
||||
case "remove-ingredient":
|
||||
this.handlers.removeIngredient(actionEl);
|
||||
return true;
|
||||
case "open-ingredient-sheet": {
|
||||
const row = actionEl.closest(".ingredient-row");
|
||||
this.handlers.openIngredientMobileSheet(row);
|
||||
return true;
|
||||
}
|
||||
case "close-ingredient-sheet":
|
||||
this.handlers.closeIngredientMobileSheet();
|
||||
return true;
|
||||
case "remove-ingredient-sheet":
|
||||
this.handlers.removeIngredientFromSheet();
|
||||
return true;
|
||||
case "open-unloading-group-sheet": {
|
||||
const row = actionEl.closest(".unloading-group-row");
|
||||
this.handlers.openUnloadingGroupMobileSheet(row);
|
||||
return true;
|
||||
}
|
||||
case "close-unloading-group-sheet":
|
||||
this.handlers.closeUnloadingGroupMobileSheet();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(action, actionEl) {
|
||||
switch (action) {
|
||||
case "group-type-change":
|
||||
this.handlers.handleGroupTypeChange(actionEl);
|
||||
return true;
|
||||
case "group-value-change":
|
||||
this.handlers.validateGroupValue(actionEl);
|
||||
return true;
|
||||
case "ingredient-change":
|
||||
this.handlers.handleIngredientChange(actionEl);
|
||||
return true;
|
||||
case "dry-matter-percent-change":
|
||||
this.handlers.handleDryMatterPercentChange(actionEl);
|
||||
return true;
|
||||
case "recalculate-weights":
|
||||
this.handlers.recalculateWeights();
|
||||
return true;
|
||||
case "recalculate-total-weight":
|
||||
this.handlers.recalculateFromTotalWeight(actionEl);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
const MOBILE_SHEET_MQ = "(max-width: 768px)";
|
||||
|
||||
function getSheetParts(root) {
|
||||
if (!root) return null;
|
||||
const panel = root.querySelector(
|
||||
".recipe-mobile-edit-sheet-panel, .ingredient-mobile-sheet-panel, .recipe-help-modal-panel"
|
||||
);
|
||||
const body = root.querySelector(
|
||||
".recipe-mobile-edit-sheet-body, .ingredient-mobile-sheet-body, .recipe-help-modal-body"
|
||||
);
|
||||
const backdrop = root.querySelector(".recipe-mobile-edit-sheet-backdrop");
|
||||
return panel ? { root, panel, body, backdrop } : null;
|
||||
}
|
||||
|
||||
export function resetRecipeMobileSheetPresentation(root) {
|
||||
const parts = getSheetParts(root);
|
||||
if (!parts) return;
|
||||
const { panel, backdrop } = parts;
|
||||
panel.style.transform = "";
|
||||
panel.style.transition = "";
|
||||
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
|
||||
if (backdrop) {
|
||||
backdrop.style.opacity = "";
|
||||
backdrop.style.transition = "";
|
||||
}
|
||||
}
|
||||
|
||||
function canStartSheetDrag(target, body) {
|
||||
if (target.closest(".recipe-mobile-edit-sheet-done")) return false;
|
||||
if (target.closest("button, input, select, textarea, a, label")) {
|
||||
return !!target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-title");
|
||||
}
|
||||
if (target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-header")) {
|
||||
return true;
|
||||
}
|
||||
if (body?.contains(target) && body.scrollTop <= 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bindSheetGestures(root, onClose, mobileQuery) {
|
||||
const parts = getSheetParts(root);
|
||||
if (!parts || typeof onClose !== "function") return;
|
||||
|
||||
const { panel, body, backdrop } = parts;
|
||||
let dragState = null;
|
||||
|
||||
function clearDragState() {
|
||||
dragState = null;
|
||||
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
|
||||
}
|
||||
|
||||
function applyDrag(deltaY) {
|
||||
const offset = Math.max(0, deltaY);
|
||||
panel.style.transition = "none";
|
||||
panel.style.transform = `translateY(${offset}px)`;
|
||||
if (backdrop) {
|
||||
backdrop.style.transition = "none";
|
||||
backdrop.style.opacity = String(Math.max(0, 1 - offset / 280));
|
||||
}
|
||||
}
|
||||
|
||||
function snapBack() {
|
||||
panel.style.transition = "transform 0.24s ease";
|
||||
panel.style.transform = "";
|
||||
if (backdrop) {
|
||||
backdrop.style.transition = "opacity 0.24s ease";
|
||||
backdrop.style.opacity = "";
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
panel.style.transition = "";
|
||||
if (backdrop) backdrop.style.transition = "";
|
||||
clearDragState();
|
||||
}, 240);
|
||||
}
|
||||
|
||||
function dismissWithAnimation() {
|
||||
panel.style.transition = "transform 0.22s ease";
|
||||
panel.style.transform = "translateY(100%)";
|
||||
if (backdrop) {
|
||||
backdrop.style.transition = "opacity 0.22s ease";
|
||||
backdrop.style.opacity = "0";
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
resetRecipeMobileSheetPresentation(root);
|
||||
onClose();
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (!mobileQuery.matches || root.hidden) return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
if (!canStartSheetDrag(event.target, body)) return;
|
||||
|
||||
dragState = {
|
||||
pointerId: event.pointerId,
|
||||
startY: event.clientY,
|
||||
fromScrollableBody: !!(body && body.contains(event.target) && !event.target.closest(".recipe-mobile-edit-sheet-header, .recipe-mobile-edit-sheet-grab")),
|
||||
};
|
||||
panel.classList.add("recipe-mobile-edit-sheet--dragging");
|
||||
panel.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) return;
|
||||
|
||||
const deltaY = event.clientY - dragState.startY;
|
||||
if (dragState.fromScrollableBody) {
|
||||
if (deltaY <= 0 || (body && body.scrollTop > 0)) {
|
||||
clearDragState();
|
||||
resetRecipeMobileSheetPresentation(root);
|
||||
return;
|
||||
}
|
||||
if (deltaY > 8) {
|
||||
dragState.fromScrollableBody = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaY > 0) {
|
||||
event.preventDefault();
|
||||
applyDrag(deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerEnd(event) {
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) return;
|
||||
|
||||
const deltaY = event.clientY - dragState.startY;
|
||||
const threshold = Math.min(96, panel.offsetHeight * 0.2);
|
||||
clearDragState();
|
||||
|
||||
if (deltaY > threshold) {
|
||||
dismissWithAnimation();
|
||||
return;
|
||||
}
|
||||
snapBack();
|
||||
}
|
||||
|
||||
panel.addEventListener("pointerdown", onPointerDown);
|
||||
panel.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
panel.addEventListener("pointerup", onPointerEnd);
|
||||
panel.addEventListener("pointercancel", onPointerEnd);
|
||||
}
|
||||
|
||||
export function initRecipeMobileSheetGestures(handlers = {}) {
|
||||
const mobileQuery = window.matchMedia(MOBILE_SHEET_MQ);
|
||||
const sheets = [
|
||||
{
|
||||
root: document.getElementById("ingredientMobileSheet"),
|
||||
onClose: handlers.closeIngredientMobileSheet,
|
||||
},
|
||||
{
|
||||
root: document.getElementById("unloadingGroupMobileSheet"),
|
||||
onClose: handlers.closeUnloadingGroupMobileSheet,
|
||||
},
|
||||
{
|
||||
root: document.getElementById("recipeHelpModal"),
|
||||
onClose: handlers.closeRecipeHelpModal,
|
||||
},
|
||||
];
|
||||
|
||||
sheets.forEach(({ root, onClose }) => bindSheetGestures(root, onClose, mobileQuery));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const LIST_LABELS = {
|
||||
dispensers: "Загрузка кормораздатчиков",
|
||||
periods: "Загрузка периодов",
|
||||
recipes: "Загрузка рейсов",
|
||||
mill: "Загрузка рецептов",
|
||||
period: "Загрузка рейсов",
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural loading — один spinner на список (не 4 копии).
|
||||
*/
|
||||
export function buildRecipeRowsSkeletonHtml(variant = "period") {
|
||||
const label = LIST_LABELS[variant] || LIST_LABELS.period;
|
||||
return `<div class="zt-loading-list dashboard-list-skeleton" data-skeleton-variant="${variant}" aria-busy="true" aria-label="${label}">
|
||||
<div class="zt-loading-placeholder">
|
||||
<div class="zt-spinner" role="status" aria-hidden="true"></div>
|
||||
<p class="zt-loading-caption">${label}…</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function cloneTemplateOuterHtml(templateId) {
|
||||
const tpl = document.getElementById(templateId);
|
||||
const el = tpl?.content?.firstElementChild;
|
||||
if (!el) return "";
|
||||
return el.outerHTML;
|
||||
}
|
||||
|
||||
export function cloneTemplateInto(container, templateId) {
|
||||
const tpl = document.getElementById(templateId);
|
||||
const el = tpl?.content?.firstElementChild;
|
||||
if (!el || !container) return false;
|
||||
container.appendChild(el.cloneNode(true));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export class SyncPanel {
|
||||
constructor(handlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
async handleAction(action, event, actionEl) {
|
||||
switch (action) {
|
||||
case "open-settings":
|
||||
event.preventDefault();
|
||||
await this.handlers.openSettings(event);
|
||||
return true;
|
||||
case "close-settings":
|
||||
this.handlers.closeSettings();
|
||||
return true;
|
||||
case "toggle-credentials-form":
|
||||
this.handlers.toggleCredentialsForm();
|
||||
return true;
|
||||
case "toggle-sync-form":
|
||||
this.handlers.toggleSyncForm();
|
||||
return true;
|
||||
case "change-credentials":
|
||||
await this.handlers.changeCredentials();
|
||||
return true;
|
||||
case "sync-edit-client":
|
||||
event.stopPropagation();
|
||||
this.handlers.editSyncClientSettings(actionEl);
|
||||
return true;
|
||||
case "sync-delete-client":
|
||||
event.stopPropagation();
|
||||
this.handlers.deleteSyncClientSettings(actionEl);
|
||||
return true;
|
||||
case "sync-save-client":
|
||||
this.handlers.saveSyncClientDisplayName(actionEl.dataset.nodeId || "");
|
||||
return true;
|
||||
case "sync-cancel-edit":
|
||||
await this.handlers.loadSyncClientsSettings();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export class ReconnectingEventSource {
|
||||
constructor(url, { retryDelayMs = 2000, maxRetryDelayMs = 15000 } = {}) {
|
||||
this.url = url;
|
||||
this.retryDelayMs = retryDelayMs;
|
||||
this.maxRetryDelayMs = maxRetryDelayMs;
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
this._closed = false;
|
||||
this._es = null;
|
||||
this._currentDelay = retryDelayMs;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
_connect() {
|
||||
if (this._closed) return;
|
||||
this._es = new EventSource(this.url);
|
||||
this._es.onmessage = (event) => {
|
||||
this._currentDelay = this.retryDelayMs;
|
||||
if (this.onmessage) this.onmessage(event);
|
||||
};
|
||||
this._es.onerror = (event) => {
|
||||
if (this.onerror) this.onerror(event);
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this._closed) return;
|
||||
if (this._es) this._es.close();
|
||||
const delay = this._currentDelay;
|
||||
this._currentDelay = Math.min(this._currentDelay * 2, this.maxRetryDelayMs);
|
||||
setTimeout(() => this._connect(), delay);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._closed = true;
|
||||
if (this._es) this._es.close();
|
||||
this._es = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user