Bläddra i källkod

Add multi-select and auto-scroll for track drag-and-drop

Multi-select:
- Ctrl/Cmd+click toggles items in/out of selection
- Shift+click range-selects all tracks between the anchor and cursor
- Plain click clears selection and opens the track as before
- Selected tracks get .multi-selected style (blue inset shadow + tint)
- Dragging a selected item drags all selected tracks together;
  dragging an unselected item clears selection and drags just that one
- Confirm dialog shows "Move N tracks to folder X?" for batches;
  all moves run in parallel via Promise.all
- Selection is cleared on reload

Auto-scroll:
- While dragging tracks, moving the cursor within 48px of the top or
  bottom edge of #browser-list scrolls the list at 6px/frame via rAF
- Scrolling stops on dragleave or dragend, enabling drops into folders
  that were scrolled out of view

No backend restart required (frontend-only change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be 8 timmar sedan
förälder
incheckning
b76e2f2eae
2 ändrade filer med 125 tillägg och 29 borttagningar
  1. 11 1
      gpx-vis-frontend/css/style.css
  2. 114 28
      gpx-vis-frontend/js/browser.js

+ 11 - 1
gpx-vis-frontend/css/style.css

@@ -506,10 +506,20 @@ form button[type="submit"]:hover {
   opacity: 0.4;
 }
 
+/* ===== Multi-selected track ===== */
+.track-item.multi-selected {
+  background: #e8f4fd;
+  box-shadow: inset 3px 0 0 var(--color-blue);
+}
+
+.track-item.multi-selected:hover {
+  background: #d6ecf8;
+}
+
 /* ===== Local (guest) active track ===== */
 .track-item.local-active {
   background: #e8f4fd;
-  border-left: 3px solid var(--color-blue);
+  box-shadow: inset 3px 0 0 var(--color-blue);
 }
 
 /* ===== Tree loading placeholder ===== */

+ 114 - 28
gpx-vis-frontend/js/browser.js

@@ -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');
       }