- Consolidate the react-trello dir into the production-board-kanban.component.jsx dir

Signed-off-by: Dave Richer <dave@imexsystems.ca>
This commit is contained in:
Dave Richer
2024-07-05 21:29:44 -04:00
parent 8e50d0ba53
commit 8199ab83ef
245 changed files with 7 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client";
import Board from "../../components/trello-board/index";
import Board from "./trello-board/index";
import { Button, notification, Skeleton, Space, Statistic } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import React, { useCallback, useEffect, useMemo, useState } from "react";

View File

@@ -0,0 +1,49 @@
import React, { useEffect, useRef } from "react";
import PropTypes from "prop-types";
/**
* Height Memory Wrapper
* @param children
* @param maxHeight
* @param setMaxHeight
* @returns {Element}
* @constructor
*/
const HeightMemoryWrapper = ({ children, maxHeight, setMaxHeight }) => {
const ref = useRef(null);
useEffect(() => {
const currentRef = ref.current; // Step 1: Capture the current ref value
const updateHeight = () => {
const currentHeight = currentRef?.firstChild?.clientHeight || 0;
setMaxHeight((prevHeight) => Math.max(prevHeight, currentHeight));
};
const resizeObserver = new ResizeObserver(updateHeight);
if (currentRef?.firstChild) {
// Step 2: Use the captured ref for observing
resizeObserver.observe(currentRef.firstChild);
}
return () => {
if (currentRef?.firstChild) {
// Step 2: Use the captured ref for observing
resizeObserver.unobserve(currentRef.firstChild);
}
};
}, [setMaxHeight]);
return (
<div ref={ref} style={{ minHeight: maxHeight }}>
{children}
</div>
);
};
HeightMemoryWrapper.propTypes = {
children: PropTypes.node.isRequired,
maxHeight: PropTypes.number.isRequired,
setMaxHeight: PropTypes.func.isRequired
};
export default HeightMemoryWrapper;

View File

@@ -0,0 +1,24 @@
import React, { useEffect, useState } from "react";
const HeightPreservingItem = ({ children, ...props }) => {
const [size, setSize] = useState(0);
const knownSize = props["data-known-size"];
useEffect(() => {
setSize((prevSize) => {
return knownSize === 0 ? prevSize : knownSize;
});
}, [setSize, knownSize]);
return (
<div
{...props}
className="height-preserving-container"
style={{
"--child-height": `${size}px`
}}
>
{children}
</div>
);
};
export default HeightPreservingItem;

View File

@@ -0,0 +1,9 @@
import React from "react";
import { LaneFooter } from "../../styles/Base";
import { CollapseBtn, ExpandBtn } from "../../styles/Elements";
const LaneFooterComponent = ({ onClick, collapsed }) => (
<LaneFooter onClick={onClick}>{collapsed ? <ExpandBtn /> : <CollapseBtn />}</LaneFooter>
);
export default LaneFooterComponent;

View File

@@ -0,0 +1,52 @@
import React, { useEffect, useRef } from "react";
import PropTypes from "prop-types";
const SizeMemoryWrapper = ({ children, maxHeight, setMaxHeight, maxWidth, setMaxWidth }) => {
const ref = useRef(null);
useEffect(() => {
const currentRef = ref.current; // Capture the current ref value
const updateSize = () => {
// Check if the zoom level is less than 100%
if (window.devicePixelRatio < 1) {
return; // Do not update width and height
}
const currentHeight = currentRef?.firstChild?.clientHeight || 0;
const currentWidth = currentRef?.firstChild?.clientWidth || 0;
// Update height as before
setMaxHeight((prevHeight) => Math.max(prevHeight, currentHeight));
// Update width to decrease when zooming back in
setMaxWidth((prevWidth) => Math.max(prevWidth, currentWidth)); // Increase width if current is greater than previous
};
const resizeObserver = new ResizeObserver(updateSize);
if (currentRef?.firstChild) {
resizeObserver.observe(currentRef.firstChild);
}
return () => {
if (currentRef?.firstChild) {
resizeObserver.unobserve(currentRef.firstChild);
}
};
}, [setMaxHeight, setMaxWidth]);
return (
<div ref={ref} className="size-memory-wrapper" style={{ minHeight: maxHeight, minWidth: maxWidth }}>
{children}
</div>
);
};
SizeMemoryWrapper.propTypes = {
children: PropTypes.node.isRequired,
maxHeight: PropTypes.number.isRequired,
setMaxHeight: PropTypes.func.isRequired,
maxWidth: PropTypes.number.isRequired,
setMaxWidth: PropTypes.func.isRequired
};
export default SizeMemoryWrapper;

View File

@@ -0,0 +1,14 @@
import LaneFooter from "./Lane/LaneFooter";
import { BoardWrapper, StyleHorizontal, StyleVertical, ScrollableLane, Section } from "../styles/Base";
const exports = {
StyleHorizontal,
StyleVertical,
BoardWrapper,
ScrollableLane,
LaneFooter,
Section
};
export default exports;

View File

@@ -0,0 +1,25 @@
import { BoardContainer } from "../index";
import { useMemo } from "react";
import { StyleHorizontal, StyleVertical } from "../styles/Base.js";
const Board = ({ id, className, orientation, cardSettings, ...additionalProps }) => {
const OrientationStyle = useMemo(
() => (orientation === "horizontal" ? StyleHorizontal : StyleVertical),
[orientation]
);
return (
<>
<OrientationStyle>
<BoardContainer
orientation={orientation}
cardSettings={cardSettings}
{...additionalProps}
className="react-trello-board"
/>
</OrientationStyle>
</>
);
};
export default Board;

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { DragDropContext } from "../dnd/lib";
import PropTypes from "prop-types";
import isEqual from "lodash/isEqual";
import Lane from "./Lane";
import { PopoverWrapper } from "react-popopo";
import * as actions from "../../../../redux/trello/trello.actions.js";
import { BoardWrapper } from "../styles/Base.js";
/**
* BoardContainer is a React component that represents a Trello-like board.
* It uses Redux for state management and provides a variety of props to customize its behavior.
*
* @component
* @param {Object} props - Component props
* @param {Object} props.data - The initial data for the board
* @param {Function} props.onDataChange - Callback function when the data changes
* @param {Function} props.onDragEnd - Callback function when a drag ends
* @param {Function} props.laneSortFunction - Callback function when a drag ends
* @param {string} props.orientation - The orientation of the board ("horizontal" or "vertical")
* @param {Function} props.eventBusHandle - Function to handle events from the event bus
* @param {Object} props.reducerData - The initial data for the Redux reducer
*
* @returns {JSX.Element} A Trello-like board
*/
const BoardContainer = ({
data,
onDataChange = () => {},
onDragEnd = () => {},
laneSortFunction = () => {},
orientation = "horizontal",
cardSettings = {},
eventBusHandle,
reducerData
}) => {
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [maxLaneHeight, setMaxLaneHeight] = useState(0);
const [maxCardHeight, setMaxCardHeight] = useState(0);
const [maxCardWidth, setMaxCardWidth] = useState(0);
const dispatch = useDispatch();
const currentReducerData = useSelector((state) => (state.trello.lanes ? state.trello : {}));
const wireEventBus = useCallback(() => {
const eventBus = {
publish: (event) => {
switch (event.type) {
// case "ADD_CARD":
// return dispatch(actions.addCard({ laneId: event.laneId, card: event.card }));
// case "REMOVE_CARD":
// return dispatch(actions.removeCard({ laneId: event.laneId, cardId: event.cardId }));
// case "REFRESH_BOARD":
// return dispatch(actions.loadBoard(event.data));
// case "UPDATE_CARDS":
// return dispatch(actions.updateCards({ laneId: event.laneId, cards: event.cards }));
// case "UPDATE_CARD":
// return dispatch(actions.updateCard({ laneId: event.laneId, updatedCard: event.card }));
// case "UPDATE_LANES":
// return dispatch(actions.updateLanes(event.lanes));
// case "UPDATE_LANE":
// return dispatch(actions.updateLane(event.lane));
case "MOVE_CARD":
return dispatch(
actions.moveCardAcrossLanes({
fromLaneId: event.fromLaneId,
toLaneId: event.toLaneId,
cardId: event.cardId,
index: event.index,
event
})
);
default:
return;
}
}
};
eventBusHandle(eventBus);
}, [dispatch, eventBusHandle]);
useEffect(() => {
dispatch(actions.loadBoard(data));
if (eventBusHandle) {
wireEventBus();
}
}, [data, eventBusHandle, dispatch, wireEventBus]);
useEffect(() => {
if (!isEqual(currentReducerData, reducerData)) {
onDataChange(currentReducerData);
}
}, [currentReducerData, reducerData, onDataChange]);
const onDragStart = useCallback(() => {
setIsDragging(true);
}, [setIsDragging]);
const onLaneDrag = useCallback(
async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false);
if (!type || type !== "lane" || !source || !destination) return;
setIsProcessing(true);
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: destination.droppableId,
cardId: draggableId,
index: destination.index
})
);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag", err);
} finally {
setIsProcessing(false);
}
},
[dispatch, onDragEnd]
);
// id: PropTypes.string.isRequired,
// title: PropTypes.node,
// index: PropTypes.number,
// laneSortFunction: PropTypes.func,
// cards: PropTypes.array,
// orientation: PropTypes.string,
// isProcessing: PropTypes.bool,
// cardSettings: PropTypes.object,
// technician: PropTypes.object,
// bodyshop: PropTypes.object
return (
<PopoverWrapper>
<BoardWrapper orientation={orientation}>
<DragDropContext onDragEnd={onLaneDrag} onDragStart={onDragStart} contextId="production-board">
{currentReducerData.lanes.map((lane, index) => {
return (
<Lane
key={lane.id}
id={lane.id}
title={lane.title}
index={index}
laneSortFunction={laneSortFunction}
orientation={orientation}
cards={lane.cards}
isDragging={isDragging}
isProcessing={isProcessing}
cardSettings={cardSettings}
maxLaneHeight={maxLaneHeight}
setMaxLaneHeight={setMaxLaneHeight}
maxCardHeight={maxCardHeight}
setMaxCardHeight={setMaxCardHeight}
maxCardWidth={maxCardWidth}
setMaxCardWidth={setMaxCardWidth}
/>
);
})}
</DragDropContext>
</BoardWrapper>
</PopoverWrapper>
);
};
BoardContainer.propTypes = {
id: PropTypes.string,
data: PropTypes.object.isRequired,
reducerData: PropTypes.object,
onDataChange: PropTypes.func,
eventBusHandle: PropTypes.func,
laneSortFunction: PropTypes.func,
handleDragEnd: PropTypes.func,
orientation: PropTypes.string
};
export default BoardContainer;

View File

@@ -0,0 +1,308 @@
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
import PropTypes from "prop-types";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as actions from "../../../../redux/trello/trello.actions.js";
import { Draggable, Droppable } from "../dnd/lib";
import { Virtuoso, VirtuosoGrid } from "react-virtuoso";
import HeightPreservingItem from "../components/Lane/HeightPreservingItem.jsx";
import { Section } from "../styles/Base.js";
import LaneFooter from "../components/Lane/LaneFooter.jsx";
import { UnorderedListOutlined } from "@ant-design/icons";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../../../redux/user/user.selectors.js";
import { selectTechnician } from "../../../../redux/tech/tech.selectors.js";
import ProductionBoardCard from "../../../production-board-kanban-card/production-board-kanban-card.component.jsx";
import HeightMemoryWrapper from "../components/Lane/HeightMemoryWrapper.jsx";
import SizeMemoryWrapper from "../components/Lane/SizeMemoryWrapper.jsx";
/**
* Lane is a React component that represents a lane in a Trello-like board.
* @param id
* @param title
* @param index
* @param isProcessing
* @param laneSortFunction
* @param cards
* @param cardSettings
* @param orientation
* @param maxLaneHeight
* @param setMaxLaneHeight
* @param maxCardHeight
* @param setMaxCardHeight
* @param maxCardWidth
* @param setMaxCardWidth
* @param technician -- connected to redux
* @param bodyshop -- connected to redux
* @returns {Element}
* @constructor
*/
const Lane = ({
id,
title,
index,
isProcessing,
laneSortFunction,
cards,
cardSettings = {},
orientation = "vertical",
maxLaneHeight,
setMaxLaneHeight,
maxCardHeight,
setMaxCardHeight,
maxCardWidth,
setMaxCardWidth,
technician,
bodyshop
}) => {
const [collapsed, setCollapsed] = useState(false);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
setIsVisible(false);
const timer = setTimeout(() => setIsVisible(true), 0);
return () => clearTimeout(timer);
}, [cards.length]);
const sortedCards = useMemo(() => {
if (!cards) return [];
if (!laneSortFunction) return cards;
return [...cards].sort(laneSortFunction);
}, [cards, laneSortFunction]);
const toggleLaneCollapsed = useCallback(() => {
setCollapsed((prevCollapsed) => !prevCollapsed);
}, []);
const renderDraggable = useCallback(
(index, card) => {
if (!card) {
console.log("null card");
return null;
}
return (
<Draggable draggableId={card.id} index={index} key={card.id} isDragDisabled={isProcessing}>
{(provided, snapshot) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={provided.draggableProps.style}
className={`item ${snapshot.isDragging ? "is-dragging" : ""}`}
key={card.id}
>
<SizeMemoryWrapper
maxHeight={maxCardHeight}
setMaxHeight={setMaxCardHeight}
maxWidth={maxCardWidth}
setMaxWidth={setMaxCardWidth}
>
<ProductionBoardCard
technician={technician}
bodyshop={bodyshop}
cardSettings={cardSettings}
key={card.id}
card={card}
style={{ minHeight: maxCardHeight, minWidth: maxCardWidth }}
className="react-trello-card"
/>
</SizeMemoryWrapper>
</div>
)}
</Draggable>
);
},
[isProcessing, technician, bodyshop, cardSettings, maxCardHeight, setMaxCardHeight, maxCardWidth, setMaxCardWidth]
);
const ItemWrapper = useCallback(
({ children, ...props }) => (
<div {...props} className="item-wrapper">
{children}
</div>
),
[]
);
const renderDroppable = useCallback(
(provided, renderedCards) => {
const Component = orientation === "vertical" ? VirtuosoGrid : Virtuoso;
const FinalComponent = collapsed ? "div" : Component;
const commonProps = {
useWindowScroll: true,
data: renderedCards
};
const verticalProps = {
...commonProps,
listClassName: "grid-container",
itemClassName: "grid-item",
components: {
List: forwardRef(({ style, children, ...props }, ref) => (
<div ref={ref} {...props} style={{ ...style }}>
{children}
</div>
)),
Item: ({ children, ...props }) => (
<div style={{ minWidth: maxCardWidth, minHeight: maxCardHeight }} {...props}>
{children}
</div>
)
},
itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>,
overscan: { main: 10, reverse: 10 }
};
const horizontalProps = {
...commonProps,
components: { Item: HeightPreservingItem },
overscan: { main: 3, reverse: 3 },
itemContent: (index, item) => renderDraggable(index, item),
scrollerRef: provided.innerRef,
style: {
minWidth: maxCardWidth,
minHeight: maxLaneHeight
}
};
const componentProps = orientation === "vertical" ? verticalProps : horizontalProps;
const finalComponentProps = collapsed
? orientation === "horizontal"
? {
style: {
height: maxLaneHeight
}
}
: {}
: componentProps;
return orientation === "horizontal" ? (
<HeightMemoryWrapper maxHeight={maxLaneHeight} setMaxHeight={setMaxLaneHeight}>
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
style={{ ...provided.droppableProps.style }}
>
{isVisible && <FinalComponent {...finalComponentProps} />}
</div>
</HeightMemoryWrapper>
) : (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
style={{ ...provided.droppableProps.style }}
>
{isVisible && <FinalComponent {...finalComponentProps} />}
{(collapsed || renderedCards.length === 0) && provided.placeholder}
</div>
);
},
[orientation, collapsed, isVisible, renderDraggable, maxLaneHeight, setMaxLaneHeight, maxCardHeight, maxCardWidth]
);
const renderDragContainer = useCallback(
() => (
<Droppable
droppableId={id}
index={index}
type="lane"
direction={orientation === "horizontal" ? "vertical" : "grid"}
mode="virtual"
renderClone={(provided, snapshot, rubric) => {
const card = sortedCards[rubric.source.index];
return (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={{
...provided.draggableProps.style,
minHeight: maxCardHeight,
minWidth: maxCardWidth
}}
className={`clone ${snapshot.isDragging ? "is-dragging" : ""}`}
key={card.id}
>
{/*<SizeMemoryWrapper*/}
{/* maxHeight={maxCardHeight}*/}
{/* setMaxHeight={setMaxCardHeight}*/}
{/* maxWidth={maxCardWidth}*/}
{/* setMaxWidth={setMaxCardWidth}*/}
{/*>*/}
<ProductionBoardCard
technician={technician}
bodyshop={bodyshop}
cardSettings={cardSettings}
key={card.id}
className="react-trello-card"
style={{
minHeight: maxCardHeight,
minWidth: maxCardWidth,
background_color: "red !important"
}}
card={card}
clone={false}
/>
{/*</SizeMemoryWrapper>*/}
</div>
);
}}
>
{(provided) => renderDroppable(provided, sortedCards)}
</Droppable>
),
[
id,
index,
orientation,
renderDroppable,
sortedCards,
technician,
bodyshop,
cardSettings,
maxCardHeight,
maxCardWidth
]
);
return (
<Section key={id} orientation={orientation}>
<div onDoubleClick={toggleLaneCollapsed} className="react-trello-column-header">
<UnorderedListOutlined className="icon" /> {title}
</div>
{renderDragContainer()}
<LaneFooter onClick={toggleLaneCollapsed} collapsed={collapsed} />
</Section>
);
};
Lane.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
index: PropTypes.number.isRequired,
laneSortFunction: PropTypes.func,
cards: PropTypes.array.isRequired,
orientation: PropTypes.string.isRequired,
isProcessing: PropTypes.bool.isRequired,
cardSettings: PropTypes.object.isRequired,
maxLaneHeight: PropTypes.number.isRequired,
setMaxLaneHeight: PropTypes.func.isRequired,
maxCardHeight: PropTypes.number.isRequired,
setMaxCardHeight: PropTypes.func.isRequired,
maxCardWidth: PropTypes.number.isRequired,
setMaxCardWidth: PropTypes.func.isRequired
};
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actions, dispatch)
});
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
technician: selectTechnician
});
export default connect(mapStateToProps, mapDispatchToProps)(Lane);

View File

@@ -0,0 +1,67 @@
import { isEqual, origin } from "./state/position";
export const curves = {
outOfTheWay: "cubic-bezier(0.2, 0, 0, 1)",
drop: "cubic-bezier(.2,1,.1,1)"
};
export const combine = {
opacity: {
// while dropping: fade out totally
drop: 0,
// while dragging: fade out partially
combining: 0.7
},
scale: {
drop: 0.75
}
};
export const timings = {
outOfTheWay: 0.2,
// greater than the out of the way time
// so that when the drop ends everything will
// have to be out of the way
minDropTime: 0.33,
maxDropTime: 0.55
};
// slow timings
// uncomment to use
// export const timings = {
// outOfTheWay: 2,
// // greater than the out of the way time
// // so that when the drop ends everything will
// // have to be out of the way
// minDropTime: 3,
// maxDropTime: 4,
// };
const outOfTheWayTiming = `${timings.outOfTheWay}s ${curves.outOfTheWay}`;
export const placeholderTransitionDelayTime = 0.1;
export const transitions = {
fluid: `opacity ${outOfTheWayTiming}`,
snap: `transform ${outOfTheWayTiming}, opacity ${outOfTheWayTiming}`,
drop: (duration) => {
const timing = `${duration}s ${curves.drop}`;
return `transform ${timing}, opacity ${timing}`;
},
outOfTheWay: `transform ${outOfTheWayTiming}`,
placeholder: `height ${outOfTheWayTiming}, width ${outOfTheWayTiming}, margin ${outOfTheWayTiming}`
};
const moveTo = (offset) => (isEqual(offset, origin) ? null : `translate(${offset.x}px, ${offset.y}px)`);
export const transforms = {
moveTo,
drop: (offset, isCombining) => {
const translate = moveTo(offset);
if (!translate) {
return null;
}
// only transforming the translate
if (!isCombining) {
return translate;
}
// when dropping while combining we also update the scale
return `${translate} scale(${combine.scale.drop})`;
}
};

View File

@@ -0,0 +1,28 @@
const average = (values) => {
const sum = values.reduce((previous, current) => previous + current, 0);
return sum / values.length;
};
export default (groupSize) => {
console.log("Starting average action timer middleware");
console.log(`Will take an average every ${groupSize} actions`);
const bucket = {};
return () => (next) => (action) => {
const start = performance.now();
const result = next(action);
const end = performance.now();
const duration = end - start;
if (!bucket[action.type]) {
bucket[action.type] = [duration];
return result;
}
bucket[action.type].push(duration);
if (bucket[action.type].length < groupSize) {
return result;
}
console.warn(`Average time for ${action.type}`, average(bucket[action.type]));
// reset
bucket[action.type] = [];
return result;
};
};

View File

@@ -0,0 +1,10 @@
import * as timings from "../timings";
export default () => (next) => (action) => {
timings.forceEnable();
const key = `redux action: ${action.type}`;
timings.start(key);
const result = next(action);
timings.finish(key);
return result;
};

View File

@@ -0,0 +1,16 @@
export default (mode = "verbose") =>
(store) =>
(next) =>
(action) => {
if (mode === "light") {
console.log("🏃‍ Action:", action.type);
return next(action);
}
console.group(`action: ${action.type}`);
console.log("action payload", action.payload);
console.log("state before", store.getState());
const result = next(action);
console.log("state after", store.getState());
console.groupEnd();
return result;
};

View File

@@ -0,0 +1,10 @@
export default () => (next) => (action) => {
const title = `👾 redux (action): ${action.type}`;
const startMark = `${action.type}:start`;
const endMark = `${action.type}:end`;
performance.mark(startMark);
const result = next(action);
performance.mark(endMark);
performance.measure(title, startMark, endMark);
return result;
};

View File

@@ -0,0 +1,68 @@
const records = {};
let isEnabled = false;
const isTimingsEnabled = () => isEnabled;
export const forceEnable = () => {
isEnabled = true;
};
// Debug: uncomment to enable
// forceEnable();
export const start = (key) => {
// we want to strip all the code out for production builds
// draw back: can only do timings in dev env (which seems to be fine for now)
if (import.meta.env.DEV) {
if (!isTimingsEnabled()) {
return;
}
const now = performance.now();
records[key] = now;
}
};
export const finish = (key) => {
if (import.meta.env.DEV) {
if (!isTimingsEnabled()) {
return;
}
const now = performance.now();
const previous = records[key];
if (!previous) {
// eslint-disable-next-line no-console
console.warn("cannot finish timing as no previous time found", key);
return;
}
const result = now - previous;
const rounded = result.toFixed(2);
const style = (() => {
if (result < 12) {
return {
textColor: "green",
symbol: "✅"
};
}
if (result < 40) {
return {
textColor: "orange",
symbol: "⚠️"
};
}
return {
textColor: "red",
symbol: "❌"
};
})();
// eslint-disable-next-line no-console
console.log(
`${style.symbol} %cTiming %c${rounded} %cms %c${key}`,
// title
"color: blue; font-weight: bold;",
// result
`color: ${style.textColor}; font-size: 1.1em;`,
// ms
"color: grey;",
// key
"color: purple; font-weight: bold;"
);
}
};

View File

@@ -0,0 +1,44 @@
const isProduction = import.meta.env.PROD;
// not replacing newlines (which \s does)
const spacesAndTabs = /[ \t]{2,}/g;
const lineStartWithSpaces = /^[ \t]*/gm;
// using .trim() to clear the any newlines before the first text and after last text
const clean = (value) => value.replace(spacesAndTabs, " ").replace(lineStartWithSpaces, "").trim();
const getDevMessage = (message) =>
clean(`
%creact-beautiful-dnd
%c${clean(message)}
%c👷 This is a development only message. It will be removed in production builds.
`);
export const getFormattedMessage = (message) => [
getDevMessage(message),
// title (green400)
"color: #00C584; font-size: 1.2em; font-weight: bold;",
// message
"line-height: 1.5",
// footer (purple300)
"color: #723874;"
];
const isDisabledFlag = "__react-beautiful-dnd-disable-dev-warnings";
export function log(type, message) {
// no warnings in production
if (isProduction) {
return;
}
// manual opt out of warnings
if (typeof window !== "undefined" && window[isDisabledFlag]) {
return;
}
// eslint-disable-next-line no-console
console[type](...getFormattedMessage(message));
}
export const warning = log.bind(null, "warn");
export const error = log.bind(null, "error");

View File

@@ -0,0 +1,5 @@
export function noop() {}
export function identity(value) {
return value;
}

View File

@@ -0,0 +1,18 @@
// Components
export { default as DragDropContext } from "./view/drag-drop-context";
export { default as Droppable } from "./view/droppable";
export { default as Draggable } from "./view/draggable";
// Default sensors
export { useMouseSensor, useTouchSensor, useKeyboardSensor } from "./view/use-sensor-marshal";
// Utils
export { resetServerContext } from "./view/drag-drop-context";
// Public flow types
// Droppable types
// Draggable types

View File

@@ -0,0 +1,32 @@
/* eslint-disable no-restricted-syntax */
const isProduction = import.meta.env.PROD;
const prefix = "Invariant failed";
// Want to use this:
// export class RbdInvariant extends Error { }
// But it causes babel to bring in a lot of code
export function RbdInvariant(message) {
this.message = message;
}
// $FlowFixMe
RbdInvariant.prototype.toString = function toString() {
return this.message;
};
// A copy-paste of tiny-invariant but with a custom error type
// Throw an error if the condition fails
export function invariant(condition, message) {
if (condition) {
return;
}
if (isProduction) {
// In production we strip the message but still throw
throw new RbdInvariant(prefix);
} else {
// When not in production we allow the message to pass through
// *This block will be removed in production builds*
throw new RbdInvariant(`${prefix}: ${message || ""}`);
}
}

View File

@@ -0,0 +1,54 @@
/* eslint-disable no-restricted-globals */
export function isInteger(value) {
if (Number.isInteger) {
return Number.isInteger(value);
}
return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
}
// Using this helper to ensure there are correct flow types
// https://github.com/facebook/flow/issues/2221
export function values(map) {
if (Object.values) {
// $FlowFixMe - Object.values currently does not have good flow support
return Object.values(map);
}
return Object.keys(map).map((key) => map[key]);
}
// Could also extend to pass index and list
// TODO: swap order
export function findIndex(list, predicate) {
if (list.findIndex) {
return list.findIndex(predicate);
}
// Using a for loop so that we can exit early
for (let i = 0; i < list.length; i++) {
if (predicate(list[i])) {
return i;
}
}
// Array.prototype.find returns -1 when nothing is found
return -1;
}
export function find(list, predicate) {
if (list.find) {
return list.find(predicate);
}
const index = findIndex(list, predicate);
if (index !== -1) {
return list[index];
}
// Array.prototype.find returns undefined when nothing is found
return undefined;
}
// Using this rather than Array.from as Array.from adds 2kb to the gzip
// document.querySelector actually returns Element[], but flow thinks it is HTMLElement[]
// So we downcast the result to Element[]
export function toArray(list) {
return Array.prototype.slice.call(list);
}

View File

@@ -0,0 +1,91 @@
const dragHandleUsageInstructions = `
Press space bar to start a drag.
When dragging you can use the arrow keys to move the item around and escape to cancel.
Some screen readers may require you to be in focus mode or to use your pass through key
`;
const position = (index) => index + 1;
// We cannot list what index the Droppable is in automatically as we are not sure how
// the Droppable's have been configured
const onDragStart = (start) => `
You have lifted an item in position ${position(start.source.index)}
`;
const withLocation = (source, destination) => {
const isInHomeList = source.droppableId === destination.droppableId;
const startPosition = position(source.index);
const endPosition = position(destination.index);
if (isInHomeList) {
return `
You have moved the item from position ${startPosition}
to position ${endPosition}
`;
}
return `
You have moved the item from position ${startPosition}
in list ${source.droppableId}
to list ${destination.droppableId}
in position ${endPosition}
`;
};
const withCombine = (id, source, combine) => {
const inHomeList = source.droppableId === combine.droppableId;
if (inHomeList) {
return `
The item ${id}
has been combined with ${combine.draggableId}`;
}
return `
The item ${id}
in list ${source.droppableId}
has been combined with ${combine.draggableId}
in list ${combine.droppableId}
`;
};
const onDragUpdate = (update) => {
const location = update.destination;
if (location) {
return withLocation(update.source, location);
}
const combine = update.combine;
if (combine) {
return withCombine(update.draggableId, update.source, combine);
}
return "You are over an area that cannot be dropped on";
};
const returnedToStart = (source) => `
The item has returned to its starting position
of ${position(source.index)}
`;
const onDragEnd = (result) => {
if (result.reason === "CANCEL") {
return `
Movement cancelled.
${returnedToStart(result.source)}
`;
}
const location = result.destination;
const combine = result.combine;
if (location) {
return `
You have dropped the item.
${withLocation(result.source, location)}
`;
}
if (combine) {
return `
You have dropped the item.
${withCombine(result.draggableId, result.source, combine)}
`;
}
return `
The item has been dropped while not over a drop area.
${returnedToStart(result.source)}
`;
};
const preset = {
dragHandleUsageInstructions,
onDragStart,
onDragUpdate,
onDragEnd
};
export default preset;

View File

@@ -0,0 +1,88 @@
export const beforeInitialCapture = (args) => ({
type: "BEFORE_INITIAL_CAPTURE",
payload: args
});
export const lift = (args) => ({
type: "LIFT",
payload: args
});
export const initialPublish = (args) => ({
type: "INITIAL_PUBLISH",
payload: args
});
export const publishWhileDragging = (args) => ({
type: "PUBLISH_WHILE_DRAGGING",
payload: args
});
export const collectionStarting = () => ({
type: "COLLECTION_STARTING",
payload: null
});
export const updateDroppableScroll = (args) => ({
type: "UPDATE_DROPPABLE_SCROLL",
payload: args
});
export const updateDroppableIsEnabled = (args) => ({
type: "UPDATE_DROPPABLE_IS_ENABLED",
payload: args
});
export const updateDroppableIsCombineEnabled = (args) => ({
type: "UPDATE_DROPPABLE_IS_COMBINE_ENABLED",
payload: args
});
export const move = (args) => ({
type: "MOVE",
payload: args
});
export const moveByWindowScroll = (args) => ({
type: "MOVE_BY_WINDOW_SCROLL",
payload: args
});
export const updateViewportMaxScroll = (args) => ({
type: "UPDATE_VIEWPORT_MAX_SCROLL",
payload: args
});
export const moveUp = () => ({
type: "MOVE_UP",
payload: null
});
export const moveDown = () => ({
type: "MOVE_DOWN",
payload: null
});
export const moveRight = () => ({
type: "MOVE_RIGHT",
payload: null
});
export const moveLeft = () => ({
type: "MOVE_LEFT",
payload: null
});
export const flush = () => ({
type: "FLUSH",
payload: null
});
export const animateDrop = (args) => ({
type: "DROP_ANIMATE",
payload: args
});
export const completeDrop = (args) => ({
type: "DROP_COMPLETE",
payload: args
});
export const drop = (args) => ({
type: "DROP",
payload: args
});
export const cancel = () =>
drop({
reason: "CANCEL"
});
export const dropPending = (args) => ({
type: "DROP_PENDING",
payload: args
});
export const dropAnimationFinished = () => ({
type: "DROP_ANIMATION_FINISHED",
payload: null
});

View File

@@ -0,0 +1,111 @@
import { add, apply, isEqual, origin } from "../position";
const smallestSigned = apply((value) => {
if (value === 0) {
return 0;
}
return value > 0 ? 1 : -1;
});
// We need to figure out how much of the movement
// cannot be done with a scroll
export const getOverlap = (() => {
const getRemainder = (target, max) => {
if (target < 0) {
return target;
}
if (target > max) {
return target - max;
}
return 0;
};
return ({ current, max, change }) => {
const targetScroll = add(current, change);
const overlap = {
x: getRemainder(targetScroll.x, max.x),
y: getRemainder(targetScroll.y, max.y)
};
if (isEqual(overlap, origin)) {
return null;
}
return overlap;
};
})();
export const canPartiallyScroll = ({ max: rawMax, current, change }) => {
// It is possible for the max scroll to be greater than the current scroll
// when there are scrollbars on the cross axis. We adjust for this by
// increasing the max scroll point if needed
// This will allow movements backwards even if the current scroll is greater than the max scroll
const max = {
x: Math.max(current.x, rawMax.x),
y: Math.max(current.y, rawMax.y)
};
// Only need to be able to move the smallest amount in the desired direction
const smallestChange = smallestSigned(change);
const overlap = getOverlap({
max,
current,
change: smallestChange
});
// no overlap at all - we can move there!
if (!overlap) {
return true;
}
// if there was an x value, but there is no x overlap - then we can scroll on the x!
if (smallestChange.x !== 0 && overlap.x === 0) {
return true;
}
// if there was an y value, but there is no y overlap - then we can scroll on the y!
if (smallestChange.y !== 0 && overlap.y === 0) {
return true;
}
return false;
};
export const canScrollWindow = (viewport, change) =>
canPartiallyScroll({
current: viewport.scroll.current,
max: viewport.scroll.max,
change
});
export const getWindowOverlap = (viewport, change) => {
if (!canScrollWindow(viewport, change)) {
return null;
}
const max = viewport.scroll.max;
const current = viewport.scroll.current;
return getOverlap({
current,
max,
change
});
};
export const canScrollDroppable = (droppable, change) => {
const frame = droppable.frame;
// Cannot scroll when there is no scrollable
if (!frame) {
return false;
}
return canPartiallyScroll({
current: frame.scroll.current,
max: frame.scroll.max,
change
});
};
export const getDroppableOverlap = (droppable, change) => {
const frame = droppable.frame;
if (!frame) {
return null;
}
if (!canScrollDroppable(droppable, change)) {
return null;
}
return getOverlap({
current: frame.scroll.current,
max: frame.scroll.max,
change
});
};

View File

@@ -0,0 +1,20 @@
// Values used to control how the fluid auto scroll feels
const config = {
// percentage distance from edge of container:
startFromPercentage: 0.25,
maxScrollAtPercentage: 0.05,
// pixels per frame
maxPixelScroll: 28,
// A function used to ease a percentage value
// A simple linear function would be: (percentage) => percentage;
// percentage is between 0 and 1
// result must be between 0 and 1
ease: (percentage) => Math.pow(percentage, 2),
durationDampening: {
// ms: how long to dampen the speed of an auto scroll from the start of a drag
stopDampeningAt: 1200,
// ms: when to start accelerating the reduction of duration dampening
accelerateAt: 360
}
};
export default config;

View File

@@ -0,0 +1,45 @@
import memoizeOne from "memoize-one";
import { invariant } from "../../../invariant";
import isPositionInFrame from "../../visibility/is-position-in-frame";
import { toDroppableList } from "../../dimension-structures";
import { find } from "../../../native-with-fallback";
const getScrollableDroppables = memoizeOne((droppables) =>
toDroppableList(droppables).filter((droppable) => {
// exclude disabled droppables
if (!droppable.isEnabled) {
return false;
}
// only want droppables that are scrollable
if (!droppable.frame) {
return false;
}
return true;
})
);
const getScrollableDroppableOver = (target, droppables) => {
const maybe = find(getScrollableDroppables(droppables), (droppable) => {
invariant(droppable.frame, "Invalid result");
return isPositionInFrame(droppable.frame.pageMarginBox)(target);
});
return maybe;
};
const getBestScrollableDroppable = ({ center, destination, droppables }) => {
// We need to scroll the best droppable frame we can so that the
// placeholder buffer logic works correctly
if (destination) {
const dimension = droppables[destination];
if (!dimension.frame) {
return null;
}
return dimension;
}
// 2. If we are not over a droppable - are we over a droppable frame?
const dimension = getScrollableDroppableOver(center, droppables);
return dimension;
};
export default getBestScrollableDroppable;

View File

@@ -0,0 +1,22 @@
import getScroll from "./get-scroll";
import { canScrollDroppable } from "../can-scroll";
const getDroppableScrollChange = ({ droppable, subject, center, dragStartTime, shouldUseTimeDampening }) => {
// We know this has a closestScrollable
const frame = droppable.frame;
// this should never happen - just being safe
if (!frame) {
return null;
}
const scroll = getScroll({
dragStartTime,
container: frame.pageMarginBox,
subject,
center,
shouldUseTimeDampening
});
return scroll && canScrollDroppable(droppable, scroll) ? scroll : null;
};
export default getDroppableScrollChange;

View File

@@ -0,0 +1,18 @@
import { warning } from "../../../dev-warning";
const getPercentage = ({ startOfRange, endOfRange, current }) => {
const range = endOfRange - startOfRange;
if (range === 0) {
warning(`
Detected distance range of 0 in the fluid auto scroller
This is unexpected and would cause a divide by 0 issue.
Not allowing an auto scroll
`);
return 0;
}
const currentInRange = current - startOfRange;
return currentInRange / range;
};
export default getPercentage;

View File

@@ -0,0 +1,23 @@
const adjustForSizeLimits = ({ container, subject, proposedScroll }) => {
const isTooBigVertically = subject.height > container.height;
const isTooBigHorizontally = subject.width > container.width;
// not too big on any axis
if (!isTooBigHorizontally && !isTooBigVertically) {
return proposedScroll;
}
// too big on both axis
if (isTooBigHorizontally && isTooBigVertically) {
return null;
}
// Only too big on one axis
// Exclude the axis that we cannot scroll on
return {
x: isTooBigHorizontally ? 0 : proposedScroll.x,
y: isTooBigVertically ? 0 : proposedScroll.y
};
};
export default adjustForSizeLimits;

View File

@@ -0,0 +1,34 @@
import getPercentage from "../../get-percentage";
import config from "../../config";
import minScroll from "./min-scroll";
const accelerateAt = config.durationDampening.accelerateAt;
const stopAt = config.durationDampening.stopDampeningAt;
const dampenValueByTime = (proposedScroll, dragStartTime) => {
const startOfRange = dragStartTime;
const endOfRange = stopAt;
const now = Date.now();
const runTime = now - startOfRange;
// we have finished the time dampening period
if (runTime >= stopAt) {
return proposedScroll;
}
// Up to this point we know there is a proposed scroll
// but we have not reached our accelerate point
// Return the minimum amount of scroll
if (runTime < accelerateAt) {
return minScroll;
}
const betweenAccelerateAtAndStopAtPercentage = getPercentage({
startOfRange: accelerateAt,
endOfRange,
current: runTime
});
const scroll = proposedScroll * config.ease(betweenAccelerateAtAndStopAtPercentage);
return Math.ceil(scroll);
};
export default dampenValueByTime;

View File

@@ -0,0 +1,15 @@
import config from "../../config";
const getDistanceThresholds = (container, axis) => {
const startScrollingFrom = container[axis.size] * config.startFromPercentage;
const maxScrollValueAt = container[axis.size] * config.maxScrollAtPercentage;
return {
startScrollingFrom,
maxScrollValueAt
};
};
// all in pixels
// converts the percentages in the config into actual pixel values
export default getDistanceThresholds;

View File

@@ -0,0 +1,54 @@
import getPercentage from "../../get-percentage";
import config from "../../config";
import minScroll from "./min-scroll";
const getValueFromDistance = (distanceToEdge, thresholds) => {
/*
// This function only looks at the distance to one edge
// Example: looking at bottom edge
|----------------------------------|
| |
| |
| |
| |
| | => no scroll in this range
| |
| |
| startScrollingFrom (eg 100px) |
| |
| | => increased scroll value the closer to maxScrollValueAt
| maxScrollValueAt (eg 10px) |
| | => max scroll value in this range
|----------------------------------|
*/
// too far away to auto scroll
if (distanceToEdge > thresholds.startScrollingFrom) {
return 0;
}
// use max speed when on or over boundary
if (distanceToEdge <= thresholds.maxScrollValueAt) {
return config.maxPixelScroll;
}
// when just going on the boundary return the minimum integer
if (distanceToEdge === thresholds.startScrollingFrom) {
return minScroll;
}
// to get the % past startScrollingFrom we will calculate
// the % the value is from maxScrollValueAt and then invert it
const percentageFromMaxScrollValueAt = getPercentage({
startOfRange: thresholds.maxScrollValueAt,
endOfRange: thresholds.startScrollingFrom,
current: distanceToEdge
});
const percentageFromStartScrollingFrom = 1 - percentageFromMaxScrollValueAt;
const scroll = config.maxPixelScroll * config.ease(percentageFromStartScrollingFrom);
// scroll will always be a positive integer
return Math.ceil(scroll);
};
export default getValueFromDistance;

View File

@@ -0,0 +1,27 @@
import getValueFromDistance from "./get-value-from-distance";
import dampenValueByTime from "./dampen-value-by-time";
import minScroll from "./min-scroll";
const getValue = ({ distanceToEdge, thresholds, dragStartTime, shouldUseTimeDampening }) => {
const scroll = getValueFromDistance(distanceToEdge, thresholds);
// not enough distance to trigger a minimum scroll
// we can bail here
if (scroll === 0) {
return 0;
}
// Dampen an auto scroll speed based on duration of drag
if (!shouldUseTimeDampening) {
return scroll;
}
// Once we know an auto scroll should occur based on distance,
// we must let at least 1px through to trigger a scroll event an
// another auto scroll call
return Math.max(dampenValueByTime(scroll, dragStartTime), minScroll);
};
export default getValue;

View File

@@ -0,0 +1,26 @@
import getDistanceThresholds from "./get-distance-thresholds";
import getValue from "./get-value";
const getScrollOnAxis = ({ container, distanceToEdges, dragStartTime, axis, shouldUseTimeDampening }) => {
const thresholds = getDistanceThresholds(container, axis);
const isCloserToEnd = distanceToEdges[axis.end] < distanceToEdges[axis.start];
if (isCloserToEnd) {
return getValue({
distanceToEdge: distanceToEdges[axis.end],
thresholds,
dragStartTime,
shouldUseTimeDampening
});
}
return (
-1 *
getValue({
distanceToEdge: distanceToEdges[axis.start],
thresholds,
dragStartTime,
shouldUseTimeDampening
})
);
};
export default getScrollOnAxis;

View File

@@ -0,0 +1,4 @@
// A scroll event will only be triggered when there is a value of at least 1px change
const minScroll = 1;
export default minScroll;

View File

@@ -0,0 +1,62 @@
import { apply, isEqual, origin } from "../../../position";
import getScrollOnAxis from "./get-scroll-on-axis";
import adjustForSizeLimits from "./adjust-for-size-limits";
import { horizontal, vertical } from "../../../axis";
// will replace -0 and replace with +0
const clean = apply((value) => (value === 0 ? 0 : value));
const getScroll = ({ dragStartTime, container, subject, center, shouldUseTimeDampening }) => {
// get distance to each edge
const distanceToEdges = {
top: center.y - container.top,
right: container.right - center.x,
bottom: container.bottom - center.y,
left: center.x - container.left
};
// 1. Figure out which x,y values are the best target
// 2. Can the container scroll in that direction at all?
// If no for both directions, then return null
// 3. Is the center close enough to a edge to start a drag?
// 4. Based on the distance, calculate the speed at which a scroll should occur
// The lower distance value the faster the scroll should be.
// Maximum speed value should be hit before the distance is 0
// Negative values to not continue to increase the speed
const y = getScrollOnAxis({
container,
distanceToEdges,
dragStartTime,
axis: vertical,
shouldUseTimeDampening
});
const x = getScrollOnAxis({
container,
distanceToEdges,
dragStartTime,
axis: horizontal,
shouldUseTimeDampening
});
const required = clean({
x,
y
});
// nothing required
if (isEqual(required, origin)) {
return null;
}
// need to not scroll in a direction that we are too big to scroll in
const limited = adjustForSizeLimits({
container,
subject,
proposedScroll: required
});
if (!limited) {
return null;
}
return isEqual(limited, origin) ? null : limited;
};
export default getScroll;

View File

@@ -0,0 +1,15 @@
import getScroll from "./get-scroll";
import { canScrollWindow } from "../can-scroll";
const getWindowScrollChange = ({ viewport, subject, center, dragStartTime, shouldUseTimeDampening }) => {
const scroll = getScroll({
dragStartTime,
container: viewport.frame,
subject,
center,
shouldUseTimeDampening
});
return scroll && canScrollWindow(viewport, scroll) ? scroll : null;
};
export default getWindowScrollChange;

View File

@@ -0,0 +1,63 @@
import rafSchd from "raf-schd";
import scroll from "./scroll";
import { invariant } from "../../../invariant";
import * as timings from "../../../debug/timings";
const fluidScroller = ({ scrollWindow, scrollDroppable }) => {
const scheduleWindowScroll = rafSchd(scrollWindow);
const scheduleDroppableScroll = rafSchd(scrollDroppable);
let dragging = null;
const tryScroll = (state) => {
invariant(dragging, "Cannot fluid scroll if not dragging");
const { shouldUseTimeDampening, dragStartTime } = dragging;
scroll({
state,
scrollWindow: scheduleWindowScroll,
scrollDroppable: scheduleDroppableScroll,
dragStartTime,
shouldUseTimeDampening
});
};
const start = (state) => {
timings.start("starting fluid scroller");
invariant(!dragging, "Cannot start auto scrolling when already started");
const dragStartTime = Date.now();
let wasScrollNeeded = false;
const fakeScrollCallback = () => {
wasScrollNeeded = true;
};
scroll({
state,
dragStartTime: 0,
shouldUseTimeDampening: false,
scrollWindow: fakeScrollCallback,
scrollDroppable: fakeScrollCallback
});
dragging = {
dragStartTime,
shouldUseTimeDampening: wasScrollNeeded
};
timings.finish("starting fluid scroller");
// we know an auto scroll is needed - let's do it!
if (wasScrollNeeded) {
tryScroll(state);
}
};
const stop = () => {
// can be called defensively
if (!dragging) {
return;
}
scheduleWindowScroll.cancel();
scheduleDroppableScroll.cancel();
dragging = null;
};
return {
start,
stop,
scroll: tryScroll
};
};
export default fluidScroller;

View File

@@ -0,0 +1,44 @@
import getBestScrollableDroppable from "./get-best-scrollable-droppable";
import whatIsDraggedOver from "../../droppable/what-is-dragged-over";
import getWindowScrollChange from "./get-window-scroll-change";
import getDroppableScrollChange from "./get-droppable-scroll-change";
const scroll = ({ state, dragStartTime, shouldUseTimeDampening, scrollWindow, scrollDroppable }) => {
const center = state.current.page.borderBoxCenter;
const draggable = state.dimensions.draggables[state.critical.draggable.id];
const subject = draggable.page.marginBox;
// 1. Can we scroll the viewport?
if (state.isWindowScrollAllowed) {
const viewport = state.viewport;
const change = getWindowScrollChange({
dragStartTime,
viewport,
subject,
center,
shouldUseTimeDampening
});
if (change) {
scrollWindow(change);
return;
}
}
const droppable = getBestScrollableDroppable({
center,
destination: whatIsDraggedOver(state.impact),
droppables: state.dimensions.droppables
});
if (!droppable) {
return;
}
const change = getDroppableScrollChange({
dragStartTime,
droppable,
subject,
center,
shouldUseTimeDampening
});
if (change) {
scrollDroppable(droppable.descriptor.id, change);
}
};
export default scroll;

View File

@@ -0,0 +1,36 @@
import createFluidScroller from "./fluid-scroller";
import createJumpScroller from "./jump-scroller";
const autoScroller = ({ scrollDroppable, scrollWindow, move }) => {
const fluidScroller = createFluidScroller({
scrollWindow,
scrollDroppable
});
const jumpScroll = createJumpScroller({
move,
scrollWindow,
scrollDroppable
});
const scroll = (state) => {
// Only allowing auto scrolling in the DRAGGING phase
if (state.phase !== "DRAGGING") {
return;
}
if (state.movementMode === "FLUID") {
fluidScroller.scroll(state);
return;
}
if (!state.scrollJumpRequest) {
return;
}
jumpScroll(state);
};
const scroller = {
scroll,
start: fluidScroller.start,
stop: fluidScroller.stop
};
return scroller;
};
export default autoScroller;

View File

@@ -0,0 +1,86 @@
import { invariant } from "../../invariant";
import { add, subtract } from "../position";
import { canScrollWindow, canScrollDroppable, getWindowOverlap, getDroppableOverlap } from "./can-scroll";
import whatIsDraggedOver from "../droppable/what-is-dragged-over";
const jumpScroller = ({ move, scrollDroppable, scrollWindow }) => {
const moveByOffset = (state, offset) => {
const client = add(state.current.client.selection, offset);
move({
client
});
};
const scrollDroppableAsMuchAsItCan = (droppable, change) => {
// Droppable cannot absorb any of the scroll
if (!canScrollDroppable(droppable, change)) {
return change;
}
const overlap = getDroppableOverlap(droppable, change);
// Droppable can absorb the entire change
if (!overlap) {
scrollDroppable(droppable.descriptor.id, change);
return null;
}
// Droppable can only absorb a part of the change
const whatTheDroppableCanScroll = subtract(change, overlap);
scrollDroppable(droppable.descriptor.id, whatTheDroppableCanScroll);
const remainder = subtract(change, whatTheDroppableCanScroll);
return remainder;
};
const scrollWindowAsMuchAsItCan = (isWindowScrollAllowed, viewport, change) => {
if (!isWindowScrollAllowed) {
return change;
}
if (!canScrollWindow(viewport, change)) {
// window cannot absorb any of the scroll
return change;
}
const overlap = getWindowOverlap(viewport, change);
// window can absorb entire scroll
if (!overlap) {
scrollWindow(change);
return null;
}
// window can only absorb a part of the scroll
const whatTheWindowCanScroll = subtract(change, overlap);
scrollWindow(whatTheWindowCanScroll);
const remainder = subtract(change, whatTheWindowCanScroll);
return remainder;
};
const jumpScroller = (state) => {
const request = state.scrollJumpRequest;
if (!request) {
return;
}
const destination = whatIsDraggedOver(state.impact);
invariant(destination, "Cannot perform a jump scroll when there is no destination");
// 1. We scroll the droppable first if we can to avoid the draggable
// leaving the list
const droppableRemainder = scrollDroppableAsMuchAsItCan(state.dimensions.droppables[destination], request);
// droppable absorbed the entire scroll
if (!droppableRemainder) {
return;
}
const viewport = state.viewport;
const windowRemainder = scrollWindowAsMuchAsItCan(state.isWindowScrollAllowed, viewport, droppableRemainder);
// window could absorb all the droppable remainder
if (!windowRemainder) {
return;
}
// The entire scroll could not be absorbed by the droppable and window
// so we manually move whatever is left
moveByOffset(state, windowRemainder);
};
return jumpScroller;
};
export default jumpScroller;

View File

@@ -0,0 +1,34 @@
export const vertical = {
direction: "vertical",
line: "y",
crossAxisLine: "x",
start: "top",
end: "bottom",
size: "height",
crossAxisStart: "left",
crossAxisEnd: "right",
crossAxisSize: "width"
};
export const horizontal = {
direction: "horizontal",
line: "x",
crossAxisLine: "y",
start: "left",
end: "right",
size: "width",
crossAxisStart: "top",
crossAxisEnd: "bottom",
crossAxisSize: "height"
};
export const grid = {
direction: "horizontal",
grid: true,
line: "x",
crossAxisLine: "y",
start: "left",
end: "right",
size: "width",
crossAxisStart: "top",
crossAxisEnd: "bottom",
crossAxisSize: "height"
};

View File

@@ -0,0 +1,88 @@
import removeDraggableFromList from "../remove-draggable-from-list";
import isHomeOf from "../droppable/is-home-of";
import { emptyGroups } from "../no-impact";
import { find } from "../../native-with-fallback";
import getDisplacementGroups from "../get-displacement-groups";
function getIndexOfLastItem(draggables, options) {
if (!draggables.length) {
return 0;
}
const indexOfLastItem = draggables[draggables.length - 1].descriptor.index;
// When in a foreign list there will be an additional one item in the list
return options.inHomeList ? indexOfLastItem : indexOfLastItem + 1;
}
function goAtEnd({ insideDestination, inHomeList, displacedBy, destination }) {
const newIndex = getIndexOfLastItem(insideDestination, {
inHomeList
});
return {
displaced: emptyGroups,
displacedBy,
at: {
type: "REORDER",
destination: {
droppableId: destination.descriptor.id,
index: newIndex
}
}
};
}
export default function calculateReorderImpact({
draggable,
insideDestination,
destination,
viewport,
displacedBy,
last,
index,
forceShouldAnimate
}) {
const inHomeList = isHomeOf(draggable, destination);
// Go into last spot of list
if (index == null) {
return goAtEnd({
insideDestination,
inHomeList,
displacedBy,
destination
});
}
// this might be the dragging item
const match = find(insideDestination, (item) => item.descriptor.index === index);
if (!match) {
return goAtEnd({
insideDestination,
inHomeList,
displacedBy,
destination
});
}
const withoutDragging = removeDraggableFromList(draggable, insideDestination);
const sliceFrom = insideDestination.indexOf(match);
const impacted = withoutDragging.slice(sliceFrom);
const displaced = getDisplacementGroups({
afterDragging: impacted,
destination,
displacedBy,
last,
viewport: viewport.frame,
forceShouldAnimate
});
return {
displaced,
displacedBy,
at: {
type: "REORDER",
destination: {
droppableId: destination.descriptor.id,
index
}
}
};
}

View File

@@ -0,0 +1,28 @@
const canStartDrag = (state, id) => {
// Ready to go!
if (state.phase === "IDLE") {
return true;
}
// Can lift depending on the type of drop animation
if (state.phase !== "DROP_ANIMATING") {
return false;
}
// - For a user drop we allow the user to drag other Draggables
// immediately as items are most likely already in their home
// - For a cancel items will be moving back to their original position
// as such it is a cleaner experience to block them from dragging until
// the drop animation is complete. Otherwise they will be grabbing
// items not in their original position which can lead to bad visuals
// Not allowing dragging of the dropping draggable
if (state.completed.result.draggableId === id) {
return false;
}
// if dropping - allow lifting
// if cancelling - disallow lifting
return state.completed.result.reason === "DROP";
};
export default canStartDrag;

View File

@@ -0,0 +1,69 @@
import { configureStore } from "@reduxjs/toolkit";
import reducer from "./reducer";
import lift from "./middleware/lift";
import style from "./middleware/style";
import drop from "./middleware/drop/drop-middleware";
import scrollListener from "./middleware/scroll-listener";
import responders from "./middleware/responders/responders-middleware";
import dropAnimationFinish from "./middleware/drop/drop-animation-finish-middleware";
import dropAnimationFlushOnScroll from "./middleware/drop/drop-animation-flush-on-scroll-middleware";
import dimensionMarshalStopper from "./middleware/dimension-marshal-stopper";
import focus from "./middleware/focus";
import autoScroll from "./middleware/auto-scroll";
import pendingDrop from "./middleware/pending-drop";
const createBoardStore = ({ dimensionMarshal, focusMarshal, styleMarshal, getResponders, announce, autoScroller }) =>
configureStore({
reducer,
middleware: (getDefaultMiddleware) =>
//Note: No additional defaults seem required as per original source
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false,
actionCreatorCheck: false,
thunk: false
}).concat([
// ## Debug middleware
// > uncomment to use
// debugging logger
// require('../debug/middleware/log').default('light'),
// // user timing api
// require('../debug/middleware/user-timing').default,
// debugging timer
// require('../debug/middleware/action-timing').default,
// average action timer
// require('../debug/middleware/action-timing-average').default(200),
// ## Application middleware
// Style updates do not cause more actions. It is important to update styles
// before responders are called: specifically the onDragEnd responder. We need to clear
// the transition styles off the elements before a reorder to prevent strange
// post drag animations in firefox. Even though we clear the transition off
// a Draggable - if it is done after a reorder firefox will still apply the
// transition.
// Must be called before dimension marshal for lifting to apply collecting styles
style(styleMarshal),
// Stop the dimension marshal collecting anything
// when moving into a phase where collection is no longer needed.
// We need to stop the marshal before responders fire as responders can cause
// dimension registration changes in response to reordering
dimensionMarshalStopper(dimensionMarshal),
// Fire application responders in response to drag changes
lift(dimensionMarshal),
drop,
// When a drop animation finishes - fire a drop complete
dropAnimationFinish,
dropAnimationFlushOnScroll,
pendingDrop,
autoScroll(autoScroller),
scrollListener,
focus(focusMarshal),
// Fire responders for consumers (after update to store)
responders(getResponders, announce)
]),
devTools: import.meta.env.DEV
});
export default createBoardStore;

View File

@@ -0,0 +1,3 @@
export default function didStartAfterCritical(draggableId, afterCritical) {
return Boolean(afterCritical.effected[draggableId]);
}

View File

@@ -0,0 +1,153 @@
import { invariant } from "../../invariant";
import createPublisher from "./while-dragging-publisher";
import getInitialPublish from "./get-initial-publish";
import { warning } from "../../dev-warning";
function shouldPublishUpdate(registry, dragging, entry) {
// do not publish updates for the critical draggable
if (entry.descriptor.id === dragging.id) {
return false;
}
// do not publish updates for draggables that are not of a type that we care about
if (entry.descriptor.type !== dragging.type) {
return false;
}
const home = registry.droppable.getById(entry.descriptor.droppableId);
if (home.descriptor.mode !== "virtual") {
warning(`
You are attempting to add or remove a Draggable [id: ${entry.descriptor.id}]
while a drag is occurring. This is only supported for virtual lists.
See https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/patterns/virtual-lists.md
`);
return false;
}
return true;
}
const dimensionMarashal = (registry, callbacks) => {
let collection = null;
const publisher = createPublisher({
callbacks: {
publish: callbacks.publishWhileDragging,
collectionStarting: callbacks.collectionStarting
},
registry
});
const updateDroppableIsEnabled = (id, isEnabled) => {
invariant(
registry.droppable.exists(id),
`Cannot update is enabled flag of Droppable ${id} as it is not registered`
);
// no need to update the application state if a collection is not occurring
if (!collection) {
return;
}
// At this point a non primary droppable dimension might not yet be published
// but may have its enabled state changed. For now we still publish this change
// and let the reducer exit early if it cannot find the dimension in the state.
callbacks.updateDroppableIsEnabled({
id,
isEnabled
});
};
const updateDroppableIsCombineEnabled = (id, isCombineEnabled) => {
// no need to update
if (!collection) {
return;
}
invariant(
registry.droppable.exists(id),
`Cannot update isCombineEnabled flag of Droppable ${id} as it is not registered`
);
callbacks.updateDroppableIsCombineEnabled({
id,
isCombineEnabled
});
};
const updateDroppableScroll = (id, newScroll) => {
// no need to update the application state if a collection is not occurring
if (!collection) {
return;
}
invariant(registry.droppable.exists(id), `Cannot update the scroll on Droppable ${id} as it is not registered`);
callbacks.updateDroppableScroll({
id,
newScroll
});
};
const scrollDroppable = (id, change) => {
if (!collection) {
return;
}
registry.droppable.getById(id).callbacks.scroll(change);
};
const stopPublishing = () => {
// This function can be called defensively
if (!collection) {
return;
}
// Stop any pending dom collections or publish
publisher.stop();
// Tell all droppables to stop watching scroll
// all good if they where not already listening
const home = collection.critical.droppable;
registry.droppable.getAllByType(home.type).forEach((entry) => entry.callbacks.dragStopped());
// Unsubscribe from registry updates
collection.unsubscribe();
// Finally - clear our collection
collection = null;
};
const subscriber = (event) => {
invariant(collection, "Should only be subscribed when a collection is occurring");
// The dragging item can be add and removed when using a clone
// We do not publish updates for the critical item
const dragging = collection.critical.draggable;
if (event.type === "ADDITION") {
if (shouldPublishUpdate(registry, dragging, event.value)) {
publisher.add(event.value);
}
}
if (event.type === "REMOVAL") {
if (shouldPublishUpdate(registry, dragging, event.value)) {
publisher.remove(event.value);
}
}
};
const startPublishing = (request) => {
invariant(!collection, "Cannot start capturing critical dimensions as there is already a collection");
const entry = registry.draggable.getById(request.draggableId);
const home = registry.droppable.getById(entry.descriptor.droppableId);
const critical = {
draggable: entry.descriptor,
droppable: home.descriptor
};
const unsubscribe = registry.subscribe(subscriber);
collection = {
critical,
unsubscribe
};
return getInitialPublish({
critical,
registry,
scrollOptions: request.scrollOptions
});
};
const marshal = {
// Droppable changes
updateDroppableIsEnabled,
updateDroppableIsCombineEnabled,
scrollDroppable,
updateDroppableScroll,
// Entry
startPublishing,
stopPublishing
};
return marshal;
};
export default dimensionMarashal;

View File

@@ -0,0 +1,30 @@
import * as timings from "../../debug/timings";
import { toDraggableMap, toDroppableMap } from "../dimension-structures";
import getViewport from "../../view/window/get-viewport";
const getInitialPublish = ({ critical, scrollOptions, registry }) => {
const timingKey = "Initial collection from DOM";
timings.start(timingKey);
const viewport = getViewport();
const windowScroll = viewport.scroll.current;
const home = critical.droppable;
const droppables = registry.droppable
.getAllByType(home.type)
.map((entry) => entry.callbacks.getDimensionAndWatchScroll(windowScroll, scrollOptions));
const draggables = registry.draggable
.getAllByType(critical.draggable.type)
.map((entry) => entry.getDimension(windowScroll));
const dimensions = {
draggables: toDraggableMap(draggables),
droppables: toDroppableMap(droppables)
};
timings.finish(timingKey);
const result = {
dimensions,
critical,
viewport
};
return result;
};
export default getInitialPublish;

View File

@@ -0,0 +1,79 @@
import * as timings from "../../debug/timings";
import { origin } from "../position";
const clean = () => ({
additions: {},
removals: {},
modified: {}
});
const timingKey = "Publish collection from DOM";
export default function createPublisher({ registry, callbacks }) {
let staging = clean();
let frameId = null;
const collect = () => {
if (frameId) {
return;
}
callbacks.collectionStarting();
frameId = requestAnimationFrame(() => {
frameId = null;
timings.start(timingKey);
const { additions, removals, modified } = staging;
const added = Object.keys(additions)
.map(
// Using the origin as the window scroll. This will be adjusted when processing the published values
(id) => registry.draggable.getById(id).getDimension(origin)
)
// Dimensions are not guarenteed to be ordered in the same order as keys
// So we need to sort them so they are in the correct order
.sort((a, b) => a.descriptor.index - b.descriptor.index);
const updated = Object.keys(modified).map((id) => {
const entry = registry.droppable.getById(id);
const scroll = entry.callbacks.getScrollWhileDragging();
return {
droppableId: id,
scroll
};
});
const result = {
additions: added,
removals: Object.keys(removals),
modified: updated
};
staging = clean();
timings.finish(timingKey);
callbacks.publish(result);
});
};
const add = (entry) => {
const id = entry.descriptor.id;
staging.additions[id] = entry;
staging.modified[entry.descriptor.droppableId] = true;
if (staging.removals[id]) {
delete staging.removals[id];
}
collect();
};
const remove = (entry) => {
const descriptor = entry.descriptor;
staging.removals[descriptor.id] = true;
staging.modified[descriptor.droppableId] = true;
if (staging.additions[descriptor.id]) {
delete staging.additions[descriptor.id];
}
collect();
};
const stop = () => {
if (!frameId) {
return;
}
cancelAnimationFrame(frameId);
frameId = null;
staging = clean();
};
return {
add,
remove,
stop
};
}

View File

@@ -0,0 +1,17 @@
import memoizeOne from "memoize-one";
import { values } from "../native-with-fallback";
export const toDroppableMap = memoizeOne((droppables) =>
droppables.reduce((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {})
);
export const toDraggableMap = memoizeOne((draggables) =>
draggables.reduce((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {})
);
export const toDroppableList = memoizeOne((droppables) => values(droppables));
export const toDraggableList = memoizeOne((draggables) => values(draggables));

View File

@@ -0,0 +1,58 @@
import { grid, horizontal, vertical } from "../axis";
import { origin } from "../position";
import getMaxScroll from "../get-max-scroll";
import getSubject from "./util/get-subject";
const getDroppable = ({ descriptor, isEnabled, isCombineEnabled, isFixedOnPage, direction, client, page, closest }) => {
const frame = (() => {
if (!closest) {
return null;
}
const { scrollSize, client: frameClient } = closest;
// scrollHeight and scrollWidth are based on the padding box
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
const maxScroll = getMaxScroll({
scrollHeight: scrollSize.scrollHeight,
scrollWidth: scrollSize.scrollWidth,
height: frameClient.paddingBox.height,
width: frameClient.paddingBox.width
});
return {
pageMarginBox: closest.page.marginBox,
frameClient,
scrollSize,
shouldClipSubject: closest.shouldClipSubject,
scroll: {
initial: closest.scroll,
current: closest.scroll,
max: maxScroll,
diff: {
value: origin,
displacement: origin
}
}
};
})();
const axis = direction === "vertical" ? vertical : direction === "grid" ? grid : horizontal;
const subject = getSubject({
page,
withPlaceholder: null,
axis,
frame
});
const dimension = {
descriptor,
isCombineEnabled,
isFixedOnPage,
axis,
isEnabled,
client,
page,
frame,
subject
};
return dimension;
};
export default getDroppable;

View File

@@ -0,0 +1,3 @@
const isHomeOf = (draggable, destination) => draggable.descriptor.droppableId === destination.descriptor.id;
export default isHomeOf;

View File

@@ -0,0 +1,43 @@
import { invariant } from "../../invariant";
import { negate, subtract } from "../position";
import getSubject from "./util/get-subject";
const scrollDroppable = (droppable, newScroll) => {
invariant(droppable.frame);
const scrollable = droppable.frame;
const scrollDiff = subtract(newScroll, scrollable.scroll.initial);
// a positive scroll difference leads to a negative displacement
// (scrolling down pulls an item upwards)
const scrollDisplacement = negate(scrollDiff);
// Sometimes it is possible to scroll beyond the max point.
// This can occur when scrolling a foreign list that now has a placeholder.
const frame = {
...scrollable,
scroll: {
initial: scrollable.scroll.initial,
current: newScroll,
diff: {
value: scrollDiff,
displacement: scrollDisplacement
},
// TODO: rename 'softMax?'
max: scrollable.scroll.max
}
};
const subject = getSubject({
page: droppable.subject.page,
withPlaceholder: droppable.subject.withPlaceholder,
axis: droppable.axis,
frame
});
const result = {
...droppable,
frame,
subject
};
return result;
};
export default scrollDroppable;

View File

@@ -0,0 +1,4 @@
import whatIsDraggedOver from "./what-is-dragged-over";
// use placeholder if dragged over
export default (descriptor, impact) => whatIsDraggedOver(impact) === descriptor.droppableId;

View File

@@ -0,0 +1,16 @@
import { getRect } from "css-box-model";
const clip = (frame, subject) => {
const result = getRect({
top: Math.max(subject.top, frame.top),
right: Math.min(subject.right, frame.right),
bottom: Math.min(subject.bottom, frame.bottom),
left: Math.max(subject.left, frame.left)
});
if (result.width <= 0 || result.height <= 0) {
return null;
}
return result;
};
export default clip;

View File

@@ -0,0 +1,38 @@
import { getRect } from "css-box-model";
import executeClip from "./clip";
import { offsetByPosition } from "../../spacing";
const scroll = (target, frame) => {
if (!frame) {
return target;
}
return offsetByPosition(target, frame.scroll.diff.displacement);
};
const increase = (target, axis, withPlaceholder) => {
if (withPlaceholder && withPlaceholder.increasedBy) {
return {
...target,
[axis.end]: target[axis.end] + withPlaceholder.increasedBy[axis.line]
};
}
return target;
};
const clip = (target, frame) => {
if (frame && frame.shouldClipSubject) {
return executeClip(frame.pageMarginBox, target);
}
return getRect(target);
};
const getSubject = ({ page, withPlaceholder, axis, frame }) => {
const scrolled = scroll(page.marginBox, frame);
const increased = increase(scrolled, axis, withPlaceholder);
const clipped = clip(increased, frame);
return {
page,
withPlaceholder,
active: clipped
};
};
export default getSubject;

View File

@@ -0,0 +1,12 @@
const whatIsDraggedOverFromResult = (result) => {
const { combine, destination } = result;
if (destination) {
return destination.droppableId;
}
if (combine) {
return combine.droppableId;
}
return null;
};
export default whatIsDraggedOverFromResult;

View File

@@ -0,0 +1,12 @@
const whatIsDraggedOver = (impact) => {
const at = impact.at;
if (!at) {
return null;
}
if (at.type === "REORDER") {
return at.destination.droppableId;
}
return at.combine.droppableId;
};
export default whatIsDraggedOver;

View File

@@ -0,0 +1,107 @@
import { invariant } from "../../invariant";
import getDraggablesInsideDroppable from "../get-draggables-inside-droppable";
import { add, patch } from "../position";
import getSubject from "./util/get-subject";
import isHomeOf from "./is-home-of";
import getDisplacedBy from "../get-displaced-by";
const getRequiredGrowthForPlaceholder = (droppable, placeholderSize, draggables) => {
const axis = droppable.axis;
// A virtual list will most likely not contain all of the Draggables
// so counting them does not help.
if (droppable.descriptor.mode === "virtual") {
return patch(axis.line, placeholderSize[axis.line]);
}
// TODO: consider margin collapsing?
// Using contentBox as that is where the Draggables will sit
const availableSpace = droppable.subject.page.contentBox[axis.size];
const insideDroppable = getDraggablesInsideDroppable(droppable.descriptor.id, draggables);
const spaceUsed = insideDroppable.reduce((sum, dimension) => sum + dimension.client.marginBox[axis.size], 0);
const requiredSpace = spaceUsed + placeholderSize[axis.line];
const needsToGrowBy = requiredSpace - availableSpace;
// nothing to do here
if (needsToGrowBy <= 0) {
return null;
}
return patch(axis.line, needsToGrowBy);
};
const withMaxScroll = (frame, max) => ({
...frame,
scroll: {
...frame.scroll,
max
}
});
export const addPlaceholder = (droppable, draggable, draggables) => {
const frame = droppable.frame;
invariant(!isHomeOf(draggable, droppable), "Should not add placeholder space to home list");
invariant(!droppable.subject.withPlaceholder, "Cannot add placeholder size to a subject when it already has one");
const placeholderSize = getDisplacedBy(droppable.axis, draggable.displaceBy).point;
const requiredGrowth = getRequiredGrowthForPlaceholder(droppable, placeholderSize, draggables);
const added = {
placeholderSize,
increasedBy: requiredGrowth,
oldFrameMaxScroll: droppable.frame ? droppable.frame.scroll.max : null
};
if (!frame) {
const subject = getSubject({
page: droppable.subject.page,
withPlaceholder: added,
axis: droppable.axis,
frame: droppable.frame
});
return {
...droppable,
subject
};
}
const maxScroll = requiredGrowth ? add(frame.scroll.max, requiredGrowth) : frame.scroll.max;
const newFrame = withMaxScroll(frame, maxScroll);
const subject = getSubject({
page: droppable.subject.page,
withPlaceholder: added,
axis: droppable.axis,
frame: newFrame
});
return {
...droppable,
subject,
frame: newFrame
};
};
export const removePlaceholder = (droppable) => {
const added = droppable.subject.withPlaceholder;
invariant(added, "Cannot remove placeholder form subject when there was none");
const frame = droppable.frame;
if (!frame) {
const subject = getSubject({
page: droppable.subject.page,
axis: droppable.axis,
frame: null,
// cleared
withPlaceholder: null
});
return {
...droppable,
subject
};
}
const oldMaxScroll = added.oldFrameMaxScroll;
invariant(oldMaxScroll, "Expected droppable with frame to have old max frame scroll when removing placeholder");
const newFrame = withMaxScroll(frame, oldMaxScroll);
const subject = getSubject({
page: droppable.subject.page,
axis: droppable.axis,
frame: newFrame,
// cleared
withPlaceholder: null
});
return {
...droppable,
subject,
frame: newFrame
};
};

View File

@@ -0,0 +1,10 @@
import { add, subtract } from "../../position";
import withViewportDisplacement from "../../with-scroll-change/with-viewport-displacement";
const getClientRectFromPageBorderBoxCenter = ({ pageBorderBoxCenter, draggable, viewport }) => {
const withoutPageScrollChange = withViewportDisplacement(viewport, pageBorderBoxCenter);
const offset = subtract(withoutPageScrollChange, draggable.page.borderBox.center);
return add(draggable.client.borderBox.center, offset);
};
export default getClientRectFromPageBorderBoxCenter;

View File

@@ -0,0 +1,19 @@
import getPageBorderBoxCenterFromImpact from "../get-page-border-box-center";
import getClientFromPageBorderBoxCenter from "./get-client-from-page-border-box-center";
const getClientBorderBoxCenter = ({ impact, draggable, droppable, draggables, viewport, afterCritical }) => {
const pageBorderBoxCenter = getPageBorderBoxCenterFromImpact({
impact,
draggable,
draggables,
droppable,
afterCritical
});
return getClientFromPageBorderBoxCenter({
pageBorderBoxCenter,
draggable,
viewport
});
};
export default getClientBorderBoxCenter;

View File

@@ -0,0 +1,37 @@
import whenCombining from "./when-combining";
import whenReordering from "./when-reordering";
import withDroppableDisplacement from "../../with-scroll-change/with-droppable-displacement";
const getResultWithoutDroppableDisplacement = ({ impact, draggable, droppable, draggables, afterCritical }) => {
const original = draggable.page.borderBox.center;
const at = impact.at;
if (!droppable) {
return original;
}
if (!at) {
return original;
}
if (at.type === "REORDER") {
return whenReordering({
impact,
draggable,
draggables,
droppable,
afterCritical
});
}
return whenCombining({
impact,
draggables,
afterCritical
});
};
const getPageBorderBoxCenter = (args) => {
const withoutDisplacement = getResultWithoutDroppableDisplacement(args);
const droppable = args.droppable;
return droppable ? withDroppableDisplacement(droppable, withoutDisplacement) : withoutDisplacement;
};
export default getPageBorderBoxCenter;

View File

@@ -0,0 +1,22 @@
import { invariant } from "../../../invariant";
import { add } from "../../position";
import getCombinedItemDisplacement from "../../get-combined-item-displacement";
import { tryGetCombine } from "../../get-impact-location";
const whenCombining = ({ afterCritical, impact, draggables }) => {
const combine = tryGetCombine(impact);
invariant(combine);
const combineWith = combine.draggableId;
const center = draggables[combineWith].page.borderBox.center;
const displaceBy = getCombinedItemDisplacement({
displaced: impact.displaced,
afterCritical,
combineWith,
displacedBy: impact.displacedBy
});
return add(center, displaceBy);
};
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default whenCombining;

View File

@@ -0,0 +1,79 @@
import { offset } from "css-box-model";
import { goBefore, goAfter, goIntoStart } from "../move-relative-to";
import getDraggablesInsideDroppable from "../../get-draggables-inside-droppable";
import { negate } from "../../position";
import didStartAfterCritical from "../../did-start-after-critical";
const whenReordering = ({ impact, draggable, draggables, droppable, afterCritical }) => {
const insideDestination = getDraggablesInsideDroppable(droppable.descriptor.id, draggables);
const draggablePage = draggable.page;
const axis = droppable.axis;
// this will only happen in a foreign list
if (!insideDestination.length) {
return goIntoStart({
axis,
moveInto: droppable.page,
isMoving: draggablePage
});
}
const { displaced, displacedBy } = impact;
const closestAfter = displaced.all[0];
// go before the first displaced item
// items can only be displaced forwards
if (closestAfter) {
const closest = draggables[closestAfter];
// want to go before where it would be with the displacement
// target is displaced and is already in it's starting position
if (didStartAfterCritical(closestAfter, afterCritical)) {
return goBefore({
axis,
moveRelativeTo: closest.page,
isMoving: draggablePage
});
}
// target has been displaced during the drag and it is not in its starting position
// we need to account for the displacement
const withDisplacement = offset(closest.page, displacedBy.point);
return goBefore({
axis,
moveRelativeTo: withDisplacement,
isMoving: draggablePage
});
}
// Nothing in list is displaced, we should go after the last item
const last = insideDestination[insideDestination.length - 1];
// we can just go into our original position if the last item
// is the dragging item
if (last.descriptor.id === draggable.descriptor.id) {
return draggablePage.borderBox.center;
}
if (didStartAfterCritical(last.descriptor.id, afterCritical)) {
// if the item started displaced and it is no longer displaced then
// we need to go after it it's non-displaced position
const page = offset(last.page, negate(afterCritical.displacedBy.point));
return goAfter({
axis,
moveRelativeTo: page,
isMoving: draggablePage
});
}
// item is in its resting spot. we can go straight after it
return goAfter({
axis,
moveRelativeTo: last.page,
isMoving: draggablePage
});
};
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default whenReordering;

View File

@@ -0,0 +1,31 @@
import { patch } from "../position";
const distanceFromStartToBorderBoxCenter = (axis, box) => box.margin[axis.start] + box.borderBox[axis.size] / 2;
const distanceFromEndToBorderBoxCenter = (axis, box) => box.margin[axis.end] + box.borderBox[axis.size] / 2;
// We align the moving item against the cross axis start of the target
// We used to align the moving item cross axis center with the cross axis center of the target.
// However, this leads to a bad experience when reordering columns
const getCrossAxisBorderBoxCenter = (axis, target, isMoving) =>
target[axis.crossAxisStart] + isMoving.margin[axis.crossAxisStart] + isMoving.borderBox[axis.crossAxisSize] / 2;
export const goAfter = ({ axis, moveRelativeTo, isMoving }) =>
patch(
axis.line,
// start measuring from the end of the target
moveRelativeTo.marginBox[axis.end] + distanceFromStartToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveRelativeTo.marginBox, isMoving)
);
export const goBefore = ({ axis, moveRelativeTo, isMoving }) =>
patch(
axis.line,
// start measuring from the start of the target
moveRelativeTo.marginBox[axis.start] - distanceFromEndToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveRelativeTo.marginBox, isMoving)
);
// moves into the content box
export const goIntoStart = ({ axis, moveInto, isMoving }) =>
patch(
axis.line,
moveInto.contentBox[axis.start] + distanceFromStartToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveInto.contentBox, isMoving)
);

View File

@@ -0,0 +1,12 @@
import { negate, origin } from "./position";
import didStartAfterCritical from "./did-start-after-critical";
const getCombinedItemDisplacement = ({ displaced, afterCritical, combineWith, displacedBy }) => {
const isDisplaced = Boolean(displaced.visible[combineWith] || displaced.invisible[combineWith]);
if (didStartAfterCritical(combineWith, afterCritical)) {
return isDisplaced ? origin : negate(displacedBy.point);
}
return isDisplaced ? displacedBy.point : origin;
};
export default getCombinedItemDisplacement;

View File

@@ -0,0 +1,11 @@
import memoizeOne from "memoize-one";
import { patch } from "./position";
// TODO: memoization needed?
export default memoizeOne(function getDisplacedBy(axis, displaceBy) {
const displacement = displaceBy[axis.line];
return {
value: displacement,
point: patch(axis.line, displacement)
};
});

View File

@@ -0,0 +1,89 @@
import { expand, getRect } from "css-box-model";
import { isPartiallyVisible } from "./visibility/is-visible";
const getShouldAnimate = (id, last, forceShouldAnimate) => {
// Use a forced value if provided
if (typeof forceShouldAnimate === "boolean") {
return forceShouldAnimate;
}
// nothing to gauge animation from
if (!last) {
return true;
}
const { invisible, visible } = last;
// it was previously invisible - no animation
if (invisible[id]) {
return false;
}
const previous = visible[id];
return previous ? previous.shouldAnimate : true;
};
// Note: it is also an optimisation to not render the displacement on
// items when they are not longer visible.
// This prevents a lot of .render() calls when leaving / entering a list
function getTarget(draggable, displacedBy) {
const marginBox = draggable.page.marginBox;
// ## Visibility overscanning
// We are expanding rather than offsetting the marginBox.
// In some cases we want
// - the target based on the starting position (such as when dropping outside of any list)
// - the target based on the items position without starting displacement (such as when moving inside a list)
// To keep things simple we just expand the whole area for this check
// The worst case is some minor redundant offscreen movements
const expandBy = {
// pull backwards into viewport
top: displacedBy.point.y,
right: 0,
bottom: 0,
// pull backwards into viewport
left: displacedBy.point.x
};
return getRect(expand(marginBox, expandBy));
}
export default function getDisplacementGroups({
afterDragging,
destination,
displacedBy,
viewport,
forceShouldAnimate,
last
}) {
return afterDragging.reduce(
function process(groups, draggable) {
const target = getTarget(draggable, displacedBy);
const id = draggable.descriptor.id;
groups.all.push(id);
const isVisible = isPartiallyVisible({
target,
destination,
viewport,
withDroppableDisplacement: true
});
if (!isVisible) {
groups.invisible[draggable.descriptor.id] = true;
return groups;
}
// item is visible
const shouldAnimate = getShouldAnimate(id, last, forceShouldAnimate);
const displacement = {
draggableId: id,
shouldAnimate
};
groups.visible[id] = displacement;
return groups;
},
{
all: [],
visible: {},
invisible: {}
}
);
}

View File

@@ -0,0 +1,87 @@
import { find } from "../../native-with-fallback";
import getDidStartAfterCritical from "../did-start-after-critical";
import getDisplacedBy from "../get-displaced-by";
import getIsDisplaced from "../get-is-displaced";
import removeDraggableFromList from "../remove-draggable-from-list";
// exported for testing
export const combineThresholdDivisor = 4;
const getCombineImpact = ({
draggable,
pageBorderBoxWithDroppableScroll: targetRect,
previousImpact,
destination,
insideDestination,
afterCritical
}) => {
if (!destination.isCombineEnabled) {
return null;
}
const axis = destination.axis;
const displacedBy = getDisplacedBy(destination.axis, draggable.displaceBy);
const displacement = displacedBy.value;
const targetStart = targetRect[axis.start];
const targetEnd = targetRect[axis.end];
const withoutDragging = removeDraggableFromList(draggable, insideDestination);
const combineWith = find(withoutDragging, (child) => {
const id = child.descriptor.id;
const childRect = child.page.borderBox;
const childSize = childRect[axis.size];
const threshold = childSize / combineThresholdDivisor;
const didStartAfterCritical = getDidStartAfterCritical(id, afterCritical);
const isDisplaced = getIsDisplaced({
displaced: previousImpact.displaced,
id
});
/*
Only combining when in the combine region
As soon as a boundary is hit then no longer combining
*/
if (didStartAfterCritical) {
// In original position
// Will combine with item when inside a band
if (isDisplaced) {
return targetEnd > childRect[axis.start] + threshold && targetEnd < childRect[axis.end] - threshold;
}
// child is now 'displaced' backwards from where it started
// want to combine when we move backwards onto it
return (
targetStart > childRect[axis.start] - displacement + threshold &&
targetStart < childRect[axis.end] - displacement - threshold
);
}
// item has moved forwards
if (isDisplaced) {
return (
targetEnd > childRect[axis.start] + displacement + threshold &&
targetEnd < childRect[axis.end] + displacement - threshold
);
}
// is in resting position - being moved backwards on to
return targetStart > childRect[axis.start] + threshold && targetStart < childRect[axis.end] - threshold;
});
if (!combineWith) {
return null;
}
return {
// no change to displacement when combining
displacedBy,
displaced: previousImpact.displaced,
at: {
type: "COMBINE",
combine: {
draggableId: combineWith.descriptor.id,
droppableId: destination.descriptor.id
}
}
};
};
export default getCombineImpact;

View File

@@ -0,0 +1,104 @@
import getDisplacedBy from "../get-displaced-by";
import removeDraggableFromList from "../remove-draggable-from-list";
import isHomeOf from "../droppable/is-home-of";
import { find } from "../../native-with-fallback";
import getDidStartAfterCritical from "../did-start-after-critical";
import calculateReorderImpact from "../calculate-drag-impact/calculate-reorder-impact";
import getIsDisplaced from "../get-is-displaced";
import { horizontal, vertical } from "../axis";
function atIndex({ draggable, closest, inHomeList }) {
if (!closest) {
return null;
}
if (!inHomeList) {
return closest.descriptor.index;
}
if (closest.descriptor.index > draggable.descriptor.index) {
return closest.descriptor.index - 1;
}
return closest.descriptor.index;
}
const getReorderImpact = ({
pageBorderBoxWithDroppableScroll: targetRect,
draggable,
destination,
insideDestination,
last,
viewport,
afterCritical
}) => {
const axis = destination.axis;
const displacedBy = getDisplacedBy(destination.axis, draggable.displaceBy);
const displacement = displacedBy.value;
const targetStart = targetRect[axis.start];
const targetEnd = targetRect[axis.end];
const uprightAxis = axis.direction === "horizontal" ? vertical : horizontal;
const uprightAxisBoundStart = targetRect[uprightAxis.start];
const withoutDragging = removeDraggableFromList(draggable, insideDestination);
const closest = find(withoutDragging, (child) => {
const id = child.descriptor.id;
const childCenter = child.page.borderBox.center[axis.line];
if (axis.grid) {
const uprightChildCenter = child.page.borderBox.center[uprightAxis.line];
if (!(uprightAxisBoundStart < uprightChildCenter)) return false;
}
const didStartAfterCritical = getDidStartAfterCritical(id, afterCritical);
const isDisplaced = getIsDisplaced({
displaced: last,
id
});
/*
Note: we change things when moving *past* the child center - not when it hits the center
If we make it when we *hit* the child center then there can be
a hit on the next update causing a flicker.
- Update 1: targetBottom hits center => displace backwards
- Update 2: targetStart is now hitting the displaced center => displace forwards
- Update 3: goto 1 (boom)
*/
if (didStartAfterCritical) {
// Continue to displace while targetEnd before the childCenter
// Move once we *move forward past* the childCenter
if (isDisplaced) {
return targetEnd <= childCenter;
}
// Has been moved backwards from where it started
// Displace forwards when targetStart *moves backwards past* the displaced childCenter
return targetStart < childCenter - displacement;
}
// Item has been shifted forward.
// Remove displacement when targetEnd moves forward past the displaced center
if (isDisplaced) {
return targetEnd <= childCenter + displacement;
}
// Item is behind the dragging item
// We want to displace it if the targetStart goes *backwards past* the childCenter
return targetStart < childCenter;
});
const newIndex = atIndex({
draggable,
closest,
inHomeList: isHomeOf(draggable, destination)
});
// TODO: index cannot be null?
// otherwise return null from there and return empty impact
// that was calculate reorder impact does not need to account for a null index
return calculateReorderImpact({
draggable,
insideDestination,
destination,
viewport,
last,
displacedBy,
index: newIndex
});
};
export default getReorderImpact;

View File

@@ -0,0 +1,53 @@
import getDroppableOver from "../get-droppable-over";
import getDraggablesInsideDroppable from "../get-draggables-inside-droppable";
import withDroppableScroll from "../with-scroll-change/with-droppable-scroll";
import getReorderImpact from "./get-reorder-impact";
import getCombineImpact from "./get-combine-impact";
import noImpact from "../no-impact";
import { offsetRectByPosition } from "../rect";
const getDragImpact = ({ pageOffset, draggable, draggables, droppables, previousImpact, viewport, afterCritical }) => {
const pageBorderBox = offsetRectByPosition(draggable.page.borderBox, pageOffset);
const destinationId = getDroppableOver({
pageBorderBox,
draggable,
droppables
});
// not dragging over anything
if (!destinationId) {
// A big design decision was made here to collapse the home list
// when not over any list. This yielded the most consistently beautiful experience.
return noImpact;
}
const destination = droppables[destinationId];
const insideDestination = getDraggablesInsideDroppable(destination.descriptor.id, draggables);
// Where the element actually is now.
// Need to take into account the change of scroll in the droppable
const pageBorderBoxWithDroppableScroll = withDroppableScroll(destination, pageBorderBox);
// checking combine first so we combine before any reordering
return (
getCombineImpact({
pageBorderBoxWithDroppableScroll,
draggable,
previousImpact,
destination,
insideDestination,
afterCritical
}) ||
getReorderImpact({
pageBorderBoxWithDroppableScroll,
draggable,
destination,
insideDestination,
last: previousImpact.displaced,
viewport,
afterCritical
})
);
};
export default getDragImpact;

View File

@@ -0,0 +1,11 @@
import memoizeOne from "memoize-one";
import { toDraggableList } from "./dimension-structures";
export default memoizeOne((droppableId, draggables) => {
const result = toDraggableList(draggables)
.filter((draggable) => droppableId === draggable.descriptor.droppableId)
// Dimensions are not guarenteed to be ordered in the same order as keys
// So we need to sort them so they are in the correct order
.sort((a, b) => a.descriptor.index - b.descriptor.index);
return result;
});

View File

@@ -0,0 +1,111 @@
import { toDroppableList } from "./dimension-structures";
import isPositionInFrame from "./visibility/is-position-in-frame";
import { distance, patch } from "./position";
import isWithin from "./is-within";
// https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
// https://silentmatt.com/rectangle-intersection/
function getHasOverlap(first, second) {
return (
first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top
);
}
function getFurthestAway({ pageBorderBox, draggable, candidates }) {
// We are not comparing the center of the home list with the target list as it would
// give preference to giant lists
// We are measuring the distance from where the draggable started
// to where it is *hitting* the candidate
// Note: The hit point might technically not be in the bounds of the candidate
const startCenter = draggable.page.borderBox.center;
const sorted = candidates
.map((candidate) => {
const axis = candidate.axis;
const target = patch(
candidate.axis.line,
// use the current center of the dragging item on the main axis
pageBorderBox.center[axis.line],
// use the center of the list on the cross axis
candidate.page.borderBox.center[axis.crossAxisLine]
);
return {
id: candidate.descriptor.id,
distance: distance(startCenter, target)
};
})
// largest value will be first
.sort((a, b) => b.distance - a.distance);
// just being safe
return sorted[0] ? sorted[0].id : null;
}
export default function getDroppableOver({ pageBorderBox, draggable, droppables }) {
// We know at this point that some overlap has to exist
const candidates = toDroppableList(droppables).filter((item) => {
// Cannot be a candidate when disabled
if (!item.isEnabled) {
return false;
}
// Cannot be a candidate when there is no visible area
const active = item.subject.active;
if (!active) {
return false;
}
// Cannot be a candidate when dragging item is not over the droppable at all
if (!getHasOverlap(pageBorderBox, active)) {
return false;
}
// 1. Candidate if the center position is over a droppable
if (isPositionInFrame(active)(pageBorderBox.center)) {
return true;
}
// 2. Candidate if an edge is over the cross axis half way point
// 3. Candidate if dragging item is totally over droppable on cross axis
const axis = item.axis;
const childCenter = active.center[axis.crossAxisLine];
const crossAxisStart = pageBorderBox[axis.crossAxisStart];
const crossAxisEnd = pageBorderBox[axis.crossAxisEnd];
const isContained = isWithin(active[axis.crossAxisStart], active[axis.crossAxisEnd]);
const isStartContained = isContained(crossAxisStart);
const isEndContained = isContained(crossAxisEnd);
// Dragging item is totally covering the active area
if (!isStartContained && !isEndContained) {
return true;
}
/**
* edges must go beyond the center line in order to avoid
* cases were both conditions are satisfied.
*/
if (isStartContained) {
return crossAxisStart < childCenter;
}
return crossAxisEnd > childCenter;
});
if (!candidates.length) {
return null;
}
// Only one candidate - use that!
if (candidates.length === 1) {
return candidates[0].descriptor.id;
}
// Multiple options returned
// Should only occur with really large items
// Going to use fallback: distance from home
return getFurthestAway({
pageBorderBox,
draggable,
candidates
});
}

View File

@@ -0,0 +1,9 @@
import { invariant } from "../invariant";
const getFrame = (droppable) => {
const frame = droppable.frame;
invariant(frame, "Expected Droppable to have a frame");
return frame;
};
export default getFrame;

View File

@@ -0,0 +1,6 @@
const getHomeLocation = (descriptor) => ({
index: descriptor.index,
droppableId: descriptor.droppableId
});
export default getHomeLocation;

View File

@@ -0,0 +1,13 @@
export function tryGetDestination(impact) {
if (impact.at && impact.at.type === "REORDER") {
return impact.at.destination;
}
return null;
}
export function tryGetCombine(impact) {
if (impact.at && impact.at.type === "COMBINE") {
return impact.at.combine;
}
return null;
}

View File

@@ -0,0 +1,3 @@
export default function getIsDisplaced({ displaced, id }) {
return Boolean(displaced.visible[id] || displaced.invisible[id]);
}

View File

@@ -0,0 +1,50 @@
import { invariant } from "../invariant";
import getHomeLocation from "./get-home-location";
import getDraggablesInsideDroppable from "./get-draggables-inside-droppable";
import getDisplacedBy from "./get-displaced-by";
import getDisplacementGroups from "./get-displacement-groups";
const getLiftEffect = ({ draggable, home, draggables, viewport }) => {
const displacedBy = getDisplacedBy(home.axis, draggable.displaceBy);
const insideHome = getDraggablesInsideDroppable(home.descriptor.id, draggables);
// in a list that does not start at 0 the descriptor.index might be different from the index in the list
// eg a list could be: [2,3,4]. A descriptor.index of '2' would actually be in index '0' of the list
const rawIndex = insideHome.indexOf(draggable);
invariant(rawIndex !== -1, "Expected draggable to be inside home list");
const afterDragging = insideHome.slice(rawIndex + 1);
const effected = afterDragging.reduce((previous, item) => {
previous[item.descriptor.id] = true;
return previous;
}, {});
const afterCritical = {
inVirtualList: home.descriptor.mode === "virtual",
displacedBy,
effected
};
const displaced = getDisplacementGroups({
afterDragging,
destination: home,
displacedBy,
last: null,
viewport: viewport.frame,
// originally we do not want any animation as we want
// everything to be fixed in the same position that
// it started in
forceShouldAnimate: false
});
const impact = {
displaced,
displacedBy,
at: {
type: "REORDER",
destination: getHomeLocation(draggable.descriptor)
}
};
return {
impact,
afterCritical
};
};
export default getLiftEffect;

View File

@@ -0,0 +1,23 @@
import { subtract } from "./position";
const getMaxScroll = ({ scrollHeight, scrollWidth, height, width }) => {
const maxScroll = subtract(
// full size
{
x: scrollWidth,
y: scrollHeight
},
// viewport size
{
x: width,
y: height
}
);
const adjustedMaxScroll = {
x: Math.max(0, maxScroll.x),
y: Math.max(0, maxScroll.y)
};
return adjustedMaxScroll;
};
export default getMaxScroll;

View File

@@ -0,0 +1,4 @@
// Using function declaration as arrow function does not play well with the %checks syntax
export default function isMovementAllowed(state) {
return state.phase === "DRAGGING" || state.phase === "COLLECTING";
}

View File

@@ -0,0 +1,4 @@
const isWithin = (lowerBound, upperBound) => (value) => lowerBound <= value && value <= upperBound;
// is a value between two other values
export default isWithin;

View File

@@ -0,0 +1,27 @@
import { invariant } from "../../invariant";
const shouldStop = (action) =>
action.type === "DROP_COMPLETE" || action.type === "DROP_ANIMATE" || action.type === "FLUSH";
const autoScroll = (autoScroller) => (store) => (next) => (action) => {
if (shouldStop(action)) {
autoScroller.stop();
next(action);
return;
}
if (action.type === "INITIAL_PUBLISH") {
// letting the action go first to hydrate the state
next(action);
const state = store.getState();
invariant(state.phase === "DRAGGING", "Expected phase to be DRAGGING after INITIAL_PUBLISH");
autoScroller.start(state);
return;
}
// auto scroll happens in response to state changes
// releasing all actions to the reducer first
next(action);
autoScroller.scroll(store.getState());
};
export default autoScroll;

View File

@@ -0,0 +1,15 @@
const dimensionMarshalStopper = (marshal) => () => (next) => (action) => {
// Not stopping a collection on a 'DROP' as we want a collection to continue
if (
// drag is finished
action.type === "DROP_COMPLETE" ||
action.type === "FLUSH" ||
// no longer accepting changes once the drop has started
action.type === "DROP_ANIMATE"
) {
marshal.stopPublishing();
}
next(action);
};
export default dimensionMarshalStopper;

View File

@@ -0,0 +1,18 @@
import { invariant } from "../../../invariant";
import { completeDrop } from "../../action-creators";
const dropAnimiationFinishMiddleware = (store) => (next) => (action) => {
if (action.type !== "DROP_ANIMATION_FINISHED") {
next(action);
return;
}
const state = store.getState();
invariant(state.phase === "DROP_ANIMATING", "Cannot finish a drop animating when no drop is occurring");
store.dispatch(
completeDrop({
completed: state.completed
})
);
};
export default dropAnimiationFinishMiddleware;

View File

@@ -0,0 +1,57 @@
import { dropAnimationFinished } from "../../action-creators";
import bindEvents from "../../../view/event-bindings/bind-events";
const dropAnimationFlushOnScrollMiddleware = (store) => {
let unbind = null;
let frameId = null;
function clear() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
if (unbind) {
unbind();
unbind = null;
}
}
return (next) => (action) => {
if (action.type === "FLUSH" || action.type === "DROP_COMPLETE" || action.type === "DROP_ANIMATION_FINISHED") {
clear();
}
next(action);
if (action.type !== "DROP_ANIMATE") {
return;
}
const binding = {
eventName: "scroll",
// capture: true will catch all scroll events, event from scroll containers
// once: just in case, we only want to ever fire one
options: {
capture: true,
passive: false,
once: true
},
fn: function flushDropAnimation() {
const state = store.getState();
if (state.phase === "DROP_ANIMATING") {
store.dispatch(dropAnimationFinished());
}
}
};
// The browser can batch a few scroll events in a single frame
// including the one that ended the drag.
// Binding after a requestAnimationFrame ensures that any scrolls caused
// by the auto scroller are finished
// TODO: why is a second window scroll being fired?
// It leads to funny drop positions :(
frameId = requestAnimationFrame(() => {
frameId = null;
unbind = bindEvents(window, [binding]);
});
};
};
export default dropAnimationFlushOnScrollMiddleware;

View File

@@ -0,0 +1,115 @@
import { invariant } from "../../../invariant";
import { animateDrop, completeDrop, dropPending } from "../../action-creators";
import { isEqual } from "../../position";
import getDropDuration from "./get-drop-duration";
import getNewHomeClientOffset from "./get-new-home-client-offset";
import getDropImpact from "./get-drop-impact";
import { tryGetCombine, tryGetDestination } from "../../get-impact-location";
const dropMiddleware =
({ getState, dispatch }) =>
(next) =>
(action) => {
if (action.type !== "DROP") {
next(action);
return;
}
const state = getState();
const reason = action.payload.reason;
// Still waiting for a bulk collection to publish
// We are now shifting the application into the 'DROP_PENDING' phase
if (state.phase === "COLLECTING") {
dispatch(
dropPending({
reason
})
);
return;
}
// Could have occurred in response to an error
if (state.phase === "IDLE") {
return;
}
// Still waiting for our drop pending to end
// TODO: should this throw?
const isWaitingForDrop = state.phase === "DROP_PENDING" && state.isWaiting;
invariant(!isWaitingForDrop, "A DROP action occurred while DROP_PENDING and still waiting");
invariant(state.phase === "DRAGGING" || state.phase === "DROP_PENDING", `Cannot drop in phase: ${state.phase}`);
// We are now in the DRAGGING or DROP_PENDING phase
const critical = state.critical;
const dimensions = state.dimensions;
const draggable = dimensions.draggables[state.critical.draggable.id];
// Only keeping impact when doing a user drop - otherwise we are cancelling
const { impact, didDropInsideDroppable } = getDropImpact({
reason,
lastImpact: state.impact,
afterCritical: state.afterCritical,
onLiftImpact: state.onLiftImpact,
home: state.dimensions.droppables[state.critical.droppable.id],
viewport: state.viewport,
draggables: state.dimensions.draggables
});
// only populating destination / combine if 'didDropInsideDroppable' is true
const destination = didDropInsideDroppable ? tryGetDestination(impact) : null;
const combine = didDropInsideDroppable ? tryGetCombine(impact) : null;
const source = {
index: critical.draggable.index,
droppableId: critical.droppable.id
};
const result = {
draggableId: draggable.descriptor.id,
type: draggable.descriptor.type,
source,
reason,
mode: state.movementMode,
// destination / combine will be null if didDropInsideDroppable is true
destination,
combine
};
const newHomeClientOffset = getNewHomeClientOffset({
impact,
draggable,
dimensions,
viewport: state.viewport,
afterCritical: state.afterCritical
});
const completed = {
critical: state.critical,
afterCritical: state.afterCritical,
result,
impact
};
const isAnimationRequired =
// 1. not already in the right spot
!isEqual(state.current.client.offset, newHomeClientOffset) ||
// 2. doing a combine (we still want to animate the scale and opacity fade)
// looking at the result and not the impact as the combine impact is cleared
Boolean(result.combine);
if (!isAnimationRequired) {
dispatch(
completeDrop({
completed
})
);
return;
}
const dropDuration = getDropDuration({
current: state.current.client.offset,
destination: newHomeClientOffset,
reason
});
const args = {
newHomeClientOffset,
dropDuration,
completed
};
dispatch(animateDrop(args));
};
export default dropMiddleware;

View File

@@ -0,0 +1,32 @@
import { distance as getDistance } from "../../position";
import { timings } from "../../../animation";
const { minDropTime, maxDropTime } = timings;
const dropTimeRange = maxDropTime - minDropTime;
const maxDropTimeAtDistance = 1500;
// will bring a time lower - which makes it faster
const cancelDropModifier = 0.6;
const getDropDuration = ({ current, destination, reason }) => {
const distance = getDistance(current, destination);
// even if there is no distance to travel, we might still need to animate opacity
if (distance <= 0) {
return minDropTime;
}
if (distance >= maxDropTimeAtDistance) {
return maxDropTime;
}
// * range from:
// 0px = 0.33s
// 1500px and over = 0.55s
// * If reason === 'CANCEL' then speeding up the animation
// * round to 2 decimal points
const percentage = distance / maxDropTimeAtDistance;
const duration = minDropTime + dropTimeRange * percentage;
const withDuration = reason === "CANCEL" ? duration * cancelDropModifier : duration;
// To two decimal points by converting to string and back
return Number(withDuration.toFixed(2));
};
export default getDropDuration;

View File

@@ -0,0 +1,46 @@
import recompute from "../../update-displacement-visibility/recompute";
import { emptyGroups } from "../../no-impact";
const getDropImpact = ({ draggables, reason, lastImpact, home, viewport, onLiftImpact }) => {
if (!lastImpact.at || reason !== "DROP") {
// Dropping outside of a list or the drag was cancelled
// Going to use the on lift impact
// Need to recompute the visibility of the original impact
// What is visible can be different to when the drag started
const recomputedHomeImpact = recompute({
draggables,
impact: onLiftImpact,
destination: home,
viewport,
// We need the draggables to animate back to their positions
forceShouldAnimate: true
});
return {
impact: recomputedHomeImpact,
didDropInsideDroppable: false
};
}
// use the existing impact
if (lastImpact.at.type === "REORDER") {
return {
impact: lastImpact,
didDropInsideDroppable: true
};
}
// When merging we remove the movement so that everything
// will animate closed
const withoutMovement = {
...lastImpact,
displaced: emptyGroups
};
return {
impact: withoutMovement,
didDropInsideDroppable: true
};
};
export default getDropImpact;

View File

@@ -0,0 +1,23 @@
import whatIsDraggedOver from "../../droppable/what-is-dragged-over";
import { subtract } from "../../position";
import getClientBorderBoxCenter from "../../get-center-from-impact/get-client-border-box-center";
const getNewHomeClientOffset = ({ impact, draggable, dimensions, viewport, afterCritical }) => {
const { draggables, droppables } = dimensions;
const droppableId = whatIsDraggedOver(impact);
const destination = droppableId ? droppables[droppableId] : null;
const home = droppables[draggable.descriptor.droppableId];
const newClientCenter = getClientBorderBoxCenter({
impact,
draggable,
draggables,
// if there is no destination, then we will be dropping back into the home
afterCritical,
droppable: destination || home,
viewport
});
const offset = subtract(newClientCenter, draggable.client.borderBox.center);
return offset;
};
export default getNewHomeClientOffset;

View File

@@ -0,0 +1 @@
export { default } from "./drop-middleware";

View File

@@ -0,0 +1,33 @@
const focus = (marshal) => {
let isWatching = false;
return () => (next) => (action) => {
if (action.type === "INITIAL_PUBLISH") {
isWatching = true;
marshal.tryRecordFocus(action.payload.critical.draggable.id);
next(action);
marshal.tryRestoreFocusRecorded();
return;
}
next(action);
if (!isWatching) {
return;
}
if (action.type === "FLUSH") {
isWatching = false;
marshal.tryRestoreFocusRecorded();
return;
}
if (action.type === "DROP_COMPLETE") {
isWatching = false;
const result = action.payload.completed.result;
// give focus to the combine target when combining
if (result.combine) {
marshal.tryShiftRecord(result.draggableId, result.combine.draggableId);
}
marshal.tryRestoreFocusRecorded();
}
};
};
export default focus;

View File

@@ -0,0 +1,67 @@
import { invariant } from "../../invariant";
import { beforeInitialCapture, completeDrop, flush, initialPublish } from "../action-creators";
import validateDimensions from "./util/validate-dimensions";
const lift =
(marshal) =>
({ getState, dispatch }) =>
(next) =>
(action) => {
if (action.type !== "LIFT") {
next(action);
return;
}
const { id, clientSelection, movementMode } = action.payload;
const initial = getState();
// flush dropping animation if needed
// this can change the descriptor of the dragging item
// Will call the onDragEnd responders
if (initial.phase === "DROP_ANIMATING") {
dispatch(
completeDrop({
completed: initial.completed
})
);
}
invariant(getState().phase === "IDLE", "Unexpected phase to start a drag");
// Removing any placeholders before we capture any starting dimensions
dispatch(flush());
// Let consumers know we are just about to publish
// We are only publishing a small amount of information as
// things might change as a result of the onBeforeCapture callback
dispatch(
beforeInitialCapture({
draggableId: id,
movementMode
})
);
// will communicate with the marshal to start requesting dimensions
const scrollOptions = {
shouldPublishImmediately: movementMode === "SNAP"
};
const request = {
draggableId: id,
scrollOptions
};
// Let's get the marshal started!
const { critical, dimensions, viewport } = marshal.startPublishing(request);
validateDimensions(critical, dimensions);
// Okay, we are good to start dragging now
dispatch(
initialPublish({
critical,
dimensions,
clientSelection,
movementMode,
viewport
})
);
};
export default lift;

View File

@@ -0,0 +1,32 @@
import { drop } from "../action-creators";
const pendingDrop = (store) => (next) => (action) => {
// Always let the action go through first
next(action);
if (action.type !== "PUBLISH_WHILE_DRAGGING") {
return;
}
// A bulk replace occurred - check if
// 1. there is a pending drop
// 2. that the pending drop is no longer waiting
const postActionState = store.getState();
// no pending drop after the publish
if (postActionState.phase !== "DROP_PENDING") {
return;
}
// the pending drop is still waiting for completion
if (postActionState.isWaiting) {
return;
}
store.dispatch(
drop({
reason: postActionState.reason
})
);
};
export default pendingDrop;

View File

@@ -0,0 +1,40 @@
import { invariant } from "../../../invariant";
import { findIndex } from "../../../native-with-fallback";
const asyncMarshal = () => {
const entries = [];
const execute = (timerId) => {
const index = findIndex(entries, (item) => item.timerId === timerId);
invariant(index !== -1, "Could not find timer");
// delete in place
const [entry] = entries.splice(index, 1);
entry.callback();
};
const add = (fn) => {
const timerId = setTimeout(() => execute(timerId));
const entry = {
timerId,
callback: fn
};
entries.push(entry);
};
const flush = () => {
// nothing to flush
if (!entries.length) {
return;
}
const shallow = [...entries];
// clearing entries in case a callback adds some more callbacks
entries.length = 0;
shallow.forEach((entry) => {
clearTimeout(entry.timerId);
entry.callback();
});
};
return {
add,
flush
};
};
export default asyncMarshal;

View File

@@ -0,0 +1,35 @@
import { warning } from "../../../dev-warning";
const expiringAnnounce = (announce) => {
let wasCalled = false;
let isExpired = false;
// not allowing async announcements
const timeoutId = setTimeout(() => {
isExpired = true;
});
const result = (message) => {
if (wasCalled) {
warning("Announcement already made. Not making a second announcement");
return;
}
if (isExpired) {
warning(`
Announcements cannot be made asynchronously.
Default message has already been announced.
`);
return;
}
wasCalled = true;
announce(message);
clearTimeout(timeoutId);
};
// getter for isExpired
// using this technique so that a consumer cannot
// set the isExpired or wasCalled flags
result.wasCalled = () => wasCalled;
return result;
};
export default expiringAnnounce;

View File

@@ -0,0 +1 @@
export { default } from "./responders-middleware";

View File

@@ -0,0 +1,38 @@
export const areLocationsEqual = (first, second) => {
// if both are null - we are equal
if (first == null && second == null) {
return true;
}
// if one is null - then they are not equal
if (first == null || second == null) {
return false;
}
// compare their actual values
return first.droppableId === second.droppableId && first.index === second.index;
};
export const isCombineEqual = (first, second) => {
// if both are null - we are equal
if (first == null && second == null) {
return true;
}
// only one is null
if (first == null || second == null) {
return false;
}
return first.draggableId === second.draggableId && first.droppableId === second.droppableId;
};
export const isCriticalEqual = (first, second) => {
if (first === second) {
return true;
}
const isDraggableEqual =
first.draggable.id === second.draggable.id &&
first.draggable.droppableId === second.draggable.droppableId &&
first.draggable.type === second.draggable.type &&
first.draggable.index === second.draggable.index;
const isDroppableEqual = first.droppable.id === second.droppable.id && first.droppable.type === second.droppable.type;
return isDraggableEqual && isDroppableEqual;
};

View File

@@ -0,0 +1,156 @@
import { invariant } from "../../../invariant";
import messagePreset from "../../../screen-reader-message-preset";
import * as timings from "../../../debug/timings";
import getExpiringAnnounce from "./expiring-announce";
import getAsyncMarshal from "./async-marshal";
import { areLocationsEqual, isCombineEqual, isCriticalEqual } from "./is-equal";
import { tryGetCombine, tryGetDestination } from "../../get-impact-location";
const withTimings = (key, fn) => {
timings.start(key);
fn();
timings.finish(key);
};
const getDragStart = (critical, mode) => ({
draggableId: critical.draggable.id,
type: critical.droppable.type,
source: {
droppableId: critical.droppable.id,
index: critical.draggable.index
},
mode
});
const execute = (responder, data, announce, getDefaultMessage) => {
if (!responder) {
announce(getDefaultMessage(data));
return;
}
const willExpire = getExpiringAnnounce(announce);
const provided = {
announce: willExpire
};
// Casting because we are not validating which data type is going into which responder
responder(data, provided);
if (!willExpire.wasCalled()) {
announce(getDefaultMessage(data));
}
};
const publisher = (getResponders, announce) => {
const asyncMarshal = getAsyncMarshal();
let dragging = null;
const beforeCapture = (draggableId, mode) => {
invariant(!dragging, "Cannot fire onBeforeCapture as a drag start has already been published");
withTimings("onBeforeCapture", () => {
// No use of screen reader for this responder
const fn = getResponders().onBeforeCapture;
if (fn) {
const before = {
draggableId,
mode
};
fn(before);
}
});
};
const beforeStart = (critical, mode) => {
invariant(!dragging, "Cannot fire onBeforeDragStart as a drag start has already been published");
withTimings("onBeforeDragStart", () => {
// No use of screen reader for this responder
const fn = getResponders().onBeforeDragStart;
if (fn) {
fn(getDragStart(critical, mode));
}
});
};
const start = (critical, mode) => {
invariant(!dragging, "Cannot fire onBeforeDragStart as a drag start has already been published");
const data = getDragStart(critical, mode);
dragging = {
mode,
lastCritical: critical,
lastLocation: data.source,
lastCombine: null
};
// we will flush this frame if we receive any responder updates
asyncMarshal.add(() => {
withTimings("onDragStart", () => execute(getResponders().onDragStart, data, announce, messagePreset.onDragStart));
});
};
// Passing in the critical location again as it can change during a drag
const update = (critical, impact) => {
const location = tryGetDestination(impact);
const combine = tryGetCombine(impact);
invariant(dragging, "Cannot fire onDragMove when onDragStart has not been called");
// Has the critical changed? Will result in a source change
const hasCriticalChanged = !isCriticalEqual(critical, dragging.lastCritical);
if (hasCriticalChanged) {
dragging.lastCritical = critical;
}
// Has the location changed? Will result in a destination change
const hasLocationChanged = !areLocationsEqual(dragging.lastLocation, location);
if (hasLocationChanged) {
dragging.lastLocation = location;
}
const hasGroupingChanged = !isCombineEqual(dragging.lastCombine, combine);
if (hasGroupingChanged) {
dragging.lastCombine = combine;
}
// Nothing has changed - no update needed
if (!hasCriticalChanged && !hasLocationChanged && !hasGroupingChanged) {
return;
}
const data = {
...getDragStart(critical, dragging.mode),
combine,
destination: location
};
asyncMarshal.add(() => {
withTimings("onDragUpdate", () =>
execute(getResponders().onDragUpdate, data, announce, messagePreset.onDragUpdate)
);
});
};
const flush = () => {
invariant(dragging, "Can only flush responders while dragging");
asyncMarshal.flush();
};
const drop = (result) => {
invariant(dragging, "Cannot fire onDragEnd when there is no matching onDragStart");
dragging = null;
// not adding to frame marshal - we want this to be done in the same render pass
// we also want the consumers reorder logic to be in the same render pass
withTimings("onDragEnd", () => execute(getResponders().onDragEnd, result, announce, messagePreset.onDragEnd));
};
// A non user initiated cancel
const abort = () => {
// aborting can happen defensively
if (!dragging) {
return;
}
const result = {
...getDragStart(dragging.lastCritical, dragging.mode),
combine: null,
destination: null,
reason: "CANCEL"
};
drop(result);
};
return {
beforeCapture,
beforeStart,
start,
update,
flush,
drop,
abort
};
};
export default publisher;

Some files were not shown because too many files have changed in this diff Show More