diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index e9a837e9c..b7aae385f 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -23,7 +23,7 @@ import { Playlist, Song, } from '/@/shared/types/domain-types'; -import { DragTarget } from '/@/shared/types/drag-and-drop'; +import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; export interface ItemCardProps { controls?: ItemControls; @@ -341,7 +341,7 @@ const PosterItemCard = ({ return []; } - const draggedItems = getDraggedItems(data, itemType, internalState); + const draggedItems = getDraggedItems(data, internalState); return draggedItems.map((item) => item.id); }, getItem: () => { @@ -349,14 +349,16 @@ const PosterItemCard = ({ return []; } - return [data]; + const draggedItems = getDraggedItems(data, internalState); + return draggedItems; }, + itemType, onDragStart: () => { if (!data || !internalState) { return; } - const draggedItems = getDraggedItems(data, itemType, internalState); + const draggedItems = getDraggedItems(data, internalState); internalState.setDragging(draggedItems); }, onDrop: () => { @@ -364,6 +366,10 @@ const PosterItemCard = ({ internalState.setDragging([]); } }, + operation: + itemType === LibraryItem.QUEUE_SONG + ? [DragOperation.REORDER, DragOperation.ADD] + : [DragOperation.ADD], target: DragTarget.ALBUM, }, isEnabled: !!enableDrag && !!data, diff --git a/src/renderer/components/item-list/helpers/get-dragged-items.ts b/src/renderer/components/item-list/helpers/get-dragged-items.ts index fcf6e46ac..27a96ff73 100644 --- a/src/renderer/components/item-list/helpers/get-dragged-items.ts +++ b/src/renderer/components/item-list/helpers/get-dragged-items.ts @@ -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]); } diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 3a44418c7..638fcd910 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -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: ({ diff --git a/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts b/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts index 065800157..45ce8c598 100644 --- a/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts +++ b/src/renderer/components/item-list/helpers/item-list-reducer-utils.ts @@ -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)); diff --git a/src/renderer/components/item-list/helpers/item-list-state.ts b/src/renderer/components/item-list/helpers/item-list-state.ts index 548ffac4a..f066a7b3c 100644 --- a/src/renderer/components/item-list/helpers/item-list-state.ts +++ b/src/renderer/components/item-list/helpers/item-list-state.ts @@ -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; - draggingItems: Map; + draggingItems: Map; expanded: Set; - expandedItems: Map; + expandedItems: Map; selected: Set; - selectedItems: Map; + selectedItems: Map; 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 & { + _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(); - const newDraggingItems = new Map(); + const newDraggingItems = new Map(); 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(); - const newExpandedItems = new Map(); - - console.log('SET_EXPANDED', action.payload); + const newExpandedItems = new Map(); 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(); - const newSelectedItems = new Map(); + const newSelectedItems = new Map(); 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(); - const newExpandedItems = new Map(); + const newExpandedItems = new Map(); // 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' }); }, []); diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index db5d59d37..72918be44 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -119,6 +119,44 @@ opacity: 0.5; } +.container.data-row.dragged-over-top::before { + position: absolute; + top: -2px; + right: 0; + left: 0; + z-index: 3; + height: 2px; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-primary); +} + +.container.data-row.dragged-over-top.dragged-over-first-cell::before { + right: -9999px; + left: -9999px; + margin-right: 9999px; + margin-left: 9999px; +} + +.container.data-row.dragged-over-bottom::after { + position: absolute; + right: 0; + bottom: -2px; + left: 0; + z-index: 3; + height: 2px; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-primary); +} + +.container.data-row.dragged-over-bottom.dragged-over-first-cell::after { + right: -9999px; + left: -9999px; + margin-right: 9999px; + margin-left: 9999px; +} + .container.data-row > * { position: relative; z-index: 2; @@ -126,6 +164,8 @@ .container.data-row { cursor: pointer; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; } .header-container { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 9b29317ff..5cb66b0cf 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -35,13 +35,17 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { Icon } from '/@/shared/components/icon/icon'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; -import { DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; +import { LibraryItem } from '/@/shared/types/domain-types'; +import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; import { TableColumn } from '/@/shared/types/types'; export interface ItemTableListColumn extends CellComponentProps {} export interface ItemTableListInnerColumn extends ItemTableListColumn { controls: ItemControls; + dragRef?: null | React.Ref; + isDraggedOver?: 'bottom' | 'top' | null; + isDragging?: boolean; type: TableColumn; } @@ -49,9 +53,149 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { const type = props.columns[props.columnIndex].id as TableColumn; const isHeaderEnabled = !!props.enableHeader; + const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true; + const item = isDataRow ? props.data[props.rowIndex] : null; + const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; + + const { + isDraggedOver, + isDragging: isDraggingLocal, + ref: dragRef, + } = useDragDrop({ + drag: { + getId: () => { + if (!item || !isDataRow) { + return []; + } + + const draggedItems = getDraggedItems(item as any, props.internalState); + + console.log('getId', draggedItems); + + return draggedItems.map((draggedItem) => draggedItem.id); + }, + getItem: () => { + if (!item || !isDataRow) { + return []; + } + + const draggedItems = getDraggedItems(item as any, props.internalState); + + console.log('getItem', draggedItems); + + return draggedItems; + }, + itemType: props.itemType, + onDragStart: () => { + if (!item || !isDataRow || !props.internalState) { + return; + } + + const draggedItems = getDraggedItems(item as any, props.internalState); + + props.internalState.setDragging(draggedItems); + }, + onDrop: () => { + if (props.internalState) { + props.internalState.setDragging([]); + } + }, + operation: + props.itemType === LibraryItem.QUEUE_SONG + ? [DragOperation.REORDER, DragOperation.ADD] + : [DragOperation.ADD], + target: DragTargetMap[props.itemType] || DragTarget.GENERIC, + }, + drop: { + canDrop: () => { + if (props.itemType === LibraryItem.QUEUE_SONG) { + return true; + } + + return false; + }, + getData: () => { + return { + id: [(item as unknown as { id: string }).id], + item: [item as unknown as unknown[]], + itemType: props.itemType, + type: DragTargetMap[props.itemType] || DragTarget.GENERIC, + }; + }, + onDrag: () => { + return; + }, + onDragLeave: () => { + return; + }, + onDrop: (args) => { + if (args.self.type === DragTarget.QUEUE_SONG) { + const sourceServerId = ( + args.source.item?.[0] as unknown as { _serverId: string } + )._serverId; + + const sourceItemType = args.source.itemType as LibraryItem; + + const droppedOnUniqueId = ( + args.self.item?.[0] as unknown as { _uniqueId: string } + )._uniqueId; + + switch (args.source.type) { + case DragTarget.ALBUM: { + props.playerContext.addToQueueByFetch( + sourceServerId, + args.source.id, + sourceItemType, + { edge: args.edge, uniqueId: droppedOnUniqueId }, + ); + break; + } + case DragTarget.ALBUM_ARTIST: { + break; + } + case DragTarget.ARTIST: { + break; + } + case DragTarget.GENRE: { + break; + } + case DragTarget.PLAYLIST: { + break; + } + case DragTarget.QUEUE_SONG: { + break; + } + case DragTarget.SONG: { + break; + } + case DragTarget.TABLE_COLUMN: { + break; + } + default: { + break; + } + } + } + + return; + }, + }, + isEnabled: shouldEnableDrag, + }); + + const isDragging = + item && typeof item === 'object' && 'id' in item && props.internalState + ? props.internalState.isDragging((item as any).id) + : isDraggingLocal; const controls = props.controls; + const dragProps = { + dragRef: shouldEnableDrag ? dragRef : null, + isDraggedOver: isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null, + isDragging, + }; + if (isHeaderEnabled && props.rowIndex === 0) { return ; } @@ -59,22 +203,22 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { switch (type) { case TableColumn.ACTIONS: case TableColumn.SKIP: - return ; + return ; case TableColumn.ALBUM_ARTIST: - return ; + return ; case TableColumn.ALBUM_COUNT: case TableColumn.PLAY_COUNT: case TableColumn.SONG_COUNT: - return ; + return ; case TableColumn.ARTIST: - return ; + return ; case TableColumn.BIOGRAPHY: case TableColumn.COMMENT: - return ; + return ; case TableColumn.BIT_RATE: case TableColumn.BPM: @@ -82,50 +226,52 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { case TableColumn.DISC_NUMBER: case TableColumn.TRACK_NUMBER: case TableColumn.YEAR: - return ; + return ; case TableColumn.DATE_ADDED: case TableColumn.RELEASE_DATE: - return ; + return ; case TableColumn.DURATION: - return ; + return ; case TableColumn.GENRE: - return ; + return ; case TableColumn.GENRE_BADGE: - return ; + return ; case TableColumn.IMAGE: - return ; + return ; case TableColumn.LAST_PLAYED: - return ; + return ; case TableColumn.PATH: - return ; + return ; case TableColumn.ROW_INDEX: - return ; + return ; case TableColumn.SIZE: - return ; + return ; case TableColumn.TITLE: - return ; + return ; case TableColumn.TITLE_COMBINED: - return ; + return ( + + ); case TableColumn.USER_FAVORITE: - return ; + return ; case TableColumn.USER_RATING: - return ; + return ; default: - return ; + return ; } }; @@ -137,6 +283,9 @@ export const TableColumnTextContainer = ( className?: string; containerClassName?: string; controls: ItemControls; + dragRef?: null | React.Ref; + isDraggedOver?: 'bottom' | 'top' | null; + isDragging?: boolean; type: TableColumn; }, ) => { @@ -149,57 +298,8 @@ export const TableColumnTextContainer = ( ? props.internalState.isSelected((item as any).id) : false; - const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; - - const { isDragging: isDraggingLocal, ref: dragRef } = useDragDrop({ - drag: { - getId: () => { - if (!item || !isDataRow) { - return []; - } - - const draggedItems = getDraggedItems( - item as any, - props.itemType, - props.internalState, - ); - return draggedItems.map((draggedItem) => draggedItem.id); - }, - getItem: () => { - if (!item || !isDataRow) { - return []; - } - - return [item]; - }, - onDragStart: () => { - if (!item || !isDataRow || !props.internalState) { - return; - } - - const draggedItems = getDraggedItems( - item as any, - props.itemType, - props.internalState, - ); - props.internalState.setDragging(draggedItems); - }, - onDrop: () => { - if (props.internalState) { - props.internalState.setDragging([]); - } - }, - target: DragTargetMap[props.itemType] || DragTarget.GENERIC, - }, - isEnabled: shouldEnableDrag, - }); - - const isDragging = - item && typeof item === 'object' && 'id' in item && props.internalState - ? props.internalState.isDragging((item as any).id) - : isDraggingLocal; - - const mergedRef = useMergedRef(containerRef, shouldEnableDrag ? dragRef : null); + const isDragging = props.isDragging ?? false; + const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); useEffect(() => { if (!isDataRow || !containerRef.current) return; @@ -209,13 +309,17 @@ export const TableColumnTextContainer = ( const handleMouseEnter = () => { // Find all cells in the same row and add hover class - const allCells = document.querySelectorAll(`[data-row-index="${rowIndex}"]`); + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); }; const handleMouseLeave = () => { // Remove hover class from all cells in the same row - const allCells = document.querySelectorAll(`[data-row-index="${rowIndex}"]`); + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); }; @@ -226,7 +330,53 @@ export const TableColumnTextContainer = ( container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); }; - }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]); + }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); + + // Apply dragged over state to all cells in the row so border can span entire row + useEffect(() => { + if (!isDataRow || !containerRef.current) return; + + const rowIndex = props.rowIndex; + const draggedOverState = props.isDraggedOver; + + if (draggedOverState) { + // Find all cells in the same row and add dragged over class + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); + allCells.forEach((cell, index) => { + if (draggedOverState === 'top') { + cell.classList.add(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } else if (draggedOverState === 'bottom') { + cell.classList.add(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverTop); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } + }); + } else { + // Remove dragged over classes from all cells in the same row + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); + allCells.forEach((cell) => { + cell.classList.remove(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverFirstCell); + }); + } + }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleCellClick = (event: React.MouseEvent) => { // Don't trigger row selection if clicking on interactive elements @@ -259,6 +409,8 @@ export const TableColumnTextContainer = ( [styles.center]: props.columns[props.columnIndex].align === 'center', [styles.compact]: props.size === 'compact', [styles.dataRow]: isDataRow, + [styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom', + [styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top', [styles.dragging]: isDataRow && isDragging, [styles.large]: props.size === 'large', [styles.left]: props.columns[props.columnIndex].align === 'start', @@ -274,7 +426,7 @@ export const TableColumnTextContainer = ( props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} - data-row-index={isDataRow ? props.rowIndex : undefined} + data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleCellClick} ref={mergedRef} style={props.style} @@ -299,6 +451,9 @@ export const TableColumnContainer = ( className?: string; containerStyle?: CSSProperties; controls: ItemControls; + dragRef?: null | React.Ref; + isDraggedOver?: 'bottom' | 'top' | null; + isDragging?: boolean; type: TableColumn; }, ) => { @@ -311,57 +466,8 @@ export const TableColumnContainer = ( ? props.internalState.isSelected((item as any).id) : false; - const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; - - const { isDragging: isDraggingLocal, ref: dragRef } = useDragDrop({ - drag: { - getId: () => { - if (!item || !isDataRow) { - return []; - } - - const draggedItems = getDraggedItems( - item as any, - props.itemType, - props.internalState, - ); - return draggedItems.map((draggedItem) => draggedItem.id); - }, - getItem: () => { - if (!item || !isDataRow) { - return []; - } - - return [item]; - }, - onDragStart: () => { - if (!item || !isDataRow || !props.internalState) { - return; - } - - const draggedItems = getDraggedItems( - item as any, - props.itemType, - props.internalState, - ); - props.internalState.setDragging(draggedItems); - }, - onDrop: () => { - if (props.internalState) { - props.internalState.setDragging([]); - } - }, - target: DragTargetMap[props.itemType] || DragTarget.GENERIC, - }, - isEnabled: shouldEnableDrag, - }); - - const isDragging = - item && typeof item === 'object' && 'id' in item && props.internalState - ? props.internalState.isDragging((item as any).id) - : isDraggingLocal; - - const mergedRef = useMergedRef(containerRef, shouldEnableDrag ? dragRef : null); + const isDragging = props.isDragging ?? false; + const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); useEffect(() => { if (!isDataRow || !containerRef.current) return; @@ -371,13 +477,17 @@ export const TableColumnContainer = ( const handleMouseEnter = () => { // Find all cells in the same row and add hover class - const allCells = document.querySelectorAll(`[data-row-index="${rowIndex}"]`); + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); }; const handleMouseLeave = () => { // Remove hover class from all cells in the same row - const allCells = document.querySelectorAll(`[data-row-index="${rowIndex}"]`); + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); }; @@ -388,7 +498,53 @@ export const TableColumnContainer = ( container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); }; - }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]); + }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); + + // Apply dragged over state to all cells in the row so border can span entire row + useEffect(() => { + if (!isDataRow || !containerRef.current) return; + + const rowIndex = props.rowIndex; + const draggedOverState = props.isDraggedOver; + + if (draggedOverState) { + // Find all cells in the same row and add dragged over class + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); + allCells.forEach((cell, index) => { + if (draggedOverState === 'top') { + cell.classList.add(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } else if (draggedOverState === 'bottom') { + cell.classList.add(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverTop); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } + }); + } else { + // Remove dragged over classes from all cells in the same row + const allCells = document.querySelectorAll( + `[data-row-index="${props.tableId}-${rowIndex}"]`, + ); + allCells.forEach((cell) => { + cell.classList.remove(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverFirstCell); + }); + } + }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleCellClick = (event: React.MouseEvent) => { // Don't trigger row selection if clicking on interactive elements @@ -421,6 +577,8 @@ export const TableColumnContainer = ( [styles.center]: props.columns[props.columnIndex].align === 'center', [styles.compact]: props.size === 'compact', [styles.dataRow]: isDataRow, + [styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom', + [styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top', [styles.dragging]: isDataRow && isDragging, [styles.large]: props.size === 'large', [styles.left]: props.columns[props.columnIndex].align === 'start', @@ -436,7 +594,7 @@ export const TableColumnContainer = ( props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} - data-row-index={isDataRow ? props.rowIndex : undefined} + data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleCellClick} ref={mergedRef} style={{ ...props.containerStyle, ...props.style }} diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.module.css b/src/renderer/components/item-list/item-table-list/item-table-list.module.css index 6fe28ac1c..2fdd4a535 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list.module.css @@ -13,14 +13,17 @@ min-width: 0; height: 100%; min-height: 0; + overflow: hidden; } .item-table-grid-container { position: relative; flex: 1 1 auto; width: 100%; + min-width: 0; height: 100%; min-height: 0; + overflow: hidden; } .item-table-pinned-rows-container { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 3994abd0d..32365054c 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -11,6 +11,7 @@ import React, { Ref, useCallback, useEffect, + useId, useImperativeHandle, useMemo, useRef, @@ -26,6 +27,7 @@ import { useDefaultItemListControls } from '/@/renderer/components/item-list/hel import { ItemListStateActions, ItemListStateItem, + ItemListStateItemWithRequiredProperties, useItemListState, } from '/@/renderer/components/item-list/helpers/item-list-state'; import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; @@ -34,8 +36,45 @@ import { ItemListHandle, ItemTableListColumnConfig, } from '/@/renderer/components/item-list/types'; +import { + PlayerContext, + usePlayerContext, +} from '/@/renderer/features/player/context/player-context'; import { LibraryItem } from '/@/shared/types/domain-types'; +/** + * Type guard to check if an item has the required properties (id and serverId) + * Similar to the type guard used in ItemCard + */ +const hasRequiredItemProperties = (item: unknown): item is { id: string; serverId: string } => { + return ( + typeof item === 'object' && + item !== null && + 'id' in item && + typeof (item as any).id === 'string' && + 'serverId' in item && + typeof (item as any).serverId === 'string' + ); +}; + +/** + * Type guard to check if an item has the required properties for ItemListStateItemWithRequiredProperties + */ +const hasRequiredStateItemProperties = ( + item: unknown, +): item is ItemListStateItemWithRequiredProperties => { + return ( + typeof item === 'object' && + item !== null && + 'id' in item && + typeof (item as any).id === 'string' && + '_serverId' in item && + typeof (item as any)._serverId === 'string' && + 'itemType' in item && + typeof (item as any).itemType === 'string' + ); +}; + interface VirtualizedTableGridProps { calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; @@ -64,9 +103,11 @@ interface VirtualizedTableGridProps { pinnedRightColumnRef: React.RefObject; pinnedRowCount: number; pinnedRowRef: React.RefObject; + playerContext: PlayerContext; showLeftShadow: boolean; showRightShadow: boolean; size: 'compact' | 'default' | 'large'; + tableId: string; totalColumnCount: number; totalRowCount: number; } @@ -100,9 +141,11 @@ const VirtualizedTableGrid = React.memo( pinnedRightColumnRef, pinnedRowCount, pinnedRowRef, + playerContext, showLeftShadow, showRightShadow, size, + tableId, totalColumnCount, totalRowCount, }: VirtualizedTableGridProps) => { @@ -129,7 +172,9 @@ const VirtualizedTableGrid = React.memo( internalState, itemType, onRowClick, + playerContext, size, + tableId, }), [ cellPadding, @@ -146,9 +191,11 @@ const VirtualizedTableGrid = React.memo( enableVerticalBorders, getRowHeight, internalState, + playerContext, itemType, onRowClick, size, + tableId, ], ); @@ -430,7 +477,9 @@ export interface TableItemProps { internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; onRowClick?: (item: any, event: React.MouseEvent) => void; + playerContext: PlayerContext; size?: ItemTableListProps['size']; + tableId: string; } interface ItemTableListProps { @@ -484,10 +533,11 @@ export const ItemTableList = ({ rowHeight, size = 'default', }: ItemTableListProps) => { + const tableId = useId(); const totalItemCount = enableHeader ? data.length + 1 : data.length; const parsedColumns = useMemo(() => parseTableColumns(columns), [columns]); const columnCount = parsedColumns.length; - + const playerContext = usePlayerContext(); const [centerContainerWidth, setCenterContainerWidth] = useState(0); useEffect(() => { @@ -614,7 +664,9 @@ export const ItemTableList = ({ getRowHeight: () => DEFAULT_ROW_HEIGHT, internalState: {} as ItemListStateActions, itemType, + playerContext, size, + tableId, }; for (let i = 0; i < adjustedIndex; i++) { @@ -632,11 +684,9 @@ export const ItemTableList = ({ }, [ enableHeader, - rowHeight, - size, - DEFAULT_ROW_HEIGHT, cellPadding, parsedColumns, + data, enableAlternateRowColors, enableExpansion, enableHorizontalBorders, @@ -644,7 +694,11 @@ export const ItemTableList = ({ enableSelection, enableVerticalBorders, itemType, - data, + playerContext, + size, + tableId, + DEFAULT_ROW_HEIGHT, + rowHeight, ], ); @@ -989,7 +1043,7 @@ export const ItemTableList = ({ const handleRowClick = useCallback( (item: any, event: React.MouseEvent) => { - if (!enableSelection || !item) { + if (!enableSelection || !item || !hasRequiredItemProperties(item)) { return; } @@ -1007,29 +1061,35 @@ export const ItemTableList = ({ // Remove this item from selection const currentSelected = internalState.getSelected(); const filteredSelected = currentSelected.filter( - (selectedItem) => selectedItem.id !== item.id, + (selectedItem): selectedItem is ItemListStateItemWithRequiredProperties => + hasRequiredStateItemProperties(selectedItem) && + selectedItem.id !== item.id, ); internalState.setSelected(filteredSelected); } else { // Add this item to selection const currentSelected = internalState.getSelected(); - const newSelected = [...currentSelected, itemListItem]; + const validSelected = currentSelected.filter(hasRequiredStateItemProperties); + const newSelected: ItemListStateItemWithRequiredProperties[] = [ + ...validSelected, + itemListItem as ItemListStateItemWithRequiredProperties, + ]; internalState.setSelected(newSelected); } } // Check if shift key is held for range selection else if (event.shiftKey) { const selectedItems = internalState.getSelected(); - const lastSelectedItem = selectedItems[selectedItems.length - 1]; + const validSelectedItems = selectedItems.filter(hasRequiredStateItemProperties); + const lastSelectedItem = validSelectedItems[validSelectedItems.length - 1]; if (lastSelectedItem) { // Find the indices of the last selected item and current item const lastIndex = data.findIndex( - (d) => - d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id, + (d) => hasRequiredItemProperties(d) && d.id === lastSelectedItem.id, ); const currentIndex = data.findIndex( - (d) => d && typeof d === 'object' && 'id' in d && d.id === item.id, + (d) => hasRequiredItemProperties(d) && d.id === item.id, ); if (lastIndex !== -1 && currentIndex !== -1) { @@ -1037,20 +1097,15 @@ export const ItemTableList = ({ 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 = data[i]; - if ( - rangeItem && - typeof rangeItem === 'object' && - 'id' in rangeItem && - 'serverId' in rangeItem - ) { + if (hasRequiredItemProperties(rangeItem)) { rangeItems.push({ - _serverId: (rangeItem as any).serverId, - id: (rangeItem as any).id, + _serverId: rangeItem.serverId, + id: rangeItem.id, itemType, - }); + } as ItemListStateItemWithRequiredProperties); } } @@ -1061,7 +1116,10 @@ export const ItemTableList = ({ // Deselect the range const currentSelected = internalState.getSelected(); const filteredSelected = currentSelected.filter( - (selectedItem) => + ( + selectedItem, + ): selectedItem is ItemListStateItemWithRequiredProperties => + hasRequiredStateItemProperties(selectedItem) && !rangeItems.some( (rangeItem) => rangeItem.id === selectedItem.id, ), @@ -1070,7 +1128,12 @@ export const ItemTableList = ({ } else { // Select the range const currentSelected = internalState.getSelected(); - const newSelected = [...currentSelected]; + const validSelected = currentSelected.filter( + hasRequiredStateItemProperties, + ); + const newSelected: ItemListStateItemWithRequiredProperties[] = [ + ...validSelected, + ]; rangeItems.forEach((rangeItem) => { if (!newSelected.some((selected) => selected.id === rangeItem.id)) { newSelected.push(rangeItem); @@ -1081,19 +1144,24 @@ export const ItemTableList = ({ } } else { // No previous selection, just toggle this item - internalState.toggleSelected(itemListItem); + internalState.toggleSelected( + itemListItem as ItemListStateItemWithRequiredProperties, + ); } } else { // Regular click - deselect all others and select only this item // If this item is already the only selected item, deselect it const selectedItems = internalState.getSelected(); + const validSelectedItems = selectedItems.filter(hasRequiredStateItemProperties); const isOnlySelected = - selectedItems.length === 1 && selectedItems[0].id === item.id; + validSelectedItems.length === 1 && validSelectedItems[0].id === item.id; if (isOnlySelected) { internalState.clearSelected(); } else { - internalState.setSelected([itemListItem]); + internalState.setSelected([ + itemListItem as ItemListStateItemWithRequiredProperties, + ]); } } }, @@ -1108,12 +1176,13 @@ export const ItemTableList = ({ e.stopPropagation(); const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); let currentIndex = -1; - if (selected.length > 0) { - const lastSelected = selected[selected.length - 1]; + if (validSelected.length > 0) { + const lastSelected = validSelected[validSelected.length - 1]; currentIndex = data.findIndex( - (d: any) => d && typeof d === 'object' && 'id' in d && d.id === lastSelected.id, + (d) => hasRequiredItemProperties(d) && d.id === lastSelected.id, ); } @@ -1131,13 +1200,13 @@ export const ItemTableList = ({ // Handle Shift + Arrow for incremental range selection (matches shift+click behavior) if (e.shiftKey) { const selectedItems = internalState.getSelected(); - const lastSelectedItem = selectedItems[selectedItems.length - 1]; + const validSelectedItems = selectedItems.filter(hasRequiredStateItemProperties); + const lastSelectedItem = validSelectedItems[validSelectedItems.length - 1]; if (lastSelectedItem) { // Find the indices of the last selected item and new item const lastIndex = data.findIndex( - (d: any) => - d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id, + (d) => hasRequiredItemProperties(d) && d.id === lastSelectedItem.id, ); if (lastIndex !== -1 && newIndex !== -1) { @@ -1145,26 +1214,26 @@ export const ItemTableList = ({ const startIndex = Math.min(lastIndex, newIndex); const stopIndex = Math.max(lastIndex, newIndex); - const rangeItems: ItemListStateItem[] = []; + const rangeItems: ItemListStateItemWithRequiredProperties[] = []; for (let i = startIndex; i <= stopIndex; i++) { const rangeItem = data[i]; - if ( - rangeItem && - typeof rangeItem === 'object' && - 'id' in rangeItem && - 'serverId' in rangeItem - ) { + if (hasRequiredItemProperties(rangeItem)) { rangeItems.push({ - _serverId: (rangeItem as any).serverId, - id: (rangeItem as any).id, + _serverId: rangeItem.serverId, + id: rangeItem.id, itemType, - }); + } as ItemListStateItemWithRequiredProperties); } } // Add range items to selection (matching shift+click behavior) const currentSelected = internalState.getSelected(); - const newSelected = [...currentSelected]; + const validSelected = currentSelected.filter( + hasRequiredStateItemProperties, + ); + const newSelected: ItemListStateItemWithRequiredProperties[] = [ + ...validSelected, + ]; rangeItems.forEach((rangeItem) => { if (!newSelected.some((selected) => selected.id === rangeItem.id)) { newSelected.push(rangeItem); @@ -1172,38 +1241,44 @@ export const ItemTableList = ({ }); // Ensure the last item in selection is the item at newIndex for incremental extension - const newItemListItem: ItemListStateItem = { - _serverId: newItem.serverId, - id: newItem.id, - itemType, - }; - // Remove the new item from its current position if it exists - const filteredSelected = newSelected.filter( - (item) => item.id !== newItemListItem.id, - ); - // Add it at the end so it becomes the last selected item - filteredSelected.push(newItemListItem); - internalState.setSelected(filteredSelected); + if (hasRequiredItemProperties(newItem)) { + const newItemListItem: ItemListStateItemWithRequiredProperties = { + _serverId: newItem.serverId, + id: newItem.id, + itemType, + } as ItemListStateItemWithRequiredProperties; + // Remove the new item from its current position if it exists + const filteredSelected = newSelected.filter( + (item) => item.id !== newItemListItem.id, + ); + // Add it at the end so it becomes the last selected item + filteredSelected.push(newItemListItem); + internalState.setSelected(filteredSelected); + } } } else { // No previous selection, just select the new item + if (hasRequiredItemProperties(newItem)) { + internalState.setSelected([ + { + _serverId: newItem.serverId, + id: newItem.id, + itemType, + } as ItemListStateItemWithRequiredProperties, + ]); + } + } + } else { + // Without Shift: select only the new item + if (hasRequiredItemProperties(newItem)) { internalState.setSelected([ { _serverId: newItem.serverId, id: newItem.id, itemType, - }, + } as ItemListStateItemWithRequiredProperties, ]); } - } else { - // Without Shift: select only the new item - internalState.setSelected([ - { - _serverId: newItem.serverId, - id: newItem.id, - itemType, - }, - ]); } const offset = calculateScrollTopForIndex(newIndex); @@ -1304,9 +1379,11 @@ export const ItemTableList = ({ pinnedRightColumnRef={pinnedRightColumnRef} pinnedRowCount={pinnedRowCount} pinnedRowRef={pinnedRowRef} + playerContext={playerContext} showLeftShadow={showLeftShadow} showRightShadow={showRightShadow} size={size} + tableId={tableId} totalColumnCount={totalColumnCount} totalRowCount={totalRowCount} /> diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index d39a1d27c..ac8981740 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -101,7 +101,7 @@ export const PlayQueueListControls = ({ // mpvPlayer!.pause(); // } - updateSong(undefined); + player.clearQueue(); // setCurrentTime(0); // pause(); diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index b8bcf9ca6..e2ef1fb0b 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -1,6 +1,7 @@ import type { Ref } from 'react'; -import { forwardRef, useMemo } from 'react'; +import { nanoid } from 'nanoid/non-secure'; +import { forwardRef, useEffect, useMemo, useRef } from 'react'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; @@ -27,19 +28,54 @@ export const PlayQueue = forwardRef(({ listKey, searchTerm }: QueueProps, ref: R return queue; }, [queue, searchTerm]); + const playQueueKeyRef = useRef({ + alreadyRendered: false, + key: nanoid(), + prevLength: 0, + }); + + useEffect(() => { + if (playQueueKeyRef.current.alreadyRendered && playQueueKeyRef.current.prevLength === 0) { + return; + } + + if (data.length === 0) { + playQueueKeyRef.current = { + alreadyRendered: false, + key: nanoid(), + prevLength: data.length, + }; + return; + } + + if (data.length > 0 && !playQueueKeyRef.current.alreadyRendered) { + playQueueKeyRef.current = { + alreadyRendered: true, + key: nanoid(), + prevLength: data.length, + }; + } + }, [data.length, playQueueKeyRef]); + return ( diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index c112bbd3f..caec59485 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -23,7 +23,7 @@ import { } from '/@/shared/types/domain-types'; import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types'; -interface PlayerContext { +export interface PlayerContext { addToQueueByData: (data: Song[], type: AddToQueueType) => void; addToQueueByFetch: ( serverId: string, diff --git a/src/renderer/hooks/use-drag-drop.tsx b/src/renderer/hooks/use-drag-drop.tsx index e65f8f662..76aa3ea89 100644 --- a/src/renderer/hooks/use-drag-drop.tsx +++ b/src/renderer/hooks/use-drag-drop.tsx @@ -8,35 +8,34 @@ import { BaseEventPayload, CleanupFn, ElementDragType, - Input, } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types'; import { draggable, dropTargetForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; -import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; -import { values } from 'lodash'; import { useEffect, useRef, useState } from 'react'; -import { createRoot } from 'react-dom/client'; +import { LibraryItem } from '/@/shared/types/domain-types'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; interface UseDraggableProps { drag?: { getId: () => string[]; getItem: () => unknown[]; + itemType?: LibraryItem; onDragStart?: () => void; onDrop?: () => void; onGenerateDragPreview?: (data: BaseEventPayload) => void; + operation: DragOperation[]; target: DragTarget | string; }; drop?: { canDrop: (args: { source: DragData }) => boolean; - getData: (args: { element: HTMLElement; input: Input }) => DragData; - onDrag: (args: { self: DragData }) => void; + getData: () => DragData; + onDrag: (args: { edge: Edge | null }) => void; onDragLeave: () => void; - onDrop: (args: { self: DragData }) => void; + onDrop: (args: { edge: Edge | null; self: DragData; source: DragData }) => void; }; isEnabled: boolean; } @@ -49,6 +48,7 @@ export const useDragDrop = ({ const ref = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(null); useEffect(() => { if (!ref.current || !isEnabled) return; @@ -66,6 +66,8 @@ export const useDragDrop = ({ const data = dndUtils.generateDragData({ id, item, + itemType: drag.itemType, + operation: drag.operation, type: drag.target, }); return data; @@ -96,59 +98,53 @@ export const useDragDrop = ({ ); } - // if (drop) { - // functions.push( - // dropTargetForElements({ - // canDrop: (args) => { - // const data = args.source.data as unknown as DragData; - // const isSelf = (args.source.data.id as string[])[0] === option.value; - // return dndUtils.isDropTarget(data.type, [DragTarget.GENERIC]) && !isSelf; - // }, - // element: ref.current, - // getData: ({ element, input }) => { - // const data = dndUtils.generateDragData({ - // id: [option.value], - // operation: [DragOperation.REORDER], - // type: DragTarget.GENERIC, - // }); + if (drop) { + functions.push( + dropTargetForElements({ + canDrop: (args) => { + return ( + drop.canDrop?.({ source: args.source.data as unknown as DragData }) || + false + ); + }, + element: ref.current, + getData: (args) => { + const dropData = drop.getData(); - // return attachClosestEdge(data, { - // allowedEdges: ['top', 'bottom'], - // element, - // input, - // }); - // }, - // onDrag: (args) => { - // const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); - // setIsDraggedOver(closestEdgeOfTarget); - // }, - // onDragLeave: () => { - // setIsDraggedOver(null); - // }, - // onDrop: (args) => { - // const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + const data = dndUtils.generateDragData(dropData); - // const from = args.source.data.id as string[]; - // const to = args.self.data.id as string[]; - - // const newOrder = dndUtils.reorderById({ - // edge: closestEdgeOfTarget, - // idFrom: from[0], - // idTo: to[0], - // list: values, - // }); - - // onChange(newOrder); - // setIsDraggedOver(null); - // }, - // }), - // ); - // } + return attachClosestEdge(data, { + allowedEdges: ['top', 'bottom'], + element: args.element, + input: args.input, + }); + }, + onDrag: (args) => { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + drop.onDrag?.({ edge: closestEdgeOfTarget }); + setIsDraggedOver(closestEdgeOfTarget); + }, + onDragLeave: () => { + setIsDraggedOver(null); + }, + onDrop: (args) => { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + drop.onDrop?.({ + edge: closestEdgeOfTarget, + self: args.self.data as unknown as DragData, + source: args.source.data as unknown as DragData, + }); + setIsDraggedOver(null); + }, + }), + ); + } return combine(...functions); - }, [drag, drop, isDragging]); + }, [drag, drop, isDragging, isDraggedOver, isEnabled]); return { + isDraggedOver, isDragging, ref, }; diff --git a/src/renderer/layouts/default-layout/right-sidebar.module.css b/src/renderer/layouts/default-layout/right-sidebar.module.css index 9258643ef..83f588411 100644 --- a/src/renderer/layouts/default-layout/right-sidebar.module.css +++ b/src/renderer/layouts/default-layout/right-sidebar.module.css @@ -1,6 +1,9 @@ .right-sidebar-container { position: relative; + display: flex; + flex-direction: column; grid-area: right-sidebar; + height: 100%; min-height: 0; overflow: hidden; border-left: 1px solid alpha(var(--theme-colors-border), 0.3); diff --git a/src/shared/styles/global.css b/src/shared/styles/global.css index c9de9195b..00e468969 100644 --- a/src/shared/styles/global.css +++ b/src/shared/styles/global.css @@ -2,6 +2,7 @@ * { box-sizing: border-box; + outline: none; } *, diff --git a/src/shared/types/drag-and-drop.ts b/src/shared/types/drag-and-drop.ts index f1c84fd18..d2e71b548 100644 --- a/src/shared/types/drag-and-drop.ts +++ b/src/shared/types/drag-and-drop.ts @@ -9,8 +9,9 @@ export enum DragTarget { GENERIC = 'generic', GENRE = LibraryItem.GENRE, PLAYLIST = LibraryItem.PLAYLIST, + QUEUE_SONG = LibraryItem.QUEUE_SONG, + SONG = LibraryItem.SONG, TABLE_COLUMN = 'tableColumn', - TRACK = LibraryItem.SONG, } export const DragTargetMap = { @@ -19,7 +20,8 @@ export const DragTargetMap = { [LibraryItem.ARTIST]: DragTarget.ARTIST, [LibraryItem.GENRE]: DragTarget.GENRE, [LibraryItem.PLAYLIST]: DragTarget.PLAYLIST, - [LibraryItem.SONG]: DragTarget.TRACK, + [LibraryItem.QUEUE_SONG]: DragTarget.QUEUE_SONG, + [LibraryItem.SONG]: DragTarget.SONG, }; export enum DragOperation { @@ -38,6 +40,7 @@ export interface DragData< > { id: string[]; item?: TDataType[]; + itemType?: LibraryItem; metadata?: T; operation?: DragOperation[]; type: DragTarget; @@ -52,6 +55,7 @@ export const dndUtils = { args: { id: string[]; item?: TDataType[]; + itemType?: LibraryItem; operation?: DragOperation[]; type: DragTarget | string; }, @@ -60,6 +64,7 @@ export const dndUtils = { return { id: args.id, item: args.item, + itemType: args.itemType, metadata, operation: args.operation, type: args.type,