diff --git a/src/controls/DraggableList.tsx b/src/controls/DraggableList.tsx index 4f9d1076b..fd99a3fe2 100644 --- a/src/controls/DraggableList.tsx +++ b/src/controls/DraggableList.tsx @@ -1,4 +1,4 @@ -import Promise from 'bluebird'; +/* eslint-disable */ import * as React from 'react'; import { ListGroup } from 'react-bootstrap'; import { @@ -22,6 +22,9 @@ export interface IDraggableListProps { interface IDraggableListState { ordered: any[]; + selectedItems: any[]; // Track selected items + lastSelectedIndex: number | null; // Track the last clicked index for shift selection + draggedItems: any[]; // Track dragged items } type IProps = IDraggableListProps & { connectDropTarget: ConnectDropTarget }; @@ -42,6 +45,9 @@ class DraggableList extends ComponentEx { this.initState({ ordered: props.items.slice(0), + selectedItems: [], + lastSelectedIndex: null, + draggedItems: [], }); this.mDraggableClass = makeDraggable(props.itemTypeId); @@ -54,9 +60,9 @@ class DraggableList extends ComponentEx { } public render(): JSX.Element { - const { connectDropTarget, id, isLocked, itemRenderer, style, className } = this.props; + const { connectDropTarget, id, itemRenderer, style, className } = this.props; + const { ordered, selectedItems, draggedItems } = this.state; - const { ordered } = this.state; return connectDropTarget(
@@ -66,45 +72,101 @@ class DraggableList extends ComponentEx { key={this.itemId(item)} item={item} index={idx} - isLocked={isLocked?.(item) ?? false} + findItemIndex={this.findItemIndex} + isLocked={this.itemLocked(item)} itemRenderer={itemRenderer} take={this.take} onChangeIndex={this.changeIndex} apply={this.apply} + onClick={this.handleItemClick(idx)} + selectedItems={selectedItems} + isSelected={selectedItems.includes(item)} + draggedItems={draggedItems} + onDragStart={this.handleDragStart} /> ))} -
); + + ); } - public changeIndex = (oldIndex: number, newIndex: number, changeContainer: boolean, - take: (list: any[]) => any) => { - if (oldIndex === undefined) { - return; + private handleItemClick = (index: number) => (event: React.MouseEvent) => { + const { ordered, selectedItems, lastSelectedIndex } = this.state; + const item = ordered[index]; + let newSelectedItems = [...selectedItems]; + + if (event.ctrlKey) { + // Handle Ctrl for multi-selection + if (selectedItems.includes(item)) { + newSelectedItems = selectedItems.filter(i => i !== item); // Deselect + } else { + newSelectedItems.push(item); // Select + } + } else if (event.shiftKey && lastSelectedIndex !== null) { + // Handle Shift for range selection + const range = [lastSelectedIndex, index].sort((a, b) => a - b); + const rangeItems = ordered.slice(range[0], range[1] + 1); + newSelectedItems = [...new Set([...selectedItems, ...rangeItems])]; + } else { + // Regular click selects single item and deselects others + newSelectedItems = [item]; } - const copy = this.state.ordered.slice(); - const item = take(changeContainer ? undefined : copy); - copy.splice(newIndex, 0, item); + this.nextState.selectedItems = newSelectedItems; + this.nextState.lastSelectedIndex = index; // Update last selected index for shift selection + }; + + public changeIndex = (oldIndex: number, newIndex: number, changeContainer: boolean, take: (list: any[]) => any) => { + const { selectedItems, ordered } = this.state; + const copy = ordered.slice(); + + // If multiple items are selected, handle reordering for all of them + let itemsToMove = selectedItems.includes(copy[oldIndex]) + ? selectedItems + : [take(changeContainer ? undefined : copy)]; // Fall back to single item + + // Remove selected items from their old position + itemsToMove.forEach(item => { + const index = copy.indexOf(item); + if (index !== -1) { + copy.splice(index, 1); + } + }); + + // Insert items in new position + itemsToMove.forEach(itm => { + const item = Array.isArray(itm) ? itm[0] : itm; + copy.splice(newIndex, 0, item); + newIndex++; + }); this.nextState.ordered = copy; } + private itemLocked(item: any) { + const itm = Array.isArray(item) ? item[0] : item; + return this.props.isLocked?.(itm) ?? false; + } + private itemId(item: any) { + const itm = Array.isArray(item) ? item[0] : item; if (this.props.idFunc !== undefined) { - return this.props.idFunc(item); - } else if (item.id !== undefined) { - return item.id; + return this.props.idFunc(itm); + } else if (itm.id !== undefined) { + return itm.id; } else { - return item; + return itm; } } + private findItemIndex = (item: any) => { + return this.nextState.ordered.findIndex(iter => this.itemId(iter) === this.itemId(item)); + } + private take = (item: any, list: any[]) => { const { ordered } = this.nextState; let res = item; - const itemId = this.itemId(item); - const index = ordered.findIndex(iter => this.itemId(iter) === itemId); + const index = this.findItemIndex(item); if (index !== -1) { if (list !== undefined) { res = list.splice(index, 1)[0]; @@ -119,6 +181,11 @@ class DraggableList extends ComponentEx { private apply = () => { this.props.apply(this.state.ordered); + this.nextState.draggedItems = []; + } + + private handleDragStart = (items: any[]) => { + this.nextState.draggedItems = items.sort((a, b) => this.findItemIndex(a) - this.findItemIndex(b)); } } @@ -136,8 +203,7 @@ const containerTarget: DropTargetSpec = { }, }; -function containerCollect(connect: DropTargetConnector, - monitor: DropTargetMonitor) { +function containerCollect(connect: DropTargetConnector, monitor: DropTargetMonitor) { return { connectDropTarget: connect.dropTarget(), }; @@ -157,4 +223,4 @@ function DraggableListWrapper(props: IDraggableListProps) { ); } -export default DraggableListWrapper; +export default DraggableListWrapper; \ No newline at end of file diff --git a/src/controls/DraggableListDragPreview.tsx b/src/controls/DraggableListDragPreview.tsx new file mode 100644 index 000000000..10ca1777a --- /dev/null +++ b/src/controls/DraggableListDragPreview.tsx @@ -0,0 +1,27 @@ +/* eslint-disable */ +import * as React from 'react'; + +interface IDragPreviewProps { + className?: string; + items: any[]; + itemRenderer: React.ComponentType<{ className?: string, item: any, forwardedRef?: any }>; +} + +const DraggableListDragPreview: React.FC = ({ items, itemRenderer, className }) => { + const classes = ['draggable-list-drag-preview']; + if (!!className) { + classes.push(className); + } + + return ( +
+ {items.map((item, index) => ( +
+ {React.createElement(itemRenderer, { item, className: 'draggable-list-drag-preview-item' })} +
+ ))} +
+ ); +}; + +export default DraggableListDragPreview; diff --git a/src/controls/DraggableListItem.tsx b/src/controls/DraggableListItem.tsx index 58ff1b100..545e6f565 100644 --- a/src/controls/DraggableListItem.tsx +++ b/src/controls/DraggableListItem.tsx @@ -1,10 +1,13 @@ +/* eslint-disable */ import * as React from 'react'; -import { ConnectDragPreview, ConnectDragSource, - ConnectDropTarget, DragSource, DragSourceConnector, - DragSourceMonitor, DragSourceSpec, DropTarget, - DropTargetConnector, DropTargetMonitor, DropTargetSpec, - } from 'react-dnd'; +import { + ConnectDragPreview, ConnectDragSource, + ConnectDropTarget, DragSource, DragSourceConnector, + DragSourceMonitor, DragSourceSpec, DropTarget, + DropTargetConnector, DropTargetMonitor, DropTargetSpec, +} from 'react-dnd'; import * as ReactDOM from 'react-dom'; +import DraggableListDragPreview from './DraggableListDragPreview'; // Import the updated preview component export interface IDraggableListItemProps { index: number; @@ -12,15 +15,20 @@ export interface IDraggableListItemProps { isLocked: boolean; itemRenderer: React.ComponentType<{ className?: string, item: any, forwardedRef?: any }>; containerId: string; - take: (item: any, list: any[]) => any; - onChangeIndex: (oldIndex: number, newIndex: number, - changeContainer: boolean, take: (list: any[]) => any) => void; + isSelected: boolean; + selectedItems: any[]; + draggedItems: any[]; apply: () => void; + findItemIndex: (item: any) => number; + take: (item: any, list: any[]) => any; + onChangeIndex: (oldIndex: number, newIndex: number, changeContainer: boolean, take: (list: any[]) => any) => void; + onClick: (event: React.MouseEvent) => void; + onDragStart: (items: any[]) => void; } interface IDragProps { connectDragSource: ConnectDragSource; - connectDragPreview: ConnectDragPreview; + connectDragPreview: ConnectDragPreview; // Connect drag preview isDragging: boolean; } @@ -34,21 +42,40 @@ type IProps = IDraggableListItemProps & IDragProps & IDropProps; class DraggableItem extends React.Component { public render(): JSX.Element { - const { isDragging, item } = this.props; - // Function components cannot be assigned a refrence - in cases like these - // we enhance the initial item to forward the setRef functor so that the - // item renderer itself can decide which DOM node to ref. - const canReference = (this.props.itemRenderer.prototype?.render !== undefined); + const { item, draggedItems, isDragging, isSelected, connectDragPreview, itemRenderer } = this.props; const refForwardedItem = (typeof item === 'object') ? { ...item, setRef: this.setRef } : { item, setRef: this.setRef }; + + // Not to be mistaken for the isDragging flag - that is only raised for the initial entry that + // is being dragged. isDraggedItem is used for visual configuration, while isDragging modifies + // functionality (The custom drag preview component only gets attached to the initial entry) + const isDraggedItem = draggedItems.includes(item); + + const classes = isSelected + ? isDraggedItem + ? ['dragging', 'selected'] + : ['selected'] + : isDraggedItem ? ['dragging'] : []; + + const dragPreview = isDragging + ? + : null; + const ItemRendererComponent = this.props.itemRenderer; - return ( - + const renderItemComponent = (!isDragging) + ? ( + + ) : null; + + return connectDragPreview( +
+ {dragPreview} + {renderItemComponent} +
); } @@ -60,28 +87,15 @@ class DraggableItem extends React.Component { } } -function collectDrag(connect: DragSourceConnector, - monitor: DragSourceMonitor) { - return { - connectDragSource: connect.dragSource(), - isDragging: monitor.isDragging(), - }; -} - -function collectDrop(connect: DropTargetConnector, - monitor: DropTargetMonitor) { - return { - connectDropTarget: connect.dropTarget(), - }; -} - const entrySource: DragSourceSpec = { - beginDrag(props: IProps) { + beginDrag(props: IProps, monitor: DragSourceMonitor) { + const draggedItems = props.isSelected ? props.selectedItems : [props.item]; + props.onDragStart(draggedItems); return { index: props.index, - item: props.item, + items: draggedItems, containerId: props.containerId, - take: (list: any[]) => props.take(props.item, list), + take: (list: any[]) => draggedItems.map(item => props.take(item, list)), }; }, endDrag(props, monitor: DragSourceMonitor) { @@ -94,7 +108,7 @@ const entrySource: DragSourceSpec = { const entryTarget: DropTargetSpec = { hover(props: IProps, monitor: DropTargetMonitor, component) { - const { containerId, index, item, take, isLocked } = (monitor.getItem() as any); + const { containerId, index, items, isLocked } = (monitor.getItem() as any); const hoverIndex = props.index; if ((index === hoverIndex) || !!isLocked || !!props.isLocked) { @@ -115,20 +129,34 @@ const entryTarget: DropTargetSpec = { return; } - props.onChangeIndex(index, hoverIndex, containerId !== props.containerId, take); + props.onChangeIndex(index, hoverIndex, containerId !== props.containerId, (list) => items.map(item => props.take(item, list))); (monitor.getItem() as any).index = hoverIndex; if (containerId !== props.containerId) { (monitor.getItem() as any).containerId = props.containerId; - (monitor.getItem() as any).take = (list: any[]) => props.take(item, list); + (monitor.getItem() as any).take = (list: any[]) => props.take(items, list); } }, }; +function collectDrag(connect: DragSourceConnector, monitor: DragSourceMonitor) { + return { + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging(), + }; +} + +function collectDrop(connect: DropTargetConnector, monitor: DropTargetMonitor) { + return { + connectDropTarget: connect.dropTarget(), + }; +} + function makeDraggable(itemTypeId: string): React.ComponentClass { return DropTarget(itemTypeId, entryTarget, collectDrop)( DragSource(itemTypeId, entrySource, collectDrag)( DraggableItem)); } -export default makeDraggable; +export default makeDraggable; \ No newline at end of file diff --git a/src/extensions/file_based_loadorder/views/ItemRenderer.tsx b/src/extensions/file_based_loadorder/views/ItemRenderer.tsx index 509806b48..657560de2 100644 --- a/src/extensions/file_based_loadorder/views/ItemRenderer.tsx +++ b/src/extensions/file_based_loadorder/views/ItemRenderer.tsx @@ -33,8 +33,10 @@ type IProps = IBaseProps & IConnectedProps & IActionProps; class ItemRenderer extends ComponentEx { public render() { - const item = this.props.item.loEntry; - const displayCheckboxes = this.props.item.displayCheckboxes; + const item = Array.isArray(this.props.item) + ? this.props.item[0].loEntry + : this.props.item.loEntry; + const displayCheckboxes = item.displayCheckboxes; return this.renderDraggable(item, displayCheckboxes); } diff --git a/src/stylesheets/vortex/page-mod-load-order.scss b/src/stylesheets/vortex/page-mod-load-order.scss index 7511d50f0..3c7a32f9e 100644 --- a/src/stylesheets/vortex/page-mod-load-order.scss +++ b/src/stylesheets/vortex/page-mod-load-order.scss @@ -18,7 +18,7 @@ } .file-based-load-order-list { - + .list-group { display: flex; align-items: stretch; @@ -26,6 +26,11 @@ gap: 4px; margin: 0; padding: 0 8px 0 0; // padding between scrollbar and list items + + #draggable-list-drag-preview { + opacity: 0.5; + border: 3px solid white; + } } .fblo-spinner-container { @@ -119,6 +124,10 @@ opacity: 0; } + &.selected { + border: 2px solid white; + } + &.locked { background-color: $brand-primary; }