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>
159 lines
4.8 KiB
JavaScript
159 lines
4.8 KiB
JavaScript
(() => {
|
||
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();
|
||
})();
|