From 322013f40f2112cf85655925ad54a0dd8776b6d3 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 16 Feb 2026 04:15:01 -0500 Subject: [PATCH] added multi-select to cover grid, added shift select to track list --- .../src/components/cover-grid/cover-grid.ts | 164 ++++++++++++++---- .../src/components/track-list/track-list.ts | 67 ++++++- 2 files changed, 197 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 4b0b3ab..ad2195e 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -1,6 +1,5 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; -import { EventsEmit } from '@runtime/runtime'; import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library'; import { library } from '@go/models'; import { QueueController } from '@store/controllers/queue-controller'; @@ -16,6 +15,8 @@ import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker export class CoverGrid extends LitElement { private queue = new QueueController(this); + private lastSelectedIndex: number | null = null; + private closeHandler = () => this.closeContextMenu(); static override styles = css` @@ -44,8 +45,13 @@ export class CoverGrid extends LitElement { background-color: rgba(255, 255, 255, 0.1); } - .album-card:focus { - outline: 2px solid #1db954; + .album-card.selected { + outline: 2px solid #ffd43b; + outline-offset: 2px; + } + + .album-card:focus-visible { + outline: 2px solid #ffd43b; outline-offset: 2px; } @@ -171,7 +177,7 @@ export class CoverGrid extends LitElement { private contextMenuOpen = false; @state() - private contextMenuAlbum: library.Album | null = null; + private selectedAlbums: Set = new Set(); @state() private playlistSubmenuOpen = false; @@ -203,6 +209,8 @@ export class CoverGrid extends LitElement { this.loading = true; const albums = await GetAllAlbums(); this.albums = albums ?? []; + this.selectedAlbums = new Set(); + this.lastSelectedIndex = null; } catch (error) { console.error("Error loading albums:", error); this.albums = []; @@ -211,6 +219,39 @@ export class CoverGrid extends LitElement { } } + private selectRange( + from: number, + to: number, + ): Set { + const start = Math.min(from, to); + const end = Math.max(from, to); + const ids = new Set(); + + for (let i = start; i <= end; i++) { + const album = this.albums[i]; + + if (album) { + ids.add(album.ID); + } + } + + return ids; + } + + private async getSelectedFilePaths(): Promise { + const selected = this.albums.filter( + (a) => this.selectedAlbums.has(a.ID), + ); + const allPaths: string[] = []; + + for (const album of selected) { + const paths = await this.getAlbumFilePaths(album); + allPaths.push(...paths); + } + + return allPaths; + } + private async getAlbumFilePaths(album: library.Album): Promise { try { const tracks = await GetAlbumTracks(album.ID); @@ -227,7 +268,10 @@ export class CoverGrid extends LitElement { e.preventDefault(); e.stopPropagation(); - this.contextMenuAlbum = album; + if (!this.selectedAlbums.has(album.ID)) { + this.selectedAlbums = new Set([album.ID]); + } + this.contextMenuOpen = true; this.updateComplete.then(() => { @@ -254,9 +298,9 @@ export class CoverGrid extends LitElement { } private async onContextMenuAction(action: string) { - if (!this.contextMenuAlbum) return; + if (this.selectedAlbums.size === 0) return; - const filePaths = await this.getAlbumFilePaths(this.contextMenuAlbum); + const filePaths = await this.getSelectedFilePaths(); if (filePaths.length === 0) return; @@ -272,17 +316,20 @@ export class CoverGrid extends LitElement { break; } - this.closeContextMenu(); + this.closeContextMenu(true); } - private closeContextMenu() { + private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; this.closePlaylistSubmenu(); this.contextMenuOpen = false; - this.contextMenuAlbum = null; this.playlistFilePaths = []; + if (clearSelection) { + this.selectedAlbums = new Set(); + } + const popup = this.contextMenuPopup; if (popup) { @@ -293,10 +340,9 @@ export class CoverGrid extends LitElement { private async showPlaylistSubmenu() { if (this.playlistSubmenuOpen) return; - if (this.contextMenuAlbum) { - this.playlistFilePaths = await this.getAlbumFilePaths( - this.contextMenuAlbum, - ); + if (this.selectedAlbums.size > 0) { + this.playlistFilePaths = + await this.getSelectedFilePaths(); } this.playlistSubmenuOpen = true; @@ -336,16 +382,31 @@ export class CoverGrid extends LitElement { this.closeContextMenu(); }; - private renderAlbumCard = (album: library.Album): unknown => { + private renderAlbumCard = ( + album: library.Album, + index: number, + ): unknown => { + const selected = this.selectedAlbums.has(album.ID); + + const classes = [ + 'album-card', + selected ? 'selected' : '', + ] + .filter(Boolean) + .join(' '); + return html`
this.onAlbumClick(album)} - @keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)} - @contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)} + @click=${(e: MouseEvent) => + this.onAlbumClick(e, album, index)} + @keydown=${(e: KeyboardEvent) => + this.onAlbumKeydown(e, album, index)} + @contextmenu=${(e: MouseEvent) => + this.onAlbumContextMenu(e, album)} >
${album.CoverArtPath @@ -379,21 +440,65 @@ export class CoverGrid extends LitElement { return name.charAt(0).toUpperCase(); } - private onAlbumClick(album: library.Album) { - EventsEmit('AlbumSelected', album); - this.dispatchEvent( - new CustomEvent('album-selected', { - detail: album, - bubbles: true, - composed: true, - }) + private onGridClick(e: MouseEvent) { + const clickedCard = e.composedPath().some( + (el) => + el instanceof HTMLElement && + el.classList.contains('album-card'), ); + + if (!clickedCard) { + this.selectedAlbums = new Set(); + this.lastSelectedIndex = null; + } } - private onAlbumKeydown(e: KeyboardEvent, album: library.Album) { + private onAlbumClick( + e: MouseEvent, + album: library.Album, + index: number, + ) { + const isCtrl = e.ctrlKey || e.metaKey; + const isShift = e.shiftKey; + + if (isShift && this.lastSelectedIndex !== null) { + const range = this.selectRange( + this.lastSelectedIndex, + index, + ); + const next = new Set(this.selectedAlbums); + + for (const id of range) { + next.add(id); + } + + this.selectedAlbums = next; + } else if (isCtrl) { + const next = new Set(this.selectedAlbums); + + if (next.has(album.ID)) { + next.delete(album.ID); + } else { + next.add(album.ID); + } + + this.selectedAlbums = next; + this.lastSelectedIndex = index; + } else { + this.selectedAlbums = new Set([album.ID]); + this.lastSelectedIndex = index; + } + } + + private onAlbumKeydown( + e: KeyboardEvent, + album: library.Album, + index: number, + ) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - this.onAlbumClick(album); + this.selectedAlbums = new Set([album.ID]); + this.lastSelectedIndex = index; } } @@ -416,6 +521,7 @@ export class CoverGrid extends LitElement { scroller .items=${this.albums} .renderItem=${this.renderAlbumCard} + @click=${(e: MouseEvent) => this.onGridClick(e)} .layout=${grid({ itemSize: { width: '176px', height: '230px' }, gap: '16px', diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index b736c87..29b9b70 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -37,6 +37,8 @@ export class TrackList extends LitElement { @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + private lastSelectedIndex: number | null = null; + private closeHandler = () => this.closeContextMenu(); static override styles = css` @@ -171,6 +173,7 @@ export class TrackList extends LitElement { const tracks = await GetAllTracks(); this.tracks = tracks; this.selectedTracks = new Set(); + this.lastSelectedIndex = null; if (tracks[0]) { LogPrint(tracks[0].TrackName); @@ -186,10 +189,59 @@ export class TrackList extends LitElement { .map((t) => t.FilePath); } - private onTrackRowClick(e: MouseEvent, track: library.Track) { - const isCtrl = e.ctrlKey || e.metaKey; + private selectRange(from: number, to: number): Set { + const start = Math.min(from, to); + const end = Math.max(from, to); + const paths = new Set(); - if (isCtrl) { + for (let i = start; i <= end; i++) { + const track = this.tracks[i]; + + if (track) { + paths.add(track.FilePath); + } + } + + return paths; + } + + private onTrackRowClick( + e: MouseEvent, + track: library.Track, + index: number, + ) { + const isCtrl = e.ctrlKey || e.metaKey; + const isShift = e.shiftKey; + + if (isShift && this.lastSelectedIndex !== null) { + const range = this.selectRange( + this.lastSelectedIndex, + index, + ); + + if (isCtrl) { + // Ctrl+Shift: add range to existing selection. + const next = new Set(this.selectedTracks); + + for (const path of range) { + next.add(path); + } + + this.selectedTracks = next; + } else { + // Shift only: add range to existing selection. + const next = new Set(this.selectedTracks); + + for (const path of range) { + next.add(path); + } + + this.selectedTracks = next; + } + + // Don't update anchor on shift-click so user can + // adjust the range endpoint with another shift-click. + } else if (isCtrl) { const next = new Set(this.selectedTracks); if (next.has(track.FilePath)) { @@ -199,8 +251,10 @@ export class TrackList extends LitElement { } this.selectedTracks = next; + this.lastSelectedIndex = index; } else { this.selectedTracks = new Set([track.FilePath]); + this.lastSelectedIndex = index; } } @@ -326,7 +380,10 @@ export class TrackList extends LitElement { return currentTrack.filePath === track.FilePath; } - private renderTrackRow = (track: library.Track): unknown => { + private renderTrackRow = ( + track: library.Track, + index: number, + ): unknown => { const active = this.isActiveTrack(track); const selected = this.selectedTracks.has(track.FilePath); @@ -341,7 +398,7 @@ export class TrackList extends LitElement { return html`
this.onTrackRowClick(e, track)} + @click=${(e: MouseEvent) => this.onTrackRowClick(e, track, index)} @dblclick=${() => this.onTrackRowDblClick(track)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}