|
|
@@ -3,10 +3,17 @@ const Browser = (() => {
|
|
|
let expandedDirs = new Set();
|
|
|
let dirContents = {}; // 'root' | dirId -> { dirs, tracks }
|
|
|
let dirMeta = {}; // dirId -> { id, name, parentId }
|
|
|
- let dragTrackId = null;
|
|
|
- let dragTrackName = null;
|
|
|
+ let dragTrackIds = []; // ids being dragged (may be multiple)
|
|
|
let allDirs = []; // flat list for move dialog
|
|
|
|
|
|
+ // ===== Multi-select state =====
|
|
|
+ let selectedTrackIds = new Set(); // Ctrl/Shift-selected track ids
|
|
|
+ let lastClickedTrackId = null; // anchor for Shift-range
|
|
|
+
|
|
|
+ // ===== Auto-scroll state =====
|
|
|
+ let autoScrollRaf = null;
|
|
|
+ let autoScrollDir = 0; // -1 up, +1 down, 0 stopped
|
|
|
+
|
|
|
async function init() {
|
|
|
document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
|
|
|
document.getElementById('upload-btn').addEventListener('click', () => {
|
|
|
@@ -21,6 +28,7 @@ const Browser = (() => {
|
|
|
const prevExpanded = new Set(expandedDirs);
|
|
|
dirContents = {};
|
|
|
dirMeta = {};
|
|
|
+ selectedTrackIds.clear();
|
|
|
|
|
|
await loadRootContents();
|
|
|
|
|
|
@@ -135,7 +143,8 @@ const Browser = (() => {
|
|
|
|
|
|
for (const track of tracks) {
|
|
|
const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
|
|
|
- html += `<div class="tree-item track-item" data-id="${track.id}" draggable="true"
|
|
|
+ const isSel = selectedTrackIds.has(track.id);
|
|
|
+ html += `<div class="tree-item track-item${isSel ? ' multi-selected' : ''}" data-id="${track.id}" draggable="true"
|
|
|
data-name="${escAttr(track.name)}" style="padding-left:${28 + baseIndent}px">
|
|
|
<span class="item-icon">🗺️</span>
|
|
|
<span class="item-name">${escHtml(track.name)}</span>
|
|
|
@@ -186,11 +195,40 @@ const Browser = (() => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
- // Track click → open on map; hover → highlight on map
|
|
|
+ // Track click → open on map / multi-select; hover → highlight on map
|
|
|
container.querySelectorAll('.track-item').forEach(el => {
|
|
|
el.addEventListener('click', (e) => {
|
|
|
if (e.target.closest('.item-actions')) return;
|
|
|
- openTrack(parseInt(el.dataset.id));
|
|
|
+ const id = parseInt(el.dataset.id);
|
|
|
+ if (e.ctrlKey || e.metaKey) {
|
|
|
+ // Ctrl/Cmd: toggle this item in selection
|
|
|
+ if (selectedTrackIds.has(id)) {
|
|
|
+ selectedTrackIds.delete(id);
|
|
|
+ } else {
|
|
|
+ selectedTrackIds.add(id);
|
|
|
+ }
|
|
|
+ lastClickedTrackId = id;
|
|
|
+ renderTree();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (e.shiftKey && lastClickedTrackId !== null) {
|
|
|
+ // Shift: range select between lastClickedTrackId and this one
|
|
|
+ const allItems = Array.from(container.querySelectorAll('.track-item'))
|
|
|
+ .map(el2 => parseInt(el2.dataset.id));
|
|
|
+ const a = allItems.indexOf(lastClickedTrackId);
|
|
|
+ const b = allItems.indexOf(id);
|
|
|
+ if (a !== -1 && b !== -1) {
|
|
|
+ const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
|
+ for (let i = lo; i <= hi; i++) selectedTrackIds.add(allItems[i]);
|
|
|
+ renderTree();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Plain click: clear selection and open track
|
|
|
+ selectedTrackIds.clear();
|
|
|
+ lastClickedTrackId = id;
|
|
|
+ renderTree();
|
|
|
+ openTrack(id);
|
|
|
});
|
|
|
el.addEventListener('mouseenter', () => MapView.highlightTrack(parseInt(el.dataset.id)));
|
|
|
el.addEventListener('mouseleave', () => MapView.unhighlightTrack());
|
|
|
@@ -221,31 +259,57 @@ const Browser = (() => {
|
|
|
// Drag tracks
|
|
|
container.querySelectorAll('.track-item[draggable="true"]').forEach(el => {
|
|
|
el.addEventListener('dragstart', (e) => {
|
|
|
- dragTrackId = parseInt(el.dataset.id);
|
|
|
- dragTrackName = el.dataset.name;
|
|
|
+ const id = parseInt(el.dataset.id);
|
|
|
+ // If dragging a selected item, drag all selected; otherwise just this one
|
|
|
+ if (selectedTrackIds.has(id)) {
|
|
|
+ dragTrackIds = [...selectedTrackIds];
|
|
|
+ } else {
|
|
|
+ selectedTrackIds.clear();
|
|
|
+ dragTrackIds = [id];
|
|
|
+ }
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
- e.dataTransfer.setData('text/plain', String(dragTrackId));
|
|
|
- el.classList.add('dragging');
|
|
|
+ e.dataTransfer.setData('text/plain', String(id));
|
|
|
+ container.querySelectorAll('.track-item').forEach(item => {
|
|
|
+ if (dragTrackIds.includes(parseInt(item.dataset.id))) item.classList.add('dragging');
|
|
|
+ });
|
|
|
});
|
|
|
el.addEventListener('dragend', () => {
|
|
|
- el.classList.remove('dragging');
|
|
|
+ container.querySelectorAll('.track-item.dragging').forEach(item => item.classList.remove('dragging'));
|
|
|
container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
|
|
|
- dragTrackId = null;
|
|
|
- dragTrackName = null;
|
|
|
+ dragTrackIds = [];
|
|
|
+ stopAutoScroll();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ // Auto-scroll when dragging near list edges
|
|
|
+ const list = document.getElementById('browser-list');
|
|
|
+ list.addEventListener('dragover', (e) => {
|
|
|
+ if (dragTrackIds.length === 0) return;
|
|
|
+ const rect = list.getBoundingClientRect();
|
|
|
+ const ZONE = 48;
|
|
|
+ if (e.clientY < rect.top + ZONE) {
|
|
|
+ startAutoScroll(list, -1);
|
|
|
+ } else if (e.clientY > rect.bottom - ZONE) {
|
|
|
+ startAutoScroll(list, 1);
|
|
|
+ } else {
|
|
|
+ stopAutoScroll();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ list.addEventListener('dragleave', (e) => {
|
|
|
+ if (!list.contains(e.relatedTarget)) stopAutoScroll();
|
|
|
+ });
|
|
|
+
|
|
|
// Drop targets (folders)
|
|
|
container.querySelectorAll('.dir-item[data-drop-target]').forEach(el => {
|
|
|
el._dragCount = 0;
|
|
|
el.addEventListener('dragenter', (e) => {
|
|
|
- if (dragTrackId === null) return;
|
|
|
+ if (dragTrackIds.length === 0) return;
|
|
|
e.preventDefault();
|
|
|
el._dragCount = (el._dragCount || 0) + 1;
|
|
|
el.classList.add('drag-target');
|
|
|
});
|
|
|
el.addEventListener('dragleave', () => {
|
|
|
- if (dragTrackId === null) return;
|
|
|
+ if (dragTrackIds.length === 0) return;
|
|
|
el._dragCount = (el._dragCount || 1) - 1;
|
|
|
if (el._dragCount <= 0) {
|
|
|
el._dragCount = 0;
|
|
|
@@ -253,7 +317,7 @@ const Browser = (() => {
|
|
|
}
|
|
|
});
|
|
|
el.addEventListener('dragover', (e) => {
|
|
|
- if (dragTrackId === null) return;
|
|
|
+ if (dragTrackIds.length === 0) return;
|
|
|
e.preventDefault();
|
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
});
|
|
|
@@ -262,18 +326,38 @@ const Browser = (() => {
|
|
|
e.stopPropagation();
|
|
|
el._dragCount = 0;
|
|
|
el.classList.remove('drag-target');
|
|
|
- if (dragTrackId === null) return;
|
|
|
+ if (dragTrackIds.length === 0) return;
|
|
|
const targetDirId = parseInt(el.dataset.id);
|
|
|
const targetDirName = el.dataset.name;
|
|
|
- const trackId = dragTrackId;
|
|
|
- const trackName = dragTrackName;
|
|
|
- dragTrackId = null;
|
|
|
- dragTrackName = null;
|
|
|
- confirmMoveTrack(trackId, trackName, targetDirId, targetDirName);
|
|
|
+ const ids = dragTrackIds.slice();
|
|
|
+ dragTrackIds = [];
|
|
|
+ stopAutoScroll();
|
|
|
+ confirmMoveTracks(ids, targetDirId, targetDirName);
|
|
|
});
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ // ===== Auto-scroll helpers =====
|
|
|
+
|
|
|
+ function startAutoScroll(el, dir) {
|
|
|
+ if (autoScrollDir === dir) return;
|
|
|
+ autoScrollDir = dir;
|
|
|
+ stopAutoScroll();
|
|
|
+ function step() {
|
|
|
+ el.scrollTop += dir * 6;
|
|
|
+ autoScrollRaf = requestAnimationFrame(step);
|
|
|
+ }
|
|
|
+ autoScrollRaf = requestAnimationFrame(step);
|
|
|
+ }
|
|
|
+
|
|
|
+ function stopAutoScroll() {
|
|
|
+ autoScrollDir = 0;
|
|
|
+ if (autoScrollRaf !== null) {
|
|
|
+ cancelAnimationFrame(autoScrollRaf);
|
|
|
+ autoScrollRaf = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// ===== Expand / Select =====
|
|
|
|
|
|
async function toggleExpand(dirId) {
|
|
|
@@ -482,20 +566,22 @@ const Browser = (() => {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // ===== Move Track =====
|
|
|
+ // ===== Move Tracks =====
|
|
|
|
|
|
- function confirmMoveTrack(trackId, trackName, targetDirId, targetDirName) {
|
|
|
- document.getElementById('confirm-title').textContent = 'Move Track';
|
|
|
- document.getElementById('confirm-message').textContent =
|
|
|
- `Move "${trackName}" to folder "${targetDirName}"?`;
|
|
|
+ function confirmMoveTracks(trackIds, targetDirId, targetDirName) {
|
|
|
+ document.getElementById('confirm-title').textContent = 'Move Track' + (trackIds.length > 1 ? 's' : '');
|
|
|
+ document.getElementById('confirm-message').textContent = trackIds.length === 1
|
|
|
+ ? `Move this track to folder "${targetDirName}"?`
|
|
|
+ : `Move ${trackIds.length} tracks to folder "${targetDirName}"?`;
|
|
|
const modal = document.getElementById('confirm-modal');
|
|
|
modal.style.display = 'flex';
|
|
|
document.getElementById('confirm-ok-btn').onclick = async () => {
|
|
|
modal.style.display = 'none';
|
|
|
try {
|
|
|
- await API.updateTrack(trackId, { directoryId: targetDirId });
|
|
|
+ await Promise.all(trackIds.map(id => API.updateTrack(id, { directoryId: targetDirId })));
|
|
|
+ selectedTrackIds.clear();
|
|
|
await reload();
|
|
|
- showToast('Track moved', 'success');
|
|
|
+ showToast(trackIds.length === 1 ? 'Track moved' : `${trackIds.length} tracks moved`, 'success');
|
|
|
} catch (e) {
|
|
|
showToast('Error: ' + e.message, 'error');
|
|
|
}
|