Files
yellowjacket/frontend/src/utils/drag-controller.ts
T
2026-02-19 22:37:19 -05:00

145 lines
3.9 KiB
TypeScript

/**
* Shared drag-and-drop coordination for track items.
*
* Uses the HTML5 Drag and Drop API with a custom MIME type so that
* drag sources and drop targets across different shadow roots can
* communicate. A global custom event ("yj-drag-active") is
* dispatched on `document` so that non-participating components
* (sidebar, queue button) can react to the drag lifecycle.
*/
/** MIME type used in dataTransfer for in-app track drags. */
export const DRAG_MIME = 'application/x-yj-tracks';
/** Sources that can originate a drag. */
export type DragSource =
| 'track-list'
| 'cover-grid'
| 'queue'
| 'playlist';
/** Serialized payload stored in dataTransfer. */
export interface DragPayload {
filePaths: string[];
source: DragSource;
sourcePlaylistId?: number;
}
// =====================================================================
// Active drag source tracking
// =====================================================================
let activeDragSource: DragSource | null = null;
let activeDragPlaylistId: number | undefined;
/** Return the source of the in-progress drag, or null. */
export function getActiveDragSource(): DragSource | null {
return activeDragSource;
}
/** Return the playlist ID of the in-progress drag, if any. */
export function getActiveDragPlaylistId():
| number
| undefined {
return activeDragPlaylistId;
}
// =====================================================================
// Global drag-active event
// =====================================================================
export interface DragActiveDetail {
active: boolean;
}
/**
* Notify the entire document that a track drag has started or ended.
* Non-participating components listen for this to show/hide drop
* affordances (e.g. sidebar hover-to-navigate, queue button glow).
*/
export function emitDragActive(active: boolean): void {
if (!active) {
activeDragSource = null;
activeDragPlaylistId = undefined;
}
document.dispatchEvent(
new CustomEvent<DragActiveDetail>(
'yj-drag-active',
{
bubbles: true,
composed: true,
detail: { active },
},
),
);
}
// =====================================================================
// Helpers for drag sources
// =====================================================================
/**
* Populate a DragEvent's dataTransfer with the standard payload.
* Returns false if dataTransfer is unavailable.
*/
export function setDragPayload(
e: DragEvent,
payload: DragPayload,
): boolean {
if (!e.dataTransfer) return false;
activeDragSource = payload.source;
activeDragPlaylistId = payload.sourcePlaylistId;
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData(
DRAG_MIME,
JSON.stringify(payload),
);
return true;
}
// =====================================================================
// Helpers for drop targets
// =====================================================================
/** Check whether a dragover event carries our custom MIME type. */
export function hasTrackPayload(e: DragEvent): boolean {
return (
e.dataTransfer?.types.includes(DRAG_MIME) ?? false
);
}
/**
* Extract the DragPayload from a drop event.
* Returns null if the data is missing or malformed.
*/
export function getDragPayload(
e: DragEvent,
): DragPayload | null {
const raw = e.dataTransfer?.getData(DRAG_MIME);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === 'object' &&
parsed !== null &&
'filePaths' in parsed &&
Array.isArray(
(parsed as DragPayload).filePaths,
)
) {
return parsed as DragPayload;
}
return null;
} catch {
return null;
}
}