Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added CTRL + click and SHIFT + click selection to draggable lists #16452

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 87 additions & 21 deletions src/controls/DraggableList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Promise from 'bluebird';
/* eslint-disable */
import * as React from 'react';
import { ListGroup } from 'react-bootstrap';
import {
Expand All @@ -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 };
Expand All @@ -42,6 +45,9 @@ class DraggableList extends ComponentEx<IProps, IDraggableListState> {

this.initState({
ordered: props.items.slice(0),
selectedItems: [],
lastSelectedIndex: null,
draggedItems: [],
});

this.mDraggableClass = makeDraggable(props.itemTypeId);
Expand All @@ -54,9 +60,9 @@ class DraggableList extends ComponentEx<IProps, IDraggableListState> {
}

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(
<div style={style} className={className}>
<ListGroup>
Expand All @@ -66,45 +72,101 @@ class DraggableList extends ComponentEx<IProps, IDraggableListState> {
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}
/>
))}
</ListGroup>
</div>);
</div>
);
}

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];
Expand All @@ -119,6 +181,11 @@ class DraggableList extends ComponentEx<IProps, IDraggableListState> {

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));
}
}

Expand All @@ -136,8 +203,7 @@ const containerTarget: DropTargetSpec<IProps> = {
},
};

function containerCollect(connect: DropTargetConnector,
monitor: DropTargetMonitor) {
function containerCollect(connect: DropTargetConnector, monitor: DropTargetMonitor) {
return {
connectDropTarget: connect.dropTarget(),
};
Expand All @@ -157,4 +223,4 @@ function DraggableListWrapper(props: IDraggableListProps) {
);
}

export default DraggableListWrapper;
export default DraggableListWrapper;
27 changes: 27 additions & 0 deletions src/controls/DraggableListDragPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<IDragPreviewProps> = ({ items, itemRenderer, className }) => {
const classes = ['draggable-list-drag-preview'];
if (!!className) {
classes.push(className);
}

return (
<div id='draggable-list-drag-preview' className={classes.join(' ')}>
{items.map((item, index) => (
<div key={index}>
{React.createElement(itemRenderer, { item, className: 'draggable-list-drag-preview-item' })}
</div>
))}
</div>
);
};

export default DraggableListDragPreview;
112 changes: 70 additions & 42 deletions src/controls/DraggableListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
/* 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;
item: any;
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;
}

Expand All @@ -34,21 +42,40 @@ type IProps = IDraggableListItemProps & IDragProps & IDropProps;

class DraggableItem extends React.Component<IProps, {}> {
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
? <DraggableListDragPreview items={draggedItems} itemRenderer={itemRenderer} />
: null;

const ItemRendererComponent = this.props.itemRenderer;
return (
<ItemRendererComponent
className={isDragging ? 'dragging' : undefined}
item={canReference ? item : refForwardedItem}
forwardedRef={canReference ? this.setRef : refForwardedItem.setRef}
/>
const renderItemComponent = (!isDragging)
? (
<ItemRendererComponent
className={`${classes.join(' ')}`}
item={refForwardedItem}
/>
) : null;

return connectDragPreview(
<div ref={this.setRef} onClick={this.props.onClick}>
{dragPreview}
{renderItemComponent}
</div>
);
}

Expand All @@ -60,28 +87,15 @@ class DraggableItem extends React.Component<IProps, {}> {
}
}

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<IProps, any> = {
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) {
Expand All @@ -94,7 +108,7 @@ const entrySource: DragSourceSpec<IProps, any> = {

const entryTarget: DropTargetSpec<IProps> = {
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) {
Expand All @@ -115,20 +129,34 @@ const entryTarget: DropTargetSpec<IProps> = {
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<IDraggableListItemProps> {
return DropTarget(itemTypeId, entryTarget, collectDrop)(
DragSource(itemTypeId, entrySource, collectDrag)(
DraggableItem));
}

export default makeDraggable;
export default makeDraggable;
Loading