basic drag-and-drop, fixed end of scan behavior

This commit is contained in:
2026-02-19 15:25:49 -05:00
parent d9ebd38382
commit 193f65bd98
12 changed files with 1043 additions and 31 deletions
@@ -26,6 +26,13 @@ export interface TrackContextMenuDetail {
clientY: number;
}
/** Detail payload for the track-dragstart custom event. */
export interface TrackDragStartDetail {
track: library.Track;
index: number;
dataTransfer: DataTransfer | null;
}
/**
* Self-contained dropdown that renders an album's track list.
*
@@ -236,6 +243,38 @@ export class AlbumDropdown extends LitElement {
);
}
private onTrackDragStart(
e: DragEvent,
track: library.Track,
index: number,
) {
// Delegate to the parent cover-grid which
// owns the selection state and drag-image.
this.dispatchEvent(
new CustomEvent<TrackDragStartDetail>(
'track-dragstart',
{
bubbles: true,
composed: true,
detail: {
track,
index,
dataTransfer: e.dataTransfer,
},
},
),
);
}
private onTrackDragEnd() {
this.dispatchEvent(
new CustomEvent('track-dragend', {
bubbles: true,
composed: true,
}),
);
}
private onTrackContextMenu(
e: MouseEvent,
track: library.Track,
@@ -288,6 +327,7 @@ export class AlbumDropdown extends LitElement {
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
@click=${(e: MouseEvent) =>
this.onTrackClick(e, track, index)}
@dblclick=${(e: MouseEvent) =>
@@ -298,6 +338,13 @@ export class AlbumDropdown extends LitElement {
)}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
@dragstart=${(e: DragEvent) =>
this.onTrackDragStart(
e,
track,
index,
)}
@dragend=${() => this.onTrackDragEnd()}
>
<span class="track-number">
${displayNumber}
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
@@ -22,7 +22,18 @@ import type {
TrackClickDetail,
TrackDblClickDetail,
TrackContextMenuDetail,
TrackDragStartDetail,
} from './album-dropdown.js';
import {
DRAG_MIME,
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import type { DragPayload } from '@utils/drag-controller';
import {
createDragImage,
removeDragImage,
} from '@utils/drag-image';
/**
* Discriminated context menu target so we know whether the
@@ -51,6 +62,7 @@ const ZOOM_STEP = 16;
@customElement('cover-grid')
export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this);
private cancelScanComplete?: () => void;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
@@ -130,6 +142,18 @@ export class CoverGrid extends LitElement {
});
}
private dragImageEl: HTMLElement | null = null;
/**
* Pre-resolved file paths for selected albums, keyed by album ID.
* Populated asynchronously when albums are selected so that
* dragstart can read them synchronously.
*/
private albumFilePathCache = new Map<
number,
string[]
>();
/** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
@@ -450,7 +474,7 @@ export class CoverGrid extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
@@ -474,7 +498,7 @@ export class CoverGrid extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
EventsOff(Events.LibraryScanComplete);
this.cancelScanComplete?.();
document.removeEventListener(
'click',
this.closeHandler,
@@ -1834,6 +1858,71 @@ export class CoverGrid extends LitElement {
}
}
/**
* Pre-resolve file paths for all selected albums so
* that dragstart can read them synchronously. Called
* fire-and-forget whenever the album selection changes.
*/
private async warmAlbumFilePathCache(): Promise<void> {
const selected = this.albums.filter((a) =>
this.selectedAlbums.has(a.ID),
);
// Prune stale entries.
for (const id of this.albumFilePathCache.keys()) {
if (!this.selectedAlbums.has(id)) {
this.albumFilePathCache.delete(id);
}
}
// Fetch missing entries.
for (const album of selected) {
if (this.albumFilePathCache.has(album.ID)) {
continue;
}
try {
const tracks = await GetAlbumTracks(
album.ID,
);
// Only store if still selected.
if (this.selectedAlbums.has(album.ID)) {
this.albumFilePathCache.set(
album.ID,
tracks.map((t) => t.FilePath),
);
}
} catch {
// Silently skip — drag will just not
// include this album's paths.
}
}
}
/**
* Read cached file paths for the current album
* selection. Returns an empty array if any albums
* haven't been cached yet.
*/
private getCachedSelectedAlbumFilePaths(): string[] {
const result: string[] = [];
for (const album of this.albums) {
if (!this.selectedAlbums.has(album.ID)) {
continue;
}
const paths =
this.albumFilePathCache.get(album.ID);
if (paths) {
result.push(...paths);
}
}
return result;
}
/* ====================================================================
* Track selection helpers
* ==================================================================== */
@@ -1966,6 +2055,7 @@ export class CoverGrid extends LitElement {
}
this.selectedAlbums = next;
void this.warmAlbumFilePathCache();
} else if (isCtrl) {
const next = new Set(this.selectedAlbums);
@@ -1977,6 +2067,7 @@ export class CoverGrid extends LitElement {
this.selectedAlbums = next;
this.lastSelectedAlbumIndex = index;
void this.warmAlbumFilePathCache();
} else {
void this.toggleDropdown(album);
this.lastSelectedAlbumIndex = index;
@@ -2028,6 +2119,7 @@ export class CoverGrid extends LitElement {
this.selectedAlbums = new Set([
hit.album.ID,
]);
void this.warmAlbumFilePathCache();
}
this.contextMenuTarget = { kind: 'album' };
@@ -2147,6 +2239,120 @@ export class CoverGrid extends LitElement {
this.openContextMenuAt(clientX, clientY);
};
/* ====================================================================
* Drag source (dropdown tracks)
* ==================================================================== */
private onTrackDragStart = (
e: CustomEvent<TrackDragStartDetail>,
) => {
const { track, dataTransfer } = e.detail;
let filePaths: string[];
if (this.selectedTracks.has(track.FilePath)) {
filePaths =
this.getSelectedTrackFilePaths();
} else {
filePaths = [track.FilePath];
}
if (filePaths.length === 0) return;
if (dataTransfer) {
const payload: DragPayload = {
filePaths,
source: 'cover-grid',
};
dataTransfer.effectAllowed = 'copy';
dataTransfer.setData(
DRAG_MIME,
JSON.stringify(payload),
);
this.dragImageEl = createDragImage(
filePaths.length,
);
dataTransfer.setDragImage(
this.dragImageEl,
0,
0,
);
}
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Drag source (album cards)
* ==================================================================== */
private onAlbumDragStart = (e: DragEvent) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
// Read file paths synchronously from the
// pre-warmed cache. The cache is populated
// asynchronously whenever the album selection
// changes, so by the time the user drags, the
// data is already available.
let filePaths: string[];
if (this.selectedAlbums.has(hit.album.ID)) {
filePaths =
this.getCachedSelectedAlbumFilePaths();
} else {
// Single unselected album — check cache.
filePaths =
this.albumFilePathCache.get(
hit.album.ID,
) ?? [];
}
if (filePaths.length === 0) {
// Cache miss — cancel the drag.
e.preventDefault();
return;
}
setDragPayload(e, {
filePaths,
source: 'cover-grid',
});
this.dragImageEl = createDragImage(
filePaths.length,
);
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onAlbumDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Grid click (empty area)
* ==================================================================== */
@@ -2395,6 +2601,9 @@ export class CoverGrid extends LitElement {
role="button"
data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}"
draggable=${selected ? 'true' : 'false'}
@dragstart=${this.onAlbumDragStart}
@dragend=${this.onAlbumDragEnd}
>
<div class="cover-container">
${album.CoverArtPath
@@ -2519,6 +2728,8 @@ export class CoverGrid extends LitElement {
@track-click=${this.onTrackClick}
@track-dblclick=${this.onTrackDblClick}
@track-contextmenu=${this.onTrackContextMenu}
@track-dragstart=${this.onTrackDragStart}
@track-dragend=${this.onTrackDragEnd}
></album-dropdown>
${this.getAfterEntries().length > 0