- Add in new DND Library (react-beautiful-dnd-grid), resides in client/src/components/trello-board/dnd/lib/index.js

Signed-off-by: Dave Richer <dave@imexsystems.ca>
This commit is contained in:
Dave Richer
2024-06-10 15:54:29 -04:00
parent 6cf4a50a83
commit 10f60752c8
233 changed files with 10090 additions and 8 deletions

View File

@@ -25,6 +25,7 @@
"autosize": "^6.0.1",
"axios": "^1.6.8",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.11",
"dayjs-business-days2": "^1.2.2",
"dinero.js": "^1.9.1",
@@ -39,6 +40,7 @@
"libphonenumber-js": "^1.11.2",
"logrocket": "^8.1.0",
"markerjs2": "^2.32.1",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"prop-types": "^15.8.1",
"query-string": "^9.0.0",
@@ -74,6 +76,7 @@
"socket.io-client": "^4.7.5",
"styled-components": "^6.1.11",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"userpilot": "^1.3.1",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^3.5.2"
@@ -5851,6 +5854,11 @@
"react": ">=16.3.0"
}
},
"node_modules/@splitsoftware/splitio-react/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -8518,6 +8526,14 @@
"node": ">=8"
}
},
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"dependencies": {
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -13069,9 +13085,9 @@
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/meow": {
"version": "13.2.0",
@@ -15414,11 +15430,6 @@
"react-dom": "^16.14.0 || ^17 || ^18"
}
},
"node_modules/react-big-calendar/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/react-color": {
"version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
@@ -18275,6 +18286,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/use-memo-one": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
"integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",

View File

@@ -25,6 +25,7 @@
"autosize": "^6.0.1",
"axios": "^1.6.8",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.11",
"dayjs-business-days2": "^1.2.2",
"dinero.js": "^1.9.1",
@@ -39,6 +40,7 @@
"libphonenumber-js": "^1.11.2",
"logrocket": "^8.1.0",
"markerjs2": "^2.32.1",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"prop-types": "^15.8.1",
"query-string": "^9.0.0",
@@ -74,6 +76,7 @@
"socket.io-client": "^4.7.5",
"styled-components": "^6.1.11",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"userpilot": "^1.3.1",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^3.5.2"

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 (process.env.NODE_ENV !== "production") {
if (!isTimingsEnabled()) {
return;
}
const now = performance.now();
records[key] = now;
}
};
export const finish = (key) => {
if (process.env.NODE_ENV !== "production") {
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 = process.env.NODE_ENV === "production";
// 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 = process.env.NODE_ENV === "production";
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,43 @@
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;
};
export default ({ 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;
};

View File

@@ -0,0 +1,20 @@
import getScroll from "./get-scroll";
import { canScrollDroppable } from "../can-scroll";
export default ({ 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;
};

View File

@@ -0,0 +1,16 @@
import { warning } from "../../../dev-warning";
export default ({ 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;
const percentage = currentInRange / range;
return percentage;
};

View File

@@ -0,0 +1,21 @@
export default ({ 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
};
};

View File

@@ -0,0 +1,31 @@
import getPercentage from "../../get-percentage";
import config from "../../config";
import minScroll from "./min-scroll";
const accelerateAt = config.durationDampening.accelerateAt;
const stopAt = config.durationDampening.stopDampeningAt;
export default (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);
};

View File

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

View File

@@ -0,0 +1,52 @@
import getPercentage from "../../get-percentage";
import config from "../../config";
import minScroll from "./min-scroll";
export default (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);
};

View File

@@ -0,0 +1,25 @@
import getValueFromDistance from "./get-value-from-distance";
import dampenValueByTime from "./dampen-value-by-time";
import minScroll from "./min-scroll";
export default ({ 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);
};

View File

@@ -0,0 +1,24 @@
import getDistanceThresholds from "./get-distance-thresholds";
import getValue from "./get-value";
export default ({ 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
})
);
};

View File

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

View File

@@ -0,0 +1,59 @@
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));
export default ({ 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;
};

View File

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

View File

@@ -0,0 +1,61 @@
import rafSchd from "raf-schd";
import scroll from "./scroll";
import { invariant } from "../../../invariant";
import * as timings from "../../../debug/timings";
export default ({ 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
};
};

View File

@@ -0,0 +1,43 @@
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";
export default ({ 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);
}
};

View File

