From fee85a14e77c8b66898118408d09397b3c4f2938 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Thu, 2 Jul 2026 06:58:13 -0500 Subject: [PATCH] v3 library: native right-click context menu on cards (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v3): native right-click context menu on library cards Right-clicking a song card now opens the same overflow (more) menu at the pointer, so the actions (Play, Add to playlist, Save for later, and any plugin card actions) are reachable without aiming for the small button. openCardMenu gains an optional pointer position (fixed + viewport-clamped when opened via right-click, the existing card-anchored absolute otherwise). Adds a "Save for later" row to the menu too, closing the gap where Save was only the inline button. Shares one menu definition + the libraryCardActions registry, so plugin actions appear in both paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3): valid JS emoji escape in card-menu toast + dismiss orphaned menu on re-render (PR #688 review) Replace the Python-style `\U0001f3b5` escape (invalid in JS, rendered literal text) with the 🎵 emoji character in the playlists-updated toast. Also tear down any open card context menu at the start of render(), the full filter/scan-refresh path: the right-click menu is appended to document.body, so replacing root.innerHTML would otherwise leave it floating orphaned until the next document click. Reuses _closeCardMenu. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- static/v3/songs.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 981f797..b17bd08 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -833,7 +833,7 @@ // Per-card action menu, built from the ui.library-card-injection registry // (core Edit/Retune + any plugin-registered actions). let _closeCardMenu = null; // tears down the currently-open card menu + its document closer - function openCardMenu(cardEl, song, anchorBtn) { + function openCardMenu(cardEl, song, anchorBtn, pos) { // Fully close any already-open menu first — removing just the DOM node // (as before) would orphan its document-level click closer. if (_closeCardMenu) _closeCardMenu(); @@ -843,7 +843,9 @@ // Undefined placement defaults to the menu. const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu'); const menu = document.createElement('div'); - menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm'; + // pos (right-click) positions the menu at the pointer via `fixed`; + // the ⋮ button keeps the absolute top-right anchor. + menu.className = 'v3-card-menu z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm ' + (pos ? 'fixed' : 'absolute top-10 right-2'); // Multi-chart entries (P5d): a grouped grid row carries chart_count + // work_key (P5a annotation), so the menu knows inline whether this card // stands for versions. Local library only — the work API is local. @@ -862,6 +864,7 @@ ...(state.provider === 'local' && song.is_split ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []), { id: '__playlist', label: 'Add to playlist' }, + { id: '__save', label: 'Save for later' }, ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })), ]; menu.innerHTML = rows.map((r) => @@ -869,7 +872,17 @@ (r.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') + (r.destructive ? 'text-fb-accent' : 'text-fb-text') + '">' + esc(r.label) + (r.plugin && r.plugin !== 'core' ? '' + esc(r.plugin) + '' : '') + '').join(''); - cardEl.appendChild(menu); + if (pos) { + // Right-click: position the menu at the pointer (fixed, viewport- + // relative), clamped so it never spills off the right/bottom edge. + document.body.appendChild(menu); + const x = Math.min(pos.x, window.innerWidth - menu.offsetWidth - 8); + const y = Math.min(pos.y, window.innerHeight - menu.offsetHeight - 8); + menu.style.left = Math.max(8, x) + 'px'; + menu.style.top = Math.max(8, y) + 'px'; + } else { + cardEl.appendChild(menu); + } // Tear down BOTH the menu and its document-level closer together, so a // menu-item click doesn't leave the closer attached (it would otherwise // leak, retaining this menu's closures until the next document click). @@ -890,6 +903,7 @@ return; } if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; } + if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; } if (reg) await reg.run(id, song, { source: 'v3-songs' }); })); // Tree rows ride the (ungrouped) artists endpoint, so they don't carry @@ -1169,6 +1183,8 @@ playCard(playTarget); // local → play; unsynced remote → sync then play })); el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); }); + // Native right-click opens the same overflow menu at the pointer. + el.addEventListener('contextmenu', (e) => { e.preventDefault(); openCardMenu(el, song, null, { x: e.clientX, y: e.clientY }); }); el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => { e.stopPropagation(); const idx = ab.getAttribute('data-arr'); @@ -1341,7 +1357,7 @@ } if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } if (acts.length && window.fbNotify) { - try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '\U0001f3b5' }); } catch (e) { /* */ } + try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '🎵' }); } catch (e) { /* */ } } done(acts.length ? true : null); } @@ -2538,6 +2554,9 @@ async function render() { const root = document.getElementById('v3-songs'); if (!root) return; + // A full re-render (filter/scan refresh) replaces the DOM the card menu + // anchored to; dismiss any open menu first so it can't float orphaned. + if (_closeCardMenu) _closeCardMenu(); // Restore last-used sort/format/view/filters once, before building the // toolbar so its selects reflect the saved choice (default: Artist A–Z). if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; }