Drag-and-drop reordering for tracks in the edit modal

Replaces the up/down arrow buttons on each track card with a grip
handle. Dragging a card shows a red insertion indicator on the target
card's top or bottom edge and drops it into the closest slot on
release, including gaps between cards or past the last card.

The card is only marked draggable while the grip handle is being held,
so text selection and button clicks inside the card body behave
normally. Existing promote_track_id logic (secondary at position 1
becomes primary on save) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-02 02:17:24 +03:00
parent 3d8d7b8efe
commit e0aa6e4817

View File

@ -108,6 +108,7 @@
<div class="um-track-card" id="edit-tc-t1-card">
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">1</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
<div class="um-tc-info">
@ -116,12 +117,6 @@
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
@ -203,6 +198,24 @@
<style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
#editVideoModal.show .modal-dialog { opacity: 1; }
/* ── Track drag-and-drop reorder ─────────────────────────────── */
#edit-tc-list .um-track-card { position: relative; }
#edit-tc-list .um-tc-drag {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin-right: 2px;
color: #666; cursor: grab; user-select: none;
border-radius: 6px; transition: color .15s, background .15s;
flex-shrink: 0;
}
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
#edit-tc-list .um-track-card.dragging {
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
}
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
</style>
<script>
@ -447,33 +460,16 @@ function _editFmtSize(bytes) {
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
}
// ── Track ordering ────────────────────────────────────────────────────────────
function editMoveTrack(cardSuffix, dir) {
const card = document.getElementById('edit-tc-' + cardSuffix);
if (!card) return;
const list = document.getElementById('edit-tc-list');
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const idx = cards.indexOf(card);
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
_editUpdateTrackPositions();
}
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
function _editUpdateTrackPositions() {
const list = document.getElementById('edit-tc-list');
if (!list) return;
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const total = cards.length;
cards.forEach((card, i) => {
const numEl = card.querySelector('.um-tc-num');
if (numEl) numEl.textContent = i + 1;
const badge = card.querySelector('.um-tc-primary');
if (badge) badge.style.display = i === 0 ? '' : 'none';
const up = card.querySelector('.edit-tc-arrow-up');
const down = card.querySelector('.edit-tc-arrow-down');
if (up) up.style.display = (i === 0) ? 'none' : '';
if (down) down.style.display = (i === total - 1) ? 'none' : '';
});
// Set promote_track_id: non-empty only when a secondary is at position 1
const first = cards[0];
@ -486,6 +482,93 @@ function _editUpdateTrackPositions() {
}
}
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
// pick it up without extra wiring. The card starts non-draggable so text
// selection / button clicks work; grabbing the grip handle flips draggable=on
// for the duration of the drag.
(function () {
const list = document.getElementById('edit-tc-list');
if (!list || list.dataset.dndWired) return;
list.dataset.dndWired = '1';
// Reset any draggable="true" set by the initial HTML — we only enable it
// on demand via the grip handle.
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
let dragged = null;
list.addEventListener('mousedown', function (e) {
const handle = e.target.closest('.um-tc-drag');
if (!handle) return;
const card = handle.closest('.um-track-card');
if (!card || card.parentElement !== list) return;
card.setAttribute('draggable', 'true');
});
list.addEventListener('mouseup', function () {
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
});
list.addEventListener('dragstart', function (e) {
const card = e.target.closest('.um-track-card');
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
dragged = card;
card.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
});
list.addEventListener('dragend', function () {
if (dragged) dragged.classList.remove('dragging');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
dragged = null;
});
// Always accept drops anywhere inside the list so the browser doesn't
// veto the drop when the cursor slips into gaps between cards.
list.addEventListener('dragover', function (e) {
if (!dragged) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
const over = e.target.closest('.um-track-card');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
if (!over || over === dragged || over.parentElement !== list) return;
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
over.classList.add(after ? 'drop-after' : 'drop-before');
});
list.addEventListener('drop', function (e) {
if (!dragged) return;
e.preventDefault();
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const over = e.target.closest('.um-track-card');
let refNode;
if (over && over !== dragged && over.parentElement === list) {
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
refNode = after ? over.nextSibling : over;
} else {
// Cursor fell into a gap or outside a card — figure out the closest slot
// by comparing cursor Y against each card's midpoint.
let insertBefore = null;
for (const c of cards) {
if (c === dragged) continue;
const r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
}
refNode = insertBefore; // null → append to end
}
if (refNode === dragged) refNode = dragged.nextSibling;
list.insertBefore(dragged, refNode);
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
_editUpdateTrackPositions();
});
})();
function _editSetLangCsd(wrap, code) {
if (!wrap || !code) return;
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
@ -864,6 +947,7 @@ function _editTrackCard(n, trackId, isExisting) {
card.innerHTML = `
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">${n + 1}</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
<div class="um-tc-info">
@ -872,8 +956,6 @@ function _editTrackCard(n, trackId, isExisting) {
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
${deleteBtn}
</div>