blastoise/public/trackContainer.js

899 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.canEdit] - Whether user can modify queue
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
* @param {boolean} [config.canRemove] - Whether tracks can be removed from playlist
* @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,
canRemove = false,
playlistId = null,
onRender
} = config;
let currentTracks = [];
// Get canEdit dynamically (permissions may change)
const getCanEdit = () => config.canEdit ?? 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 canEdit = getCanEdit();
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 (canEdit && type === 'queue') {
wireQueueContainerDrop(element);
}
if (canRemove && 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' && canEdit) || (type === 'playlist' && canRemove);
const div = M.trackComponent.render(track, {
view: type,
index: filteredIndex,
displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null,
isSelected,
isCached,
isActive,
showPlayButton: type === 'queue',
draggable: isDraggable
});
// Wire up event handlers
wireTrackEvents(div, track, filteredIndex, index, canEdit);
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(draggedTrackIds, 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();
}
}
async function addTracksToPlaylistAt(trackIds, position) {
if (!playlistId) {
console.error("[addTracksToPlaylistAt] No 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 {
console.error("[addTracksToPlaylistAt] Failed:", await res.text());
}
}
async function reorderPlaylist(trackIds, targetIndex) {
if (!playlistId) return;
// Get current track IDs
const current = currentTracks.map(t => (t.track || t).id);
// Remove the dragged tracks from their current positions
const remaining = current.filter(id => !trackIds.includes(id));
// Calculate insertion position (adjusted for removed items before target)
let insertAt = targetIndex;
for (const id of trackIds) {
const originalPos = current.indexOf(id);
if (originalPos < targetIndex) {
insertAt--;
}
}
insertAt = Math.max(0, Math.min(insertAt, remaining.length));
// Insert at new position
const newList = [...remaining.slice(0, insertAt), ...trackIds, ...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();
}
}
function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) {
const trackId = track.id || track.filename;
const index = type === 'queue' ? originalIndex : filteredIndex;
// Click - toggle selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
toggleSelection(index, trackId, e.shiftKey);
render();
};
// Play button (queue only)
const playBtn = div.querySelector('.track-play-btn');
if (playBtn) {
playBtn.onclick = (e) => {
e.stopPropagation();
playTrack(track, originalIndex);
};
}
// Preview button
const previewBtn = div.querySelector('.track-preview-btn');
if (previewBtn) {
previewBtn.onclick = (e) => {
e.stopPropagation();
previewTrack(track);
};
}
// Context menu
div.oncontextmenu = (e) => {
e.preventDefault();
showContextMenu(e, track, originalIndex, canEdit);
};
// Drag start/end handlers - library always, queue/playlist with permissions
const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove);
if (canDrag) {
div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div);
div.ondragend = (e) => handleDragEnd(e, div);
}
// Drop handlers - only queue accepts drops (from library/playlist)
if (canEdit && type === 'queue') {
div.ondragover = (e) => handleDragOver(e, div, originalIndex);
div.ondragleave = (e) => handleDragLeave(e, div);
div.ondrop = (e) => handleDrop(e, div, originalIndex);
}
// For playlist tracks, allow reordering and insertion (separate from canEdit)
if (type === 'playlist' && playlistId && canRemove) {
div.ondragover = (e) => {
e.preventDefault();
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 (for reorder)
if (dragSource === 'playlist' && draggedTrackIds.includes(trackId)) return;
div.classList.add(isAbove ? "drop-above" : "drop-below");
dropTargetIndex = isAbove ? filteredIndex : filteredIndex + 1;
};
div.ondragleave = () => {
div.classList.remove("drop-above", "drop-below");
};
div.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
div.classList.remove("drop-above", "drop-below");
element.classList.remove("drop-target");
if (draggedTrackIds.length > 0 && dropTargetIndex !== null) {
if (dragSource === 'playlist') {
// Reorder within playlist
reorderPlaylist(draggedTrackIds, dropTargetIndex);
} else if (dragSource === 'queue' || dragSource === 'library') {
// Insert at position
addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex);
}
}
draggedTrackIds = [];
draggedIndices = [];
dragSource = null;
dropTargetIndex = null;
};
}
}
function toggleSelection(index, trackId, shiftKey) {
// Queue and playlist use indices, library uses trackIds
const key = type === 'library' ? trackId : index;
const sel = selection[type];
if (shiftKey && lastSelected[type] !== null) {
// Range select
const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
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 (sel.has(key)) {
sel.delete(key);
} else {
sel.add(key);
}
lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index;
}
}
function getFilteredIndex(trackId) {
return currentTracks.findIndex(t => ((t.track || t).id) === trackId);
}
function handleDragStart(e, track, index, div) {
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();
e.dataTransfer.dropEffect = dragSource === 'queue' ? "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 === 'queue' && 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) {
e.preventDefault();
div.classList.remove("drop-above", "drop-below");
if (dropTargetIndex === null) return;
if (dragSource === 'queue' && draggedIndices.length > 0) {
// Reorder within queue
const minDragged = Math.min(...draggedIndices);
const maxDragged = Math.max(...draggedIndices);
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
reorderQueue(draggedIndices, dropTargetIndex);
}
} else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
// Insert tracks from library or playlist
insertTracksAtPosition(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;
}
}
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();
}
}
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();
if (M.synced) {
M.synced = false;
M.showToast("Previewing track (desynced)");
}
}
function showContextMenu(e, track, index, canEdit) {
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)
if (type === 'queue' && !hasSelection) {
menuItems.push({
label: "▶ Play",
action: () => playTrack(track, index)
});
}
// Preview (all views, single track)
if (!hasSelection) {
menuItems.push({
label: "⏵ Preview",
action: () => previewTrack(track)
});
}
// Queue actions
if (type === 'queue' && canEdit) {
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
if (type === 'library' && canEdit) {
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') {
if (canEdit) {
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)
});
}
if (canRemove && 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();
}
}
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();
}
}
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();
}
}
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";
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();
};
sub.appendChild(subEl);
});
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;
});
};
})();