// MusicRoom - Track Container // Manages track lists with selection, drag-and-drop, and context menus (function() { const M = window.MusicRoom; // Track if a drag is in progress (to prevent re-renders from canceling it) let isDragging = false; // Selection state per container type const selection = { queue: new Set(), // indices library: new Set(), // track IDs playlist: new Set() // indices (for duplicate support) }; // Last selected for shift-select const lastSelected = { queue: null, library: null, playlist: null }; // Drag state (shared across containers) let dragSource = null; let draggedIndices = []; let draggedTrackIds = []; let dropTargetIndex = null; // Active context menu let activeContextMenu = null; /** * Create a track container manager * @param {Object} config * @param {string} config.type - 'queue' | 'library' | 'playlist' * @param {HTMLElement} config.element - Container DOM element * @param {Function} config.getTracks - Returns array of tracks to render * @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search) * @param {boolean} [config.canEditQueue] - Whether user can modify queue * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only) * @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder) * @param {string} [config.playlistId] - Playlist ID (for playlist type) * @param {Function} [config.onRender] - Callback after render */ function createContainer(config) { const { type, element, getTracks, getFilteredTracks, canReorder = false, isPlaylistOwner = false, playlistId = null, onRender } = config; let currentTracks = []; // Get canEditQueue dynamically (permissions may change) const getCanEditQueue = () => config.canEditQueue ?? M.canControl(); // Track if this container needs a render after drag ends let pendingRender = false; function render() { // Defer render if a drag is in progress (would cancel the drag) if (isDragging) { pendingRender = true; return; } pendingRender = false; const canEditQueue = getCanEditQueue(); element.innerHTML = ""; // Get tracks (filtered for library, direct for queue/playlist) currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks(); // Always wire up container drop handlers first (even for empty containers) if (canEditQueue && type === 'queue') { wireQueueContainerDrop(element); } if (isPlaylistOwner && type === 'playlist' && playlistId) { wirePlaylistContainerDrop(element); } if (currentTracks.length === 0) { const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here' : type === 'library' ? 'No tracks' : 'No tracks - drag here to add'; element.innerHTML = `
${emptyMsg}
`; if (onRender) onRender(); return; } currentTracks.forEach((item, filteredIndex) => { // item can be { track, originalIndex } or just track const track = item.track || item; const index = type === 'queue' ? filteredIndex : (item.originalIndex ?? filteredIndex); const trackId = track.id || track.filename; // Queue and playlist use indices, library uses trackIds const isSelected = type === 'library' ? selection.library.has(trackId) : selection[type].has(index); const isCached = M.cachedTracks.has(trackId); const isActive = type === 'queue' && index === M.currentIndex; // Library/playlist always draggable (read access), queue needs edit permission const isDraggable = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); const div = M.trackComponent.render(track, { view: type, index: filteredIndex, displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null, isSelected, isCached, isActive, draggable: isDraggable }); // Wire up event handlers wireTrackEvents(div, track, filteredIndex, index, canEditQueue); element.appendChild(div); }); if (onRender) onRender(); } function wirePlaylistContainerDrop(container) { container.ondragover = (e) => { if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') { e.preventDefault(); e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; container.classList.add("drop-target"); } }; container.ondragleave = (e) => { if (!container.contains(e.relatedTarget)) { container.classList.remove("drop-target"); } }; container.ondrop = (e) => { container.classList.remove("drop-target"); // Clear any drop indicators on tracks container.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); if (draggedTrackIds.length > 0) { e.preventDefault(); // If dropTargetIndex was set by a track, use that position // Otherwise append to end const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length; if (dragSource === 'playlist') { reorderPlaylist(draggedIndices, targetPos); } else if (dragSource === 'queue' || dragSource === 'library') { if (currentTracks.length === 0) { addTracksToPlaylist(draggedTrackIds); } else { addTracksToPlaylistAt(draggedTrackIds, targetPos); } } draggedTrackIds = []; draggedIndices = []; dragSource = null; dropTargetIndex = null; } }; } async function addTracksToPlaylist(trackIds) { if (!playlistId) return; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ add: trackIds }) }); if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { M.showToast("Failed to add to playlist", "error"); } } async function addTracksToPlaylistAt(trackIds, position) { if (!playlistId) return; // Get current tracks and insert at position const current = currentTracks.map(t => (t.track || t).id); const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)]; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ set: newList }) }); if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { M.showToast("Failed to add to playlist", "error"); } } async function reorderPlaylist(indices, targetIndex) { if (!playlistId) return; // Get current track IDs const current = currentTracks.map(t => (t.track || t).id); // Sort indices descending for safe removal const sortedIndices = [...indices].sort((a, b) => b - a); // Get track IDs being moved const movedTrackIds = indices.map(i => current[i]); // Calculate insertion position (adjusted for removed items before target) let insertAt = targetIndex; for (const idx of indices) { if (idx < targetIndex) { insertAt--; } } // Remove tracks at indices (from end to preserve indices) const remaining = current.filter((_, i) => !indices.includes(i)); insertAt = Math.max(0, Math.min(insertAt, remaining.length)); // Insert at new position const newList = [...remaining.slice(0, insertAt), ...movedTrackIds, ...remaining.slice(insertAt)]; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ set: newList }) }); if (res.ok) { selection.playlist.clear(); lastSelected.playlist = null; if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { M.showToast("Failed to reorder playlist", "error"); } } function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) { const trackId = track.id || track.filename; const index = type === 'queue' ? originalIndex : filteredIndex; // Click - handle selection (Ctrl = toggle, Shift = range, plain = select only this) div.onclick = (e) => { if (e.target.closest('.track-actions')) return; toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey); render(); }; // Context menu div.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e, track, originalIndex, canEditQueue); }; // Drag start/end handlers - library/playlist always (read access), queue needs edit permission const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); if (canDrag) { div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); div.ondragend = (e) => handleDragEnd(e, div); } // Drop handlers - queue and playlist accept drops if (canEditQueue && type === 'queue') { div.ondragover = (e) => handleDragOver(e, div, originalIndex); div.ondragleave = (e) => handleDragLeave(e, div); div.ondrop = (e) => handleDrop(e, div, originalIndex); } if (type === 'playlist' && playlistId && isPlaylistOwner) { div.ondragover = (e) => handleDragOver(e, div, filteredIndex); div.ondragleave = (e) => handleDragLeave(e, div); div.ondrop = (e) => handleDrop(e, div, filteredIndex); } } function toggleSelection(index, trackId, shiftKey, ctrlKey) { // Queue and playlist use indices, library uses trackIds const key = type === 'library' ? trackId : index; const sel = selection[type]; const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; if (shiftKey && lastSelected[type] !== null) { // Shift+click: Range select (add to existing selection) const start = Math.min(lastSelected[type], currentIdx); const end = Math.max(lastSelected[type], currentIdx); for (let i = start; i <= end; i++) { if (type === 'library') { const t = currentTracks[i]; if (t) { const id = (t.track || t).id; if (id) sel.add(id); } } else { // Queue and playlist use indices sel.add(i); } } } else if (ctrlKey) { // Ctrl+click: Toggle single item if (sel.has(key)) { sel.delete(key); } else { sel.add(key); } lastSelected[type] = currentIdx; } else { // Plain click: Select only this item (clear others) sel.clear(); sel.add(key); lastSelected[type] = currentIdx; } } function getFilteredIndex(trackId) { return currentTracks.findIndex(t => ((t.track || t).id) === trackId); } function handleDragStart(e, track, index, div) { console.log(`[Drag] handleDragStart: type=${type} index=${index} track=${track.title || track.filename}`); isDragging = true; const trackId = track.id || track.filename; dragSource = type; if (type === 'queue') { draggedIndices = selection.queue.has(index) ? [...selection.queue] : [index]; draggedTrackIds = draggedIndices.map(i => M.queue[i]?.id).filter(Boolean); } else if (type === 'playlist') { // Playlist uses indices for selection (supports duplicates) draggedIndices = selection.playlist.has(index) ? [...selection.playlist] : [index]; draggedTrackIds = draggedIndices.map(i => { const t = currentTracks[i]; return t ? (t.track || t).id : null; }).filter(Boolean); } else { // Library uses trackIds draggedTrackIds = selection.library.has(trackId) ? [...selection.library] : [trackId]; draggedIndices = []; } div.classList.add("dragging"); // Use "copyMove" to allow both copy and move operations e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`); } function handleDragEnd(e, div) { isDragging = false; div.classList.remove("dragging"); draggedIndices = []; draggedTrackIds = []; dragSource = null; dropTargetIndex = null; // Clear all drop indicators element.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); // Execute deferred render if any if (pendingRender) { setTimeout(() => render(), 50); } } function handleDragOver(e, div, index) { e.preventDefault(); // Set drop effect based on source if (type === 'queue') { e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; } else if (type === 'playlist') { e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; } const rect = div.getBoundingClientRect(); const midY = rect.top + rect.height / 2; const isAbove = e.clientY < midY; // Clear other indicators element.querySelectorAll(".drop-above, .drop-below").forEach(el => { el.classList.remove("drop-above", "drop-below"); }); // Don't show indicator on dragged items if (dragSource === type && draggedIndices.includes(index)) return; div.classList.add(isAbove ? "drop-above" : "drop-below"); dropTargetIndex = isAbove ? index : index + 1; } function handleDragLeave(e, div) { div.classList.remove("drop-above", "drop-below"); } function handleDrop(e, div, index) { console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`); e.preventDefault(); e.stopPropagation(); div.classList.remove("drop-above", "drop-below"); element.classList.remove("drop-target"); if (dropTargetIndex === null) { console.log(`[Drag] handleDrop: dropTargetIndex is null, aborting`); return; } if (type === 'queue') { if (dragSource === 'queue' && draggedIndices.length > 0) { // Reorder within queue const minDragged = Math.min(...draggedIndices); const maxDragged = Math.max(...draggedIndices); console.log(`[Drag] Reorder check: dropTargetIndex=${dropTargetIndex} minDragged=${minDragged} maxDragged=${maxDragged}`); if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { console.log(`[Drag] Calling reorderQueue(${draggedIndices}, ${dropTargetIndex})`); reorderQueue(draggedIndices, dropTargetIndex); } else { console.log(`[Drag] Skipping reorder - dropping on self`); } } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { // Insert tracks from library or playlist insertTracksAtPosition(draggedTrackIds, dropTargetIndex); } } else if (type === 'playlist') { if (dragSource === 'playlist' && draggedIndices.length > 0) { // Reorder within playlist reorderPlaylist(draggedIndices, dropTargetIndex); } else if ((dragSource === 'queue' || dragSource === 'library') && draggedTrackIds.length > 0) { // Insert at position addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex); } } draggedIndices = []; draggedTrackIds = []; dragSource = null; dropTargetIndex = null; } function wireQueueContainerDrop(container) { container.ondragover = (e) => { if (dragSource === 'library' || dragSource === 'playlist') { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; if (M.queue.length === 0) { container.classList.add("drop-target"); } } }; container.ondragleave = (e) => { if (!container.contains(e.relatedTarget)) { container.classList.remove("drop-target"); } }; container.ondrop = (e) => { container.classList.remove("drop-target"); if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { e.preventDefault(); const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; insertTracksAtPosition(draggedTrackIds, targetIndex); draggedTrackIds = []; dragSource = null; dropTargetIndex = null; } }; } async function reorderQueue(indices, targetIndex) { if (!M.currentChannelId) return; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ move: indices, to: targetIndex }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { selection.queue.clear(); lastSelected.queue = null; } else { M.showToast("Failed to reorder queue", "error"); } } async function insertTracksAtPosition(trackIds, position) { if (!M.currentChannelId) return; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ add: trackIds, insertAt: position }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); clearSelection(); } else { M.showToast("Failed to add tracks", "error"); } } async function playTrack(track, index) { const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); if (type === 'queue') { // Jump to track in queue if (M.synced && M.currentChannelId) { const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index }) }); if (res.status === 403) M.flashPermissionDenied(); } else { // Local playback M.currentIndex = index; M.currentTrackId = trackId; M.serverTrackDuration = track.duration; M.setTrackTitle(title); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); render(); } } } async function previewTrack(track) { const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); M.currentTrackId = trackId; M.serverTrackDuration = track.duration; M.setTrackTitle(title); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); // Desync and disable auto-resync if (M.synced || M.wantSync) { M.synced = false; M.wantSync = false; M.showToast("Previewing track (desynced)"); M.updateUI(); } } function showContextMenu(e, track, index, canEditQueue) { const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const sel = selection[type]; const hasSelection = sel.size > 0; const selectedCount = hasSelection ? sel.size : 1; // Get IDs/indices for bulk operations let idsForAction, indicesToRemove; if (type === 'queue') { indicesToRemove = hasSelection ? [...sel] : [index]; idsForAction = indicesToRemove.map(i => M.queue[i]?.id).filter(Boolean); } else if (type === 'playlist') { // Playlist uses indices for selection/removal (supports duplicates) indicesToRemove = hasSelection ? [...sel] : [index]; idsForAction = indicesToRemove.map(i => currentTracks[i]?.track?.id || currentTracks[i]?.id).filter(Boolean); } else { // Library uses trackIds idsForAction = hasSelection ? [...sel] : [trackId]; } const menuItems = []; // Play (queue only, single track or single selection) if (type === 'queue' && selectedCount === 1) { menuItems.push({ label: "▶ Play", action: () => playTrack(track, index) }); } // Preview (all views, single track or single selection) if (selectedCount === 1) { menuItems.push({ label: "⏵ Preview", action: () => previewTrack(track) }); } // Queue actions if (type === 'queue' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again", action: () => addToQueue(idsForAction) }); menuItems.push({ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", action: () => addToQueue(idsForAction, true) }); menuItems.push({ label: selectedCount > 1 ? `✕ Remove ${selectedCount}` : "✕ Remove", danger: true, action: () => removeFromQueue(indicesToRemove) }); } // Library actions - can add to queue if user has queue edit permission if (type === 'library' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) }); menuItems.push({ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", action: () => addToQueue(idsForAction, true) }); } // Playlist actions if (type === 'playlist') { // Can add to queue if user has queue edit permission if (canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) }); menuItems.push({ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", action: () => addToQueue(idsForAction, true) }); } // Can remove if user owns the playlist if (isPlaylistOwner && playlistId) { menuItems.push({ label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove", danger: true, action: () => removeFromPlaylist(indicesToRemove) }); } } // Preload (library/queue, non-stream mode) if ((type === 'library' || type === 'queue') && !M.streamOnly) { menuItems.push({ label: selectedCount > 1 ? `Preload ${selectedCount}` : "Preload", action: () => { const uncached = idsForAction.filter(id => !M.cachedTracks.has(id)); if (uncached.length === 0) { M.showToast("Already cached"); return; } M.showToast(`Preloading ${uncached.length}...`); uncached.forEach(id => M.downloadAndCacheTrack(id)); } }); } // Add to Playlist if (M.playlists && !M.currentUser?.is_guest) { const submenu = M.playlists.showAddToPlaylistMenu(idsForAction); if (submenu && submenu.length > 0) { menuItems.push({ label: idsForAction.length > 1 ? `📁 Add ${idsForAction.length} to Playlist...` : "📁 Add to Playlist...", submenu }); } } // Copy link (single) if (!hasSelection) { menuItems.push({ label: "🔗 Copy link", action: () => { navigator.clipboard.writeText(`${location.origin}/listen/${encodeURIComponent(trackId)}`); M.showToast("Link copied"); } }); } // Clear selection if (hasSelection) { menuItems.push({ label: "Clear selection", action: () => { clearSelection(); render(); } }); } M.contextMenu.show(e, menuItems); } async function addToQueue(trackIds, playNext = false) { if (!M.currentChannelId) { M.showToast("No channel selected"); return; } const body = playNext ? { add: trackIds, insertAt: (M.currentIndex ?? 0) + 1 } : { add: trackIds }; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast(playNext ? "Playing next" : "Added to queue"); clearSelection(); render(); } else { M.showToast("Failed to add to queue", "error"); } } async function removeFromQueue(indices) { if (!M.currentChannelId) return; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ remove: indices }) }); if (res.status === 403) M.flashPermissionDenied(); else if (res.ok) { M.showToast("Removed"); clearSelection(); render(); } else { M.showToast("Failed to remove from queue", "error"); } } async function removeFromPlaylist(indices) { if (!playlistId) return; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ remove: indices }) }); if (res.ok) { M.showToast("Removed from playlist"); clearSelection(); if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { M.showToast("Failed to remove from playlist", "error"); } } function clearSelection() { selection[type].clear(); lastSelected[type] = null; } function getSelection() { return [...selection[type]]; } return { render, clearSelection, getSelection, get currentTracks() { return currentTracks; } }; } // Context menu rendering (shared) function showContextMenuUI(e, items) { e.preventDefault(); hideContextMenu(); const menu = document.createElement("div"); menu.className = "context-menu"; items.forEach(item => { if (item.separator) { const sep = document.createElement("div"); sep.className = "context-menu-separator"; menu.appendChild(sep); return; } const el = document.createElement("div"); el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : ""); el.textContent = item.label; if (item.submenu) { el.classList.add("has-submenu"); el.innerHTML += ' ▸'; const sub = document.createElement("div"); sub.className = "context-submenu"; const subInner = document.createElement("div"); subInner.className = "context-submenu-inner"; item.submenu.forEach(subItem => { const subEl = document.createElement("div"); subEl.className = "context-menu-item"; subEl.textContent = subItem.label; subEl.onclick = (e) => { e.stopPropagation(); hideContextMenu(); subItem.action(); }; subInner.appendChild(subEl); }); sub.appendChild(subInner); el.appendChild(sub); } else if (!item.disabled) { el.onclick = () => { hideContextMenu(); item.action(); }; } menu.appendChild(el); }); menu.style.left = e.clientX + "px"; menu.style.top = e.clientY + "px"; document.body.appendChild(menu); // Adjust if off-screen const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { menu.style.left = (window.innerWidth - rect.width - 5) + "px"; } if (rect.bottom > window.innerHeight) { menu.style.top = (window.innerHeight - rect.height - 5) + "px"; } activeContextMenu = menu; // Close on click outside setTimeout(() => { document.addEventListener("click", hideContextMenu, { once: true }); }, 0); } function hideContextMenu() { if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null; } } // Export M.trackContainer = { createContainer }; M.contextMenu = { show: showContextMenuUI, hide: hideContextMenu }; // Clear all selections helper M.clearAllSelections = function() { Object.keys(selection).forEach(k => { selection[k].clear(); lastSelected[k] = null; }); }; })();