left4me/l4d2web/static/js/blueprint-overlay-picker.js
mwiegand a4e9f6cd26
feat(l4d2-web): blueprint overlay picker — drag-list + add-dropdown
Replace the per-row checkbox + numeric Order table on the blueprint
detail page with a drag-to-reorder list of selected overlays plus a
native <select> for adding more. Removing uses an × button per row;
the option sorted-inserts back into the dropdown alphabetically.

Native HTML5 drag-and-drop, no library, no JS-disabled fallback.
Server contract is unchanged: each list row owns one hidden
<input name="overlay_ids">, DOM order = submission order, and the
existing fallback_position branch in ordered_overlay_ids_from_form
absorbs the now-omitted overlay_position_<id> fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:37:11 +02:00

159 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const root = document.querySelector("[data-overlay-list]");
if (!root) return;
const select = document.querySelector("[data-overlay-add]");
const empty = document.querySelector("[data-overlay-empty]");
let dragRow = null;
const clearDropMarkers = () => {
root
.querySelectorAll(".drop-before, .drop-after")
.forEach((el) => el.classList.remove("drop-before", "drop-after"));
};
const refreshEmpty = () => {
if (!empty) return;
empty.hidden = root.children.length > 0;
};
const buildHiddenInput = (overlayId) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = "overlay_ids";
input.value = overlayId;
return input;
};
const buildRow = (overlayId, overlayName) => {
const li = document.createElement("li");
li.className = "overlay-picker-row";
li.draggable = true;
li.dataset.overlayId = overlayId;
li.dataset.overlayName = overlayName;
const handle = document.createElement("span");
handle.className = "overlay-picker-handle";
handle.setAttribute("aria-hidden", "true");
handle.textContent = "⋮⋮";
const name = document.createElement("span");
name.className = "overlay-picker-name";
name.textContent = overlayName;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "overlay-picker-remove";
remove.dataset.action = "remove";
remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×";
li.append(handle, name, remove, buildHiddenInput(overlayId));
return li;
};
const insertOptionSorted = (overlayId, overlayName) => {
if (!select) return;
const option = document.createElement("option");
option.value = overlayId;
option.dataset.overlayName = overlayName;
option.textContent = overlayName;
const lower = overlayName.toLowerCase();
const existing = Array.from(select.querySelectorAll("option[value]:not([value=''])"));
const next = existing.find(
(opt) => (opt.dataset.overlayName || opt.textContent).toLowerCase() > lower,
);
if (next) {
select.insertBefore(option, next);
} else {
select.appendChild(option);
}
};
root.addEventListener("dragstart", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (!row) return;
dragRow = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", row.dataset.overlayId || "");
}
});
root.addEventListener("dragend", () => {
if (dragRow) {
dragRow.classList.remove("is-dragging");
}
clearDropMarkers();
dragRow = null;
});
root.addEventListener("dragover", (event) => {
if (!dragRow) return;
const row = event.target.closest(".overlay-picker-row");
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
clearDropMarkers();
if (row && row !== dragRow) {
const rect = row.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2;
row.classList.add(before ? "drop-before" : "drop-after");
}
});
root.addEventListener("dragleave", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (row) {
row.classList.remove("drop-before", "drop-after");
}
});
root.addEventListener("drop", (event) => {
if (!dragRow) return;
event.preventDefault();
const row = event.target.closest(".overlay-picker-row");
if (row && row !== dragRow) {
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (before) {
root.insertBefore(dragRow, row);
} else {
root.insertBefore(dragRow, row.nextSibling);
}
} else if (!row) {
root.appendChild(dragRow);
}
clearDropMarkers();
});
root.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action='remove']");
if (!trigger) return;
const row = trigger.closest(".overlay-picker-row");
if (!row) return;
const overlayId = row.dataset.overlayId;
const overlayName = row.dataset.overlayName || "";
row.remove();
if (overlayId) insertOptionSorted(overlayId, overlayName);
refreshEmpty();
});
if (select) {
select.addEventListener("change", () => {
const value = select.value;
if (!value) return;
const option = select.querySelector(`option[value="${CSS.escape(value)}"]`);
const overlayName = option ? option.dataset.overlayName || option.textContent : "";
root.appendChild(buildRow(value, overlayName));
if (option) option.remove();
select.value = "";
refreshEmpty();
});
}
refreshEmpty();
})();