v3 library: native right-click context menu on cards (#688)

* 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-02 13:58:13 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 80caf78306
commit fee85a14e7
+23 -4
View File
@@ -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' ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').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 AZ).
if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; }