blastoise/public/trackContainer.js

898 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `<div class="empty">${emptyMsg}</div>`;
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 tracks are always draggable, queue tracks need edit permission
const isDraggable = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner);
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 always, queue/playlist with permissions
const canDrag = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner);
console.log(`[Drag] wireTrackEvents: type=${type} canDrag=${canDrag} canEditQueue=${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') {
console.log(`[Drag] Wiring drop handlers for queue track ${originalIndex}`);
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;
});
};
})();