add drag/drop from lists into queue

This commit is contained in:
jeffvli
2025-11-09 20:44:03 -08:00
parent d7e2ec0860
commit 489daa6353
16 changed files with 718 additions and 355 deletions
@@ -1,28 +1,25 @@
import {
ItemListStateActions,
ItemListStateItem,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/shared/types/domain-types';
/**
* Converts domain data to ItemListStateItem format
* Type guard to assert that an item has the required properties for dragging
*/
const convertToItemListItem = (
data: Album | AlbumArtist | Artist | Playlist | Song,
itemType: LibraryItem,
): ItemListStateItem => {
return {
_serverId: data._serverId,
id: data.id,
itemType,
};
const hasRequiredDragProperties = (
item: unknown,
): item is ItemListStateItemWithRequiredProperties => {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as any).id === 'string' &&
'itemType' in item &&
typeof (item as any).itemType === 'string' &&
'_serverId' in item &&
typeof (item as any)._serverId === 'string'
);
};
/**
@@ -34,31 +31,40 @@ const convertToItemListItem = (
* @param itemType - The type of library item
* @param internalState - The item list state actions (optional)
* @param updateSelection - Whether to update the selection state (default: true)
* @returns Array of ItemListItem objects that should be dragged
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
*/
export const getDraggedItems = (
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
internalState?: ItemListStateActions,
updateSelection: boolean = true,
): ItemListStateItem[] => {
): ItemListStateItemWithRequiredProperties[] => {
if (!data || !internalState) {
return [];
}
// Convert data to ItemListStateItem format
const draggedItem = convertToItemListItem(data, itemType);
if (!hasRequiredDragProperties(data)) {
return [];
}
const draggedItem = data as ItemListStateItemWithRequiredProperties;
const previouslySelected = internalState.getSelected();
const isDraggingSelectedItem = previouslySelected.some((selected) => selected.id === data.id);
const isDraggingSelectedItem = previouslySelected.some((selected) => {
if (hasRequiredDragProperties(selected)) {
return selected.id === data.id;
}
return false;
});
const draggedItems: ItemListStateItem[] = [];
const draggedItems: ItemListStateItemWithRequiredProperties[] = [];
if (isDraggingSelectedItem) {
// If dragging a selected item, drag all selected items
draggedItems.push(...previouslySelected);
const selectedItems = previouslySelected.filter(
(item): item is ItemListStateItemWithRequiredProperties =>
hasRequiredDragProperties(item),
);
draggedItems.push(...selectedItems);
} else {
// If dragging an unselected item, select it and drag only it
if (updateSelection) {
internalState.setSelected([draggedItem]);
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemListStateItemWithRequiredProperties } from '/@/renderer/components/item-list/helpers/item-list-state';
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { Play } from '/@/shared/types/types';
@@ -10,16 +10,13 @@ export const useDefaultItemListControls = () => {
const controls: ItemControls = useMemo(() => {
return {
onClick: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
onClick: ({ event, internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState || !event) {
return;
}
const itemListItem: ItemListStateItem = {
_serverId: item._serverId,
id: item.id,
itemType,
};
// Use the full item instead of converting to minimal
const itemListItem = item as ItemListStateItemWithRequiredProperties;
// Check if ctrl/cmd key is held for multi-selection
if (event.ctrlKey || event.metaKey) {
@@ -29,13 +26,27 @@ export const useDefaultItemListControls = () => {
// Remove this item from selection
const currentSelected = internalState.getSelected();
const filteredSelected = currentSelected.filter(
(selectedItem) => selectedItem.id !== item.id,
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' &&
selectedItem !== null &&
'id' in selectedItem &&
(selectedItem as any).id !== item.id,
);
internalState.setSelected(filteredSelected);
} else {
// Add this item to selection
const currentSelected = internalState.getSelected();
const newSelected = [...currentSelected, itemListItem];
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
itemListItem,
];
internalState.setSelected(newSelected);
}
}
@@ -44,7 +55,12 @@ export const useDefaultItemListControls = () => {
const selectedItems = internalState.getSelected();
const lastSelectedItem = selectedItems[selectedItems.length - 1];
if (lastSelectedItem) {
if (
lastSelectedItem &&
typeof lastSelectedItem === 'object' &&
lastSelectedItem !== null &&
'id' in lastSelectedItem
) {
// Get the data array from internalState
const data = internalState.getData();
// Filter out null/undefined values (e.g., header row)
@@ -53,7 +69,7 @@ export const useDefaultItemListControls = () => {
);
// Find the indices of the last selected item and current item
const lastIndex = internalState.findItemIndex(lastSelectedItem.id);
const lastIndex = internalState.findItemIndex((lastSelectedItem as any).id);
const currentIndex = internalState.findItemIndex(item.id);
if (lastIndex !== -1 && currentIndex !== -1) {
@@ -61,28 +77,38 @@ export const useDefaultItemListControls = () => {
const startIndex = Math.min(lastIndex, currentIndex);
const stopIndex = Math.max(lastIndex, currentIndex);
const rangeItems: ItemListStateItem[] = [];
const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = validData[i];
if (
rangeItem &&
typeof rangeItem === 'object' &&
'id' in rangeItem &&
'_serverId' in rangeItem
'_serverId' in rangeItem &&
'itemType' in rangeItem
) {
rangeItems.push({
_serverId: (rangeItem as any)._serverId,
id: (rangeItem as any).id,
itemType,
});
rangeItems.push(
rangeItem as ItemListStateItemWithRequiredProperties,
);
}
}
// Merge with existing selection, avoiding duplicates
const currentSelected = internalState.getSelected();
const newSelected = [...currentSelected];
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
];
rangeItems.forEach((rangeItem) => {
if (!newSelected.some((selected) => selected.id === rangeItem.id)) {
if (
!newSelected.some(
(selected) => (selected as any).id === rangeItem.id,
)
) {
newSelected.push(rangeItem);
}
});
@@ -97,7 +123,11 @@ export const useDefaultItemListControls = () => {
// If this item is already the only selected item, deselect it
const selectedItems = internalState.getSelected();
const isOnlySelected =
selectedItems.length === 1 && selectedItems[0].id === item.id;
selectedItems.length === 1 &&
typeof selectedItems[0] === 'object' &&
selectedItems[0] !== null &&
'id' in selectedItems[0] &&
(selectedItems[0] as any).id === item.id;
if (isOnlySelected) {
internalState.clearSelected();
@@ -111,16 +141,14 @@ export const useDefaultItemListControls = () => {
console.log('onDoubleClick', item, itemType, internalState);
},
onExpand: ({ internalState, item, itemType }: DefaultItemControlProps) => {
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
return internalState?.toggleExpanded({
_serverId: item._serverId,
id: item.id,
itemType,
});
return internalState?.toggleExpanded(
item as ItemListStateItemWithRequiredProperties,
);
},
onFavorite: ({
@@ -1,4 +1,4 @@
import { ItemListAction, ItemListStateItem, ItemListState } from './item-list-state';
import { ItemListAction, ItemListState, ItemListStateItemWithRequiredProperties } from './item-list-state';
/**
* Action creators for item grid state management
@@ -17,27 +17,27 @@ export const itemGridActions = {
type: 'CLEAR_SELECTED',
}),
setDragging: (items: ItemListStateItem[]): ItemListAction => ({
setDragging: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({
payload: items,
type: 'SET_DRAGGING',
}),
setExpanded: (items: ItemListStateItem[]): ItemListAction => ({
setExpanded: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({
payload: items,
type: 'SET_EXPANDED',
}),
setSelected: (items: ItemListStateItem[]): ItemListAction => ({
setSelected: (items: ItemListStateItemWithRequiredProperties[]): ItemListAction => ({
payload: items,
type: 'SET_SELECTED',
}),
toggleExpanded: (item: ItemListStateItem): ItemListAction => ({
toggleExpanded: (item: ItemListStateItemWithRequiredProperties): ItemListAction => ({
payload: item,
type: 'TOGGLE_EXPANDED',
}),
toggleSelected: (item: ItemListStateItem): ItemListAction => ({
toggleSelected: (item: ItemListStateItemWithRequiredProperties): ItemListAction => ({
payload: item,
type: 'TOGGLE_SELECTED',
}),
@@ -48,7 +48,7 @@ export const itemGridActions = {
* These can be reused to extract specific data from state
*/
export const itemGridSelectors = {
getDragging: (state: ItemListState): ItemListStateItem[] => {
getDragging: (state: ItemListState): unknown[] => {
return Array.from(state.draggingItems.values());
},
@@ -60,7 +60,7 @@ export const itemGridSelectors = {
return Array.from(state.dragging);
},
getExpanded: (state: ItemListState): ItemListStateItem[] => {
getExpanded: (state: ItemListState): unknown[] => {
return Array.from(state.expandedItems.values());
},
@@ -72,7 +72,7 @@ export const itemGridSelectors = {
return Array.from(state.expanded);
},
getSelected: (state: ItemListState): ItemListStateItem[] => {
getSelected: (state: ItemListState): unknown[] => {
return Array.from(state.selectedItems.values());
},
@@ -146,7 +146,7 @@ export const itemListUtils = {
* Toggle expansion of all items in a list
*/
toggleAllExpanded: (
items: ItemListStateItem[],
items: ItemListStateItemWithRequiredProperties[],
currentState: ItemListState,
): ItemListAction => {
const allExpanded = items.every((item) => currentState.expanded.has(item.id));
@@ -157,7 +157,7 @@ export const itemListUtils = {
* Toggle selection of all items in a list
*/
toggleAllSelected: (
items: ItemListStateItem[],
items: ItemListStateItemWithRequiredProperties[],
currentState: ItemListState,
): ItemListAction => {
const allSelected = items.every((item) => currentState.selected.has(item.id));
@@ -4,11 +4,11 @@ import { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item
import { LibraryItem } from '/@/shared/types/domain-types';
export type ItemListAction =
| { payload: ItemListStateItem; type: 'TOGGLE_EXPANDED' }
| { payload: ItemListStateItem; type: 'TOGGLE_SELECTED' }
| { payload: ItemListStateItem[]; type: 'SET_DRAGGING' }
| { payload: ItemListStateItem[]; type: 'SET_EXPANDED' }
| { payload: ItemListStateItem[]; type: 'SET_SELECTED' }
| { payload: ItemListStateItemWithRequiredProperties; type: 'TOGGLE_EXPANDED' }
| { payload: ItemListStateItemWithRequiredProperties; type: 'TOGGLE_SELECTED' }
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_DRAGGING' }
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_EXPANDED' }
| { payload: ItemListStateItemWithRequiredProperties[]; type: 'SET_SELECTED' }
| { type: 'CLEAR_ALL' }
| { type: 'CLEAR_DRAGGING' }
| { type: 'CLEAR_EXPANDED' }
@@ -16,11 +16,11 @@ export type ItemListAction =
export interface ItemListState {
dragging: Set<string>;
draggingItems: Map<string, ItemListStateItem>;
draggingItems: Map<string, unknown>;
expanded: Set<string>;
expandedItems: Map<string, ItemListStateItem>;
expandedItems: Map<string, unknown>;
selected: Set<string>;
selectedItems: Map<string, ItemListStateItem>;
selectedItems: Map<string, unknown>;
version: number;
}
@@ -31,11 +31,11 @@ export interface ItemListStateActions {
clearSelected: () => void;
findItemIndex: (itemId: string) => number;
getData: () => unknown[];
getDragging: () => ItemListStateItem[];
getDragging: () => unknown[];
getDraggingIds: () => string[];
getExpanded: () => ItemListStateItem[];
getExpanded: () => unknown[];
getExpandedIds: () => string[];
getSelected: () => ItemListStateItem[];
getSelected: () => unknown[];
getSelectedIds: () => string[];
getVersion: () => number;
hasDragging: () => boolean;
@@ -44,11 +44,11 @@ export interface ItemListStateActions {
isDragging: (itemId: string) => boolean;
isExpanded: (itemId: string) => boolean;
isSelected: (itemId: string) => boolean;
setDragging: (items: ItemListStateItem[]) => void;
setExpanded: (items: ItemListStateItem[]) => void;
setSelected: (items: ItemListStateItem[]) => void;
toggleExpanded: (item: ItemListStateItem) => void;
toggleSelected: (item: ItemListStateItem) => void;
setDragging: (items: ItemListStateItemWithRequiredProperties[]) => void;
setExpanded: (items: ItemListStateItemWithRequiredProperties[]) => void;
setSelected: (items: ItemListStateItemWithRequiredProperties[]) => void;
toggleExpanded: (item: ItemListStateItemWithRequiredProperties) => void;
toggleSelected: (item: ItemListStateItemWithRequiredProperties) => void;
}
export interface ItemListStateItem {
@@ -57,6 +57,12 @@ export interface ItemListStateItem {
itemType: LibraryItem;
}
export type ItemListStateItemWithRequiredProperties = Record<string, unknown> & {
_serverId: string;
id: string;
itemType: LibraryItem;
};
/**
* Reusable reducer for item grid state management
* Can be used in different components or contexts
@@ -101,7 +107,7 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
case 'SET_DRAGGING': {
const newDragging = new Set<string>();
const newDraggingItems = new Map<string, ItemListStateItem>();
const newDraggingItems = new Map<string, unknown>();
action.payload.forEach((item) => {
newDragging.add(item.id);
@@ -118,9 +124,7 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
case 'SET_EXPANDED': {
const newExpanded = new Set<string>();
const newExpandedItems = new Map<string, ItemListStateItem>();
console.log('SET_EXPANDED', action.payload);
const newExpandedItems = new Map<string, unknown>();
if (action.payload.length > 0) {
const firstItem = action.payload[0];
@@ -138,7 +142,7 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
case 'SET_SELECTED': {
const newSelected = new Set<string>();
const newSelectedItems = new Map<string, ItemListStateItem>();
const newSelectedItems = new Map<string, unknown>();
action.payload.forEach((item) => {
newSelected.add(item.id);
@@ -155,7 +159,7 @@ export const itemListReducer = (state: ItemListState, action: ItemListAction): I
case 'TOGGLE_EXPANDED': {
const newExpanded = new Set<string>();
const newExpandedItems = new Map<string, ItemListStateItem>();
const newExpandedItems = new Map<string, unknown>();
// If the item is already expanded, collapse it
if (state.expanded.has(action.payload.id)) {
@@ -212,23 +216,23 @@ export const initialItemListState: ItemListState = {
export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActions => {
const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
const setExpanded = useCallback((items: ItemListStateItem[]) => {
const setExpanded = useCallback((items: ItemListStateItemWithRequiredProperties[]) => {
dispatch({ payload: items, type: 'SET_EXPANDED' });
}, []);
const setDragging = useCallback((items: ItemListStateItem[]) => {
const setDragging = useCallback((items: ItemListStateItemWithRequiredProperties[]) => {
dispatch({ payload: items, type: 'SET_DRAGGING' });
}, []);
const setSelected = useCallback((items: ItemListStateItem[]) => {
const setSelected = useCallback((items: ItemListStateItemWithRequiredProperties[]) => {
dispatch({ payload: items, type: 'SET_SELECTED' });
}, []);
const toggleExpanded = useCallback((item: ItemListStateItem) => {
const toggleExpanded = useCallback((item: ItemListStateItemWithRequiredProperties) => {
dispatch({ payload: item, type: 'TOGGLE_EXPANDED' });
}, []);
const toggleSelected = useCallback((item: ItemListStateItem) => {
const toggleSelected = useCallback((item: ItemListStateItemWithRequiredProperties) => {
dispatch({ payload: item, type: 'TOGGLE_SELECTED' });
}, []);