首先,需要npm install @emotion/styled 和 react-beautiful-dnd 和 @emotion/react 这三个依赖
import React, { Component } from "react";
import styled from "@emotion/styled";
import { DragDropContext } from "react-beautiful-dnd";
import initial from "./data";
import Column from "./column";
import { mutliDragAwareReorder, multiSelectTo } from "./utils";
const Container = styled.div`display: flex`,
getTasks = (entities, columnId) =>
entities.columns[columnId].taskIds.map((taskId) => entities.tasks[taskId]);
export default class TaskApp extends Component {
state = {
entities: initial,
selectedTaskIds: [],
draggingTaskId: null
};
componentDidMount () {
window.addEventListener("click", this.onWindowClick);
window.addEventListener("keydown", this.onWindowKeyDown);
window.addEventListener("touchend", this.onWindowTouchEnd);
}
componentWillUnmount () {
window.removeEventListener("click", this.onWindowClick);
window.removeEventListener("keydown", this.onWindowKeyDown);
window.removeEventListener("touchend", this.onWindowTouchEnd);
}
onDragStart = (start) => {
const id = start.draggableId,
selected = this.state.selectedTaskIds.find((taskId) => taskId === id);
// if dragging an item that is not selected - unselect all items
if (!selected) {
this.unselectAll();
}
this.setState({
draggingTaskId: start.draggableId
});
};
onDragEnd = (result) => {
const destination = result.destination,
source = result.source;
// nothing to do
if (!destination || result.reason === "CANCEL") {
this.setState({
draggingTaskId: null
});
return;
}
const processed = mutliDragAwareReorder({
entities: this.state.entities,
selectedTaskIds: this.state.selectedTaskIds,
source,
destination
});
// processed.selectedTaskIds = [];
this.setState({
...processed,
draggingTaskId: null
});
};
onWindowKeyDown = (event) => {
if (event.defaultPrevented) {
return;
}
if (event.key === "Escape") {
this.unselectAll();
}
};
onWindowClick = (event) => {
if (event.defaultPrevented) {
return;
}
this.unselectAll();
};
// touched
onWindowTouchEnd = (event) => {
if (event.defaultPrevented) {
return;
}
this.unselectAll();
};
// click
toggleSelection = (taskId) => {
const selectedTaskIds = this.state.selectedTaskIds,
wasSelected = selectedTaskIds.includes(taskId),
newTaskIds = (() => {
// Task was not previously selected
// now will be the only selected item
if (!wasSelected) {
return [taskId];
}
// Task was part of a selected group
// will now become the only selected item
if (selectedTaskIds.length > 1) {
return [taskId];
}
// task was previously selected but not in a group
// we will now clear the selection
return [];
})();
this.setState({
selectedTaskIds: newTaskIds
});
};
// ctrl
toggleSelectionInGroup = (taskId) => {
const selectedTaskIds = this.state.selectedTaskIds,
index = selectedTaskIds.indexOf(taskId);
// if not selected - add it to the selected items
if (index === -1) {
this.setState({
selectedTaskIds: [...selectedTaskIds, taskId]
});
return;
}
// it was previously selected and now needs to be removed from the group
const shallow = [...selectedTaskIds];
shallow.splice(index, 1);
this.setState({
selectedTaskIds: shallow
});
};
// shift
// This behaviour matches the MacOSX finder selection
multiSelectTo1 = (newTaskId) => {
const updated = multiSelectTo(
this.state.entities,
this.state.selectedTaskIds,
newTaskId
);
if (updated === null) {
return;
}
this.setState({
selectedTaskIds: updated
});
};
unselect = () => {
this.unselectAll();
};
unselectAll = () => {
this.setState({
selectedTaskIds: []
});
};
render () {
const entities = this.state.entities,
selected = this.state.selectedTaskIds;
return (
<DragDropContext
onDragStart={this.onDragStart}
onDragEnd={this.onDragEnd}
>
<Container>
{entities.columnOrder.map((columnId) => (
<Column
column={entities.columns[columnId]}
tasks={getTasks(entities, columnId)}
selectedTaskIds={selected}
key={columnId}
draggingTaskId={this.state.draggingTaskId}
toggleSelection={this.toggleSelection}
toggleSelectionInGroup={this.toggleSelectionInGroup}
multiSelectTo={this.multiSelectTo1}
/>
))}
</Container>
</DragDropContext>
);
}
}
import React, { Component } from "react";
import styled from "@emotion/styled";
import memoizeOne from "memoize-one";
import { Droppable } from "react-beautiful-dnd";
import { grid, borderRadius } from "./constants";
import Task from "./task";
const Container = styled.div`
width: 350px;
height: 400px;
overflow-y: auto;
margin: ${grid}px;
border-radius: ${borderRadius}px;
border: 1px solid pink;
background-color: lightblue;
display: flex;
flex-direction: column;
`,
Title = styled.h3`
font-weight: bold;
padding: ${grid}px;
`,
TaskList = styled.div`
padding: ${grid}px;
min-height: 200px;
flex-grow: 1;
transition: background-color 0.2s ease;
${(props) =>
props.isDraggingOver ? "background-color: pink" : ""};
`,
getSelectedMap = memoizeOne((selectedTaskIds) =>
selectedTaskIds.reduce((previous, current) => {
previous[current] = true;
return previous;
}, {})
);
export default class Column extends Component {
render () {
const column = this.props.column,
tasks = this.props.tasks,
selectedTaskIds = this.props.selectedTaskIds,
draggingTaskId = this.props.draggingTaskId;
return (
<Container>
<Title>{column.title}</Title>
<Droppable droppableId={column.id}>
{(provided, snapshot) => (
<TaskList
ref={provided.innerRef}
isDraggingOver={snapshot.isDraggingOver}
{...provided.droppableProps}
>
{tasks.map((task, index) => {
const isSelected = Boolean(getSelectedMap(selectedTaskIds)[task.id]),
isGhosting = isSelected && Boolean(draggingTaskId) && draggingTaskId !== task.id;
return (
<Task
task={task}
index={index}
key={task.id}
isSelected={isSelected}
isGhosting={isGhosting}
selectionCount={selectedTaskIds.length}
toggleSelection={this.props.toggleSelection}
toggleSelectionInGroup={this.props.toggleSelectionInGroup}
multiSelectTo={this.props.multiSelectTo}
/>
);
})}
{provided.placeholder}
</TaskList>
)}
</Droppable>
</Container>
);
}
}
import React, { Component } from "react";
import styled from "@emotion/styled";
import { Draggable } from "react-beautiful-dnd";
import { grid, borderRadius } from "./constants";
const primaryButton = 0,
getBackgroundColor = ({ isSelected, isGhosting }) => {
if (isGhosting) {
return "cyan";
}
if (isSelected) {
return "pink";
}
return "cyan";
},
getColor = ({ isSelected, isGhosting }) => {
if (isGhosting) {
return "darkgrey";
}
if (isSelected) {
return "red";
}
return "blue";
},
Container = styled.div`
background-color: ${(props) => getBackgroundColor(props)};
color: ${(props) => getColor(props)};
padding: ${grid}px;
margin-bottom: ${grid}px;
border-radius: ${borderRadius}px;
font-size: 18px;
border: 3px solid yellow;
`,
Content = styled.div``,
size = 30,
SelectionCount = styled.div`
right: -${grid}px;
top: -${grid}px;
color: red;
background: crimson;
border-radius: 50%;
height: ${size}px;
width: ${size}px;
line-height: ${size}px;
position: absolute;
text-align: center;
font-size: 0.8rem;
`,
keyCodes = {
enter: 13,
escape: 27,
arrowDown: 40,
arrowUp: 38,
tab: 9
};
export default class Task extends Component {
onKeyDown = (event, provided, snapshot) => {
if (event.defaultPrevented) {
return;
}
if (snapshot.isDragging) {
return;
}
if (event.keyCode !== keyCodes.enter) {
return;
}
// we are using the event for selection
event.preventDefault();
this.performAction(event);
};
// Using onClick as it will be correctly
// preventing if there was a drag
onClick = (event) => {
if (event.defaultPrevented) {
return;
}
if (event.button !== primaryButton) {
return;
}
// marking the event as used
event.preventDefault();
this.performAction(event);
};
onTouchEnd = (event) => {
if (event.defaultPrevented) {
return;
}
// marking the event as used
// we would also need to add some extra logic to prevent the click
// if this element was an anchor
event.preventDefault();
this.props.toggleSelectionInGroup(this.props.task.id);
};
// Determines if the platform specific toggle selection in group key was used
wasToggleInSelectionGroupKeyUsed = (event) => {
const isUsingWindows = navigator.platform.indexOf("Win") >= 0;
return isUsingWindows ? event.ctrlKey : event.metaKey;
};
// Determines if the multiSelect key was used
wasMultiSelectKeyUsed = (event) => event.shiftKey;
performAction = (event) => {
const {
task,
toggleSelection,
toggleSelectionInGroup,
multiSelectTo
} = this.props;
if (this.wasToggleInSelectionGroupKeyUsed(event)) {
toggleSelectionInGroup(task.id);
return;
}
if (this.wasMultiSelectKeyUsed(event)) {
multiSelectTo(task.id);
return;
}
toggleSelection(task.id);
};
render () {
const task = this.props.task,
index = this.props.index,
isSelected = this.props.isSelected,
selectionCount = this.props.selectionCount,
isGhosting = this.props.isGhosting;
return (
<Draggable draggableId={task.id} index={index}>
{(provided, snapshot) => {
const shouldShowSelection = snapshot.isDragging && selectionCount > 1;
return (
<Container
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={this.onClick}
onTouchEnd={this.onTouchEnd}
onKeyDown={(event) => this.onKeyDown(event, provided, snapshot)}
isDragging={snapshot.isDragging}
isSelected={isSelected}
isGhosting={isGhosting}
>
<Content>{task.content}</Content>
{shouldShowSelection ? (
<SelectionCount>{selectionCount}</SelectionCount>
) : null}
</Container>
);
}}
</Draggable>
);
}
}
const tasks = Array.from({ length: 20 }, (v, k) => k).map((val) => ({
id: `task-${val}`,
content: `TaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTaskTask ${val}`
})),
taskMap = tasks.reduce((previous, current) => {
previous[current.id] = current;
return previous;
}, {}),
todo = {
id: "todo",
title: "To do",
taskIds: tasks.map((task) => task.id)
},
done = {
id: "done",
title: "Done",
taskIds: []
},
entities = {
columnOrder: [todo.id, done.id],
columns: {
[todo.id]: todo,
[done.id]: done
},
tasks: taskMap
};
export default entities;
// import { invariant } from "react-beautiful-dnd";
import reorder from "./reorder";
const withNewTaskIds = (column, taskIds) => ({
id: column.id,
title: column.title,
taskIds
}),
reorderSingleDrag = ({
entities,
selectedTaskIds,
source,
destination
}) => {
// moving in the same list
if (source.droppableId === destination.droppableId) {
const column = entities.columns[source.droppableId],
reordered = reorder(column.taskIds, source.index, destination.index),
updated = {
...entities,
columns: {
...entities.columns,
[column.id]: withNewTaskIds(column, reordered)
}
};
return {
entities: updated,
selectedTaskIds
};
}
// moving to a new list
const home = entities.columns[source.droppableId],
foreign = entities.columns[destination.droppableId],
// the id of the task to be moved
taskId = home.taskIds[source.index],
// remove from home column
newHomeTaskIds = [...home.taskIds];
newHomeTaskIds.splice(source.index, 1);
// add to foreign column
const newForeignTaskIds = [...foreign.taskIds];
newForeignTaskIds.splice(destination.index, 0, taskId);
const updated = {
...entities,
columns: {
...entities.columns,
[home.id]: withNewTaskIds(home, newHomeTaskIds),
[foreign.id]: withNewTaskIds(foreign, newForeignTaskIds)
}
};
return {
entities: updated,
selectedTaskIds
};
};
export const getHomeColumn = (entities, taskId) => {
const columnId = entities.columnOrder.find((id) => {
const column = entities.columns[id];
return column.taskIds.includes(taskId);
});
// invariant(columnId, "Count not find column for task");
return entities.columns[columnId];
};
const reorderMultiDrag = ({
entities,
selectedTaskIds,
source,
destination
}) => {
const start = entities.columns[source.droppableId],
dragged = start.taskIds[source.index],
insertAtIndex = (() => {
const destinationIndexOffset = selectedTaskIds.reduce(
(previous, current) => {
if (current === dragged) {
return previous;
}
const final = entities.columns[destination.droppableId],
column = getHomeColumn(entities, current);
if (column !== final) {
return previous;
}
const index = column.taskIds.indexOf(current);
if (index >= destination.index) {
return previous;
}
// the selected item is before the destination index
// we need to account for this when inserting into the new location
return previous + 1;
},
0
),
result = destination.index - destinationIndexOffset;
return result;
})(),
// doing the ordering now as we are required to look up columns
// and know original ordering
orderedSelectedTaskIds = [...selectedTaskIds],
// 不需要重新排序
// orderedSelectedTaskIds.sort((a, b) => {
// // moving the dragged item to the top of the list
// if (a === dragged) {
// return -1;
// }
// if (b === dragged) {
// return 1;
// }
// // sorting by their natural indexes
// const columnForA = getHomeColumn(entities, a),
// indexOfA = columnForA.taskIds.indexOf(a),
// columnForB = getHomeColumn(entities, b),
// indexOfB = columnForB.taskIds.indexOf(b);
// if (indexOfA !== indexOfB) {
// return indexOfB - indexOfA;
// }
// // sorting by their order in the selectedTaskIds list
// return -1;
// });
// we need to remove all of the selected tasks from their columns
withRemovedTasks = entities.columnOrder.reduce((previous, columnId) => {
const column = entities.columns[columnId],
// remove the id's of the items that are selected
remainingTaskIds = column.taskIds.filter(
(id) => !selectedTaskIds.includes(id)
);
previous[column.id] = withNewTaskIds(column, remainingTaskIds);
return previous;
}, entities.columns),
final = withRemovedTasks[destination.droppableId],
withInserted = (() => {
const base = [...final.taskIds];
base.splice(insertAtIndex, 0, ...orderedSelectedTaskIds);
return base;
})(),
// insert all selected tasks into final column
withAddedTasks = {
...withRemovedTasks,
[final.id]: withNewTaskIds(final, withInserted)
},
updated = {
...entities,
columns: withAddedTasks
};
return {
entities: updated,
selectedTaskIds: orderedSelectedTaskIds
};
};
export const mutliDragAwareReorder = (args) => {
if (args.selectedTaskIds.length > 1) {
return reorderMultiDrag(args);
}
return reorderSingleDrag(args);
};
export const multiSelectTo = (entities, selectedTaskIds, newTaskId) => {
// Nothing already selected
if (!selectedTaskIds.length) {
return [newTaskId];
}
const columnOfNew = getHomeColumn(entities, newTaskId),
indexOfNew = columnOfNew.taskIds.indexOf(newTaskId),
lastSelected = selectedTaskIds[selectedTaskIds.length - 1],
columnOfLast = getHomeColumn(entities, lastSelected),
indexOfLast = columnOfLast.taskIds.indexOf(lastSelected);
// multi selecting to another column
// select everything up to the index of the current item
if (columnOfNew !== columnOfLast) {
return columnOfNew.taskIds.slice(0, indexOfNew + 1);
}
// multi selecting in the same column
// need to select everything between the last index and the current index inclusive
// nothing to do here
if (indexOfNew === indexOfLast) {
return null;
}
const isSelectingForwards = indexOfNew > indexOfLast,
start = isSelectingForwards ? indexOfLast : indexOfNew,
end = isSelectingForwards ? indexOfNew : indexOfLast,
inBetween = columnOfNew.taskIds.slice(start, end + 1),
// everything inbetween needs to have it's selection toggled.
// with the exception of the start and end values which will always be selected
toAdd = inBetween.filter((taskId) => {
// if already selected: then no need to select it again
if (selectedTaskIds.includes(taskId)) {
return false;
}
return true;
}),
sorted = isSelectingForwards ? toAdd : [...toAdd].reverse(),
combined = [...selectedTaskIds, ...sorted];
return combined;
};
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list),
[removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
export default reorder;
export const reorderQuoteMap = ({ quoteMap, source, destination }) => {
const current = [...quoteMap[source.droppableId]],
next = [...quoteMap[destination.droppableId]],
target = current[source.index];
// moving to same list
if (source.droppableId === destination.droppableId) {
const reordered = reorder(current, source.index, destination.index),
result = {
...quoteMap,
[source.droppableId]: reordered
};
return {
quoteMap: result
};
}
// moving to different list
// remove from original
current.splice(source.index, 1);
// insert into next
next.splice(destination.index, 0, target);
const result = {
...quoteMap,
[source.droppableId]: current,
[destination.droppableId]: next
};
return {
quoteMap: result
};
};
export function moveBetween ({ list1, list2, source, destination }) {
const newFirst = Array.from(list1.values),
newSecond = Array.from(list2.values),
moveFrom = source.droppableId === list1.id ? newFirst : newSecond,
moveTo = moveFrom === newFirst ? newSecond : newFirst,
[moved] = moveFrom.splice(source.index, 1);
moveTo.splice(destination.index, 0, moved);
return {
list1: {
...list1,
values: newFirst
},
list2: {
...list2,
values: newSecond
}
};
}
export const grid = 8;
export const borderRadius = 2;