@@ -0,0 +1,34 @@
import createFluidScroller from "./fluid-scroller";
import createJumpScroller from "./jump-scroller";
export default ({ 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;
};

View File

@@ -0,0 +1,84 @@
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";
export default ({ 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;
};

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,26 @@
export default (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";
};

View File

@@ -0,0 +1,70 @@
/* eslint-disable no-underscore-dangle */
import { applyMiddleware, createStore, compose } from "redux";
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";
// We are checking if window is available before using it.
// This is needed for universal apps that render the component server side.
// Details: https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup
const composeEnhancers =
process.env.NODE_ENV !== "production" && typeof window !== "undefined" && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
name: "react-beautiful-dnd"
})
: compose;
export default ({ dimensionMarshal, focusMarshal, styleMarshal, getResponders, announce, autoScroller }) =>
createStore(
reducer,
composeEnhancers(
applyMiddleware(
// ## 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)
)
)
);

View File

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

View File

@@ -0,0 +1,151 @@
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;
}
export default (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;
};

View File

@@ -0,0 +1,28 @@
import * as timings from "../../debug/timings";
import { toDraggableMap, toDroppableMap } from "../dimension-structures";
import getViewport from "../../view/window/get-viewport";
export default ({ 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;
};

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,56 @@
import { grid, horizontal, vertical } from "../axis";
import { origin } from "../position";
import getMaxScroll from "../get-max-scroll";
import getSubject from "./util/get-subject";
export default ({ 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;
};

View File

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

View File

@@ -0,0 +1,41 @@
import { invariant } from "../../invariant";
import { negate, subtract } from "../position";
import getSubject from "./util/get-subject";
export default (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;
};

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,14 @@
import { getRect } from "css-box-model";
export default (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;
};

View File

@@ -0,0 +1,35 @@
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);
};
export default ({ 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
};
};

View File

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

View File

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

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,8 @@
import { add, subtract } from "../../position";
import withViewportDisplacement from "../../with-scroll-change/with-viewport-displacement";
export default ({ pageBorderBoxCenter, draggable, viewport }) => {
const withoutPageScrollChange = withViewportDisplacement(viewport, pageBorderBoxCenter);
const offset = subtract(withoutPageScrollChange, draggable.page.borderBox.center);
return add(draggable.client.borderBox.center, offset);
};

View File

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

View File

@@ -0,0 +1,34 @@
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
});
};
export default (args) => {
const withoutDisplacement = getResultWithoutDroppableDisplacement(args);
const droppable = args.droppable;
const withDisplacement = droppable ? withDroppableDisplacement(droppable, withoutDisplacement) : withoutDisplacement;
return withDisplacement;
};

View File

@@ -0,0 +1,19 @@
import { invariant } from "../../../invariant";
import { add } from "../../position";
import getCombinedItemDisplacement from "../../get-combined-item-displacement";
import { tryGetCombine } from "../../get-impact-location";
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default ({ 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);
};

View File

@@ -0,0 +1,76 @@
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";
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default ({ 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
});
};

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,10 @@
import { negate, origin } from "./position";
import didStartAfterCritical from "./did-start-after-critical";
export default ({ 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;
};

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,83 @@
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;
export default ({
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;
}
const impact = {
// no change to displacement when combining
displacedBy,
displaced: previousImpact.displaced,
at: {
type: "COMBINE",
combine: {
draggableId: combineWith.descriptor.id,
droppableId: destination.descriptor.id
}
}
};
return impact;
};

View File

@@ -0,0 +1,102 @@
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;
}
export default ({
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
});
};

View File

@@ -0,0 +1,51 @@
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";
export default ({ 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
})
);
};

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,7 @@
import { invariant } from "../invariant";
export default (droppable) => {
const frame = droppable.frame;
invariant(frame, "Expected Droppable to have a frame");
return frame;
};

View File

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

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,48 @@
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";
export default ({ 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
};
};

View File

@@ -0,0 +1,21 @@
import { subtract } from "./position";
export default ({ 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;
};

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,2 @@
// is a value between two other values
export default (lowerBound, upperBound) => (value) => lowerBound <= value && value <= upperBound;

View File

@@ -0,0 +1,24 @@
import { invariant } from "../../invariant";
const shouldStop = (action) =>
action.type === "DROP_COMPLETE" || action.type === "DROP_ANIMATE" || action.type === "FLUSH";
export default (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());
};

View File

@@ -0,0 +1,13 @@
export default (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);
};

View File

@@ -0,0 +1,16 @@
import { invariant } from "../../../invariant";
import { completeDrop } from "../../action-creators";
export default (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
})
);
};

View File

@@ -0,0 +1,55 @@
import { dropAnimationFinished } from "../../action-creators";
import bindEvents from "../../../view/event-bindings/bind-events";
export default (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]);
});
};
};

View File

@@ -0,0 +1,112 @@
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";
export default ({ 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));
};

View File

@@ -0,0 +1,30 @@
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;
export default ({ 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));
};

View File

@@ -0,0 +1,44 @@
import recompute from "../../update-displacement-visibility/recompute";
import { emptyGroups } from "../../no-impact";
export default ({ 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
};
};

View File

@@ -0,0 +1,21 @@
import whatIsDraggedOver from "../../droppable/what-is-dragged-over";
import { subtract } from "../../position";
import getClientBorderBoxCenter from "../../get-center-from-impact/get-client-border-box-center";
export default ({ 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;
};

View File

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

View File

@@ -0,0 +1,31 @@
export default (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();
}
};
};

View File

@@ -0,0 +1,64 @@
import { invariant } from "../../invariant";
import { beforeInitialCapture, completeDrop, flush, initialPublish } from "../action-creators";
import validateDimensions from "./util/validate-dimensions";
export default (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
})
);
};

View File

@@ -0,0 +1,30 @@
import { drop } from "../action-creators";
export default (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
})
);
};

View File

@@ -0,0 +1,38 @@
import { invariant } from "../../../invariant";
import { findIndex } from "../../../native-with-fallback";
export default () => {
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
};
};

View File

@@ -0,0 +1,33 @@
import { warning } from "../../../dev-warning";
export default (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;
};

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,154 @@
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));
}
};
export default (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
};
};

View File

@@ -0,0 +1,48 @@
import getPublisher from "./publisher";
export default (getResponders, announce) => {
const publisher = getPublisher(getResponders, announce);
return (store) => (next) => (action) => {
if (action.type === "BEFORE_INITIAL_CAPTURE") {
publisher.beforeCapture(action.payload.draggableId, action.payload.movementMode);
return;
}
if (action.type === "INITIAL_PUBLISH") {
const critical = action.payload.critical;
publisher.beforeStart(critical, action.payload.movementMode);
next(action);
publisher.start(critical, action.payload.movementMode);
return;
}
// Drag end
if (action.type === "DROP_COMPLETE") {
// it is important that we use the result and not the last impact
// the last impact might be different to the result for visual reasons
const result = action.payload.completed.result;
// flushing all pending responders before snapshots are updated
publisher.flush();
next(action);
publisher.drop(result);
return;
}
// All other responders can fire after we have updated our connected components
next(action);
// Drag state resetting - need to check if
// we should fire a onDragEnd responder
if (action.type === "FLUSH") {
publisher.abort();
return;
}
// ## Perform drag updates
// impact of action has already been reduced
const state = store.getState();
if (state.phase === "DRAGGING") {
publisher.update(state.critical, state.impact);
}
};
};

View File

@@ -0,0 +1,26 @@
import { moveByWindowScroll } from "../action-creators";
import getScrollListener from "../../view/scroll-listener";
// TODO: this is taken from auto-scroll. Let's make it a util
const shouldEnd = (action) =>
action.type === "DROP_COMPLETE" || action.type === "DROP_ANIMATE" || action.type === "FLUSH";
export default (store) => {
const listener = getScrollListener({
onWindowScroll: (newScroll) => {
store.dispatch(
moveByWindowScroll({
newScroll
})
);
}
});
return (next) => (action) => {
if (!listener.isActive() && action.type === "INITIAL_PUBLISH") {
listener.start();
}
if (listener.isActive() && shouldEnd(action)) {
listener.stop();
}
next(action);
};
};

View File

@@ -0,0 +1,14 @@
export default (marshal) => () => (next) => (action) => {
if (action.type === "INITIAL_PUBLISH") {
marshal.dragging();
}
if (action.type === "DROP_ANIMATE") {
marshal.dropping(action.payload.completed.result.reason);
}
// this will clear any styles immediately before a reorder
if (action.type === "FLUSH" || action.type === "DROP_COMPLETE") {
marshal.resting();
}
next(action);
};

View File

@@ -0,0 +1,46 @@
import getDraggablesInsideDroppable from "../../get-draggables-inside-droppable";
import { warning } from "../../../dev-warning";
function checkIndexes(insideDestination) {
// no point running if there are 1 or less items
if (insideDestination.length <= 1) {
return;
}
const indexes = insideDestination.map((d) => d.descriptor.index);
const errors = {};
for (let i = 1; i < indexes.length; i++) {
const current = indexes[i];
const previous = indexes[i - 1];
// this will be an error if:
// 1. index is not consecutive
// 2. index is duplicated (which is true if #1 is not passed)
if (current !== previous + 1) {
errors[current] = true;
}
}
if (!Object.keys(errors).length) {
return;
}
const formatted = indexes
.map((index) => {
const hasError = Boolean(errors[index]);
return hasError ? `[🔥${index}]` : `${index}`;
})
.join(", ");
warning(`
Detected non-consecutive <Draggable /> indexes.
(This can cause unexpected bugs)
${formatted}
`);
}
export default function validateDimensions(critical, dimensions) {
// wrapping entire block for better minification
if (process.env.NODE_ENV !== "production") {
const insideDestination = getDraggablesInsideDroppable(critical.droppable.id, dimensions.draggables);
checkIndexes(insideDestination);
}
}

View File

@@ -0,0 +1,50 @@
import moveToNextPlace from "./move-to-next-place";
import moveCrossAxis from "./move-cross-axis";
import whatIsDraggedOver from "../droppable/what-is-dragged-over";
const getDroppableOver = (impact, droppables) => {
const id = whatIsDraggedOver(impact);
return id ? droppables[id] : null;
};
export default ({ state, type }) => {
const isActuallyOver = getDroppableOver(state.impact, state.dimensions.droppables);
const isMainAxisMovementAllowed = Boolean(isActuallyOver);
const home = state.dimensions.droppables[state.critical.droppable.id];
// use home when not actually over a droppable (can happen when move is disabled)
const isOver = isActuallyOver || home;
const direction = isOver.axis.direction;
const isMovingOnMainAxis =
(direction === "vertical" && (type === "MOVE_UP" || type === "MOVE_DOWN")) ||
(direction === "horizontal" && (type === "MOVE_LEFT" || type === "MOVE_RIGHT"));
// This movement is not permitted right now
if (isMovingOnMainAxis && !isMainAxisMovementAllowed) {
return null;
}
const isMovingForward = type === "MOVE_DOWN" || type === "MOVE_RIGHT";
const draggable = state.dimensions.draggables[state.critical.draggable.id];
const previousPageBorderBoxCenter = state.current.page.borderBoxCenter;
const { draggables, droppables } = state.dimensions;
return isMovingOnMainAxis
? moveToNextPlace({
isMovingForward,
previousPageBorderBoxCenter,
draggable,
destination: isOver,
draggables,
viewport: state.viewport,
previousClientSelection: state.current.client.selection,
previousImpact: state.impact,
afterCritical: state.afterCritical
})
: moveCrossAxis({
isMovingForward,
previousPageBorderBoxCenter,
draggable,
isOver,
draggables,
droppables,
viewport: state.viewport,
afterCritical: state.afterCritical
});
};

View File

@@ -0,0 +1,109 @@
import { invariant } from "../../../invariant";
import { closest } from "../../position";
import isWithin from "../../is-within";
import { getCorners } from "../../spacing";
import isPartiallyVisibleThroughFrame from "../../visibility/is-partially-visible-through-frame";
import { toDroppableList } from "../../dimension-structures";
const getKnownActive = (droppable) => {
const rect = droppable.subject.active;
invariant(rect, "Cannot get clipped area from droppable");
return rect;
};
export default ({ isMovingForward, pageBorderBoxCenter, source, droppables, viewport }) => {
const active = source.subject.active;
if (!active) {
return null;
}
const axis = source.axis;
const isBetweenSourceClipped = isWithin(active[axis.start], active[axis.end]);
const candidates = toDroppableList(droppables)
// Remove the source droppable from the list
.filter((droppable) => droppable !== source)
// Remove any options that are not enabled
.filter((droppable) => droppable.isEnabled)
// Remove any droppables that do not have a visible subject
.filter((droppable) => Boolean(droppable.subject.active))
// Remove any that are not visible in the window
.filter((droppable) => isPartiallyVisibleThroughFrame(viewport.frame)(getKnownActive(droppable)))
.filter((droppable) => {
const activeOfTarget = getKnownActive(droppable);
// is the target in front of the source on the cross axis?
if (isMovingForward) {
return active[axis.crossAxisEnd] < activeOfTarget[axis.crossAxisEnd];
}
// is the target behind the source on the cross axis?
return activeOfTarget[axis.crossAxisStart] < active[axis.crossAxisStart];
})
// Must have some overlap on the main axis
.filter((droppable) => {
const activeOfTarget = getKnownActive(droppable);
const isBetweenDestinationClipped = isWithin(activeOfTarget[axis.start], activeOfTarget[axis.end]);
return (
isBetweenSourceClipped(activeOfTarget[axis.start]) ||
isBetweenSourceClipped(activeOfTarget[axis.end]) ||
isBetweenDestinationClipped(active[axis.start]) ||
isBetweenDestinationClipped(active[axis.end])
);
})
// Sort on the cross axis
.sort((a, b) => {
const first = getKnownActive(a)[axis.crossAxisStart];
const second = getKnownActive(b)[axis.crossAxisStart];
if (isMovingForward) {
return first - second;
}
return second - first;
})
// Find the droppables that have the same cross axis value as the first item
.filter(
(droppable, index, array) =>
getKnownActive(droppable)[axis.crossAxisStart] === getKnownActive(array[0])[axis.crossAxisStart]
);
// no possible candidates
if (!candidates.length) {
return null;
}
// only one result - all done!
if (candidates.length === 1) {
return candidates[0];
}
// At this point we have a number of candidates that
// all have the same axis.crossAxisStart value.
// Check to see if the center position is within the size of a Droppable on the main axis
const contains = candidates.filter((droppable) => {
const isWithinDroppable = isWithin(getKnownActive(droppable)[axis.start], getKnownActive(droppable)[axis.end]);
return isWithinDroppable(pageBorderBoxCenter[axis.line]);
});
if (contains.length === 1) {
return contains[0];
}
// The center point of the draggable falls on the boundary between two droppables
if (contains.length > 1) {
// sort on the main axis and choose the first
return contains.sort((a, b) => getKnownActive(a)[axis.start] - getKnownActive(b)[axis.start])[0];
}
// The center is not contained within any droppable
// 1. Find the candidate that has the closest corner
// 2. If there is a tie - choose the one that is first on the main axis
return candidates.sort((a, b) => {
const first = closest(pageBorderBoxCenter, getCorners(getKnownActive(a)));
const second = closest(pageBorderBoxCenter, getCorners(getKnownActive(b)));
// if the distances are not equal - choose the shortest
if (first !== second) {
return first - second;
}
// They both have the same distance -
// choose the one that is first on the main axis
return getKnownActive(a)[axis.start] - getKnownActive(b)[axis.start];
})[0];
};

View File

@@ -0,0 +1,45 @@
import { distance } from "../../position";
import { isTotallyVisible } from "../../visibility/is-visible";
import withDroppableDisplacement from "../../with-scroll-change/with-droppable-displacement";
import { getCurrentPageBorderBox, getCurrentPageBorderBoxCenter } from "./without-starting-displacement";
export default ({ pageBorderBoxCenter, viewport, destination, insideDestination, afterCritical }) => {
const sorted = insideDestination
.filter((draggable) =>
// Allowing movement to draggables that are not visible in the viewport
// but must be visible in the droppable
// We can improve this, but this limitation is easier for now
isTotallyVisible({
target: getCurrentPageBorderBox(draggable, afterCritical),
destination,
viewport: viewport.frame,
withDroppableDisplacement: true
})
)
.sort((a, b) => {
// Need to consider the change in scroll in the destination
const distanceToA = distance(
pageBorderBoxCenter,
withDroppableDisplacement(destination, getCurrentPageBorderBoxCenter(a, afterCritical))
);
const distanceToB = distance(
pageBorderBoxCenter,
withDroppableDisplacement(destination, getCurrentPageBorderBoxCenter(b, afterCritical))
);
// if a is closer - return a
if (distanceToA < distanceToB) {
return -1;
}
// if b is closer - return b
if (distanceToB < distanceToA) {
return 1;
}
// if the distance to a and b are the same:
// return the one with the lower index (it will be higher on the main axis)
return a.descriptor.index - b.descriptor.index;
});
return sorted[0] || null;
};

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