diff --git a/client/package-lock.json b/client/package-lock.json
index 3bb33c172..5f59dd6e1 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index d2dc5290d..7d7de9475 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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"
diff --git a/client/src/components/trello-board/dnd/lib/animation.js b/client/src/components/trello-board/dnd/lib/animation.js
new file mode 100644
index 000000000..67b2254a8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/animation.js
@@ -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})`;
+ }
+};
diff --git a/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing-average.js b/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing-average.js
new file mode 100644
index 000000000..c475e3c43
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing-average.js
@@ -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;
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing.js b/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing.js
new file mode 100644
index 000000000..eb4ea015d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/debug/middleware/action-timing.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/debug/middleware/log.js b/client/src/components/trello-board/dnd/lib/debug/middleware/log.js
new file mode 100644
index 000000000..9dab43ca9
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/debug/middleware/log.js
@@ -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;
+ };
diff --git a/client/src/components/trello-board/dnd/lib/debug/middleware/user-timing.js b/client/src/components/trello-board/dnd/lib/debug/middleware/user-timing.js
new file mode 100644
index 000000000..5b343badb
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/debug/middleware/user-timing.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/debug/timings.js b/client/src/components/trello-board/dnd/lib/debug/timings.js
new file mode 100644
index 000000000..0c565b77d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/debug/timings.js
@@ -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;"
+ );
+ }
+};
diff --git a/client/src/components/trello-board/dnd/lib/dev-warning.js b/client/src/components/trello-board/dnd/lib/dev-warning.js
new file mode 100644
index 000000000..ec6e70a39
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/dev-warning.js
@@ -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");
diff --git a/client/src/components/trello-board/dnd/lib/empty.js b/client/src/components/trello-board/dnd/lib/empty.js
new file mode 100644
index 000000000..95e87e540
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/empty.js
@@ -0,0 +1,5 @@
+export function noop() {}
+
+export function identity(value) {
+ return value;
+}
diff --git a/client/src/components/trello-board/dnd/lib/index.js b/client/src/components/trello-board/dnd/lib/index.js
new file mode 100644
index 000000000..525132bbf
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/index.js
@@ -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
diff --git a/client/src/components/trello-board/dnd/lib/invariant.js b/client/src/components/trello-board/dnd/lib/invariant.js
new file mode 100644
index 000000000..fc67d0e12
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/invariant.js
@@ -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 || ""}`);
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/native-with-fallback.js b/client/src/components/trello-board/dnd/lib/native-with-fallback.js
new file mode 100644
index 000000000..9cb560615
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/native-with-fallback.js
@@ -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);
+}
diff --git a/client/src/components/trello-board/dnd/lib/screen-reader-message-preset.js b/client/src/components/trello-board/dnd/lib/screen-reader-message-preset.js
new file mode 100644
index 000000000..5fe7cd16d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/screen-reader-message-preset.js
@@ -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;
diff --git a/client/src/components/trello-board/dnd/lib/state/action-creators.js b/client/src/components/trello-board/dnd/lib/state/action-creators.js
new file mode 100644
index 000000000..0d36c0c79
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/action-creators.js
@@ -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
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/auto-scroller-types.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/auto-scroller-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/can-scroll.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/can-scroll.js
new file mode 100644
index 000000000..202b656f8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/can-scroll.js
@@ -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
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/config.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/config.js
new file mode 100644
index 000000000..20077c485
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/config.js
@@ -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;
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/did-start-in-scrollable-area.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/did-start-in-scrollable-area.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-best-scrollable-droppable.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-best-scrollable-droppable.js
new file mode 100644
index 000000000..9badbe8b4
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-best-scrollable-droppable.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-droppable-scroll-change.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-droppable-scroll-change.js
new file mode 100644
index 000000000..55f46c8d6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-droppable-scroll-change.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-percentage.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-percentage.js
new file mode 100644
index 000000000..c73f74ffc
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-percentage.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/adjust-for-size-limits.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/adjust-for-size-limits.js
new file mode 100644
index 000000000..505c3d709
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/adjust-for-size-limits.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/dampen-value-by-time.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/dampen-value-by-time.js
new file mode 100644
index 000000000..73b8b9148
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/dampen-value-by-time.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-distance-thresholds.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-distance-thresholds.js
new file mode 100644
index 000000000..3d05c2e2e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-distance-thresholds.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value-from-distance.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value-from-distance.js
new file mode 100644
index 000000000..f3cccc867
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value-from-distance.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value.js
new file mode 100644
index 000000000..d1edddd8d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/get-value.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/index.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/index.js
new file mode 100644
index 000000000..d42f9768c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/index.js
@@ -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
+ })
+ );
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/min-scroll.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/min-scroll.js
new file mode 100644
index 000000000..6a655895c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/get-scroll-on-axis/min-scroll.js
@@ -0,0 +1,2 @@
+// A scroll event will only be triggered when there is a value of at least 1px change
+export default 1;
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/index.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/index.js
new file mode 100644
index 000000000..6eede3845
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-scroll/index.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-window-scroll-change.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-window-scroll-change.js
new file mode 100644
index 000000000..ccd6db2e8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/get-window-scroll-change.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/index.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/index.js
new file mode 100644
index 000000000..f034a4d3b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/index.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/scroll.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/scroll.js
new file mode 100644
index 000000000..b13cba766
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/fluid-scroller/scroll.js
@@ -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);
+ }
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/index.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/index.js
new file mode 100644
index 000000000..1f8aea6c4
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/index.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/auto-scroller/jump-scroller.js b/client/src/components/trello-board/dnd/lib/state/auto-scroller/jump-scroller.js
new file mode 100644
index 000000000..aca04c873
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/auto-scroller/jump-scroller.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/axis.js b/client/src/components/trello-board/dnd/lib/state/axis.js
new file mode 100644
index 000000000..1a7042131
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/axis.js
@@ -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"
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/calculate-drag-impact/calculate-reorder-impact.js b/client/src/components/trello-board/dnd/lib/state/calculate-drag-impact/calculate-reorder-impact.js
new file mode 100644
index 000000000..acc568119
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/calculate-drag-impact/calculate-reorder-impact.js
@@ -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
+ }
+ }
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/can-start-drag.js b/client/src/components/trello-board/dnd/lib/state/can-start-drag.js
new file mode 100644
index 000000000..38c1fc4a8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/can-start-drag.js
@@ -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";
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/create-store.js b/client/src/components/trello-board/dnd/lib/state/create-store.js
new file mode 100644
index 000000000..af887d353
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/create-store.js
@@ -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)
+ )
+ )
+ );
diff --git a/client/src/components/trello-board/dnd/lib/state/did-start-after-critical.js b/client/src/components/trello-board/dnd/lib/state/did-start-after-critical.js
new file mode 100644
index 000000000..a6b54607d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/did-start-after-critical.js
@@ -0,0 +1,3 @@
+export default function didStartAfterCritical(draggableId, afterCritical) {
+ return Boolean(afterCritical.effected[draggableId]);
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/dimension-marshal/dimension-marshal-types.js b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/dimension-marshal-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/dimension-marshal/dimension-marshal.js b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/dimension-marshal.js
new file mode 100644
index 000000000..7200a9996
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/dimension-marshal.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/dimension-marshal/get-initial-publish.js b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/get-initial-publish.js
new file mode 100644
index 000000000..f05aec18a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/get-initial-publish.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/dimension-marshal/while-dragging-publisher.js b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/while-dragging-publisher.js
new file mode 100644
index 000000000..ccab477a6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/dimension-marshal/while-dragging-publisher.js
@@ -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
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/dimension-structures.js b/client/src/components/trello-board/dnd/lib/state/dimension-structures.js
new file mode 100644
index 000000000..d74351c0c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/dimension-structures.js
@@ -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));
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/get-droppable.js b/client/src/components/trello-board/dnd/lib/state/droppable/get-droppable.js
new file mode 100644
index 000000000..44cc3ebd2
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/get-droppable.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/is-home-of.js b/client/src/components/trello-board/dnd/lib/state/droppable/is-home-of.js
new file mode 100644
index 000000000..33e13c133
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/is-home-of.js
@@ -0,0 +1 @@
+export default (draggable, destination) => draggable.descriptor.droppableId === destination.descriptor.id;
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/scroll-droppable.js b/client/src/components/trello-board/dnd/lib/state/droppable/scroll-droppable.js
new file mode 100644
index 000000000..212c55660
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/scroll-droppable.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/should-use-placeholder.js b/client/src/components/trello-board/dnd/lib/state/droppable/should-use-placeholder.js
new file mode 100644
index 000000000..31d4be990
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/should-use-placeholder.js
@@ -0,0 +1,4 @@
+import whatIsDraggedOver from "./what-is-dragged-over";
+
+// use placeholder if dragged over
+export default (descriptor, impact) => whatIsDraggedOver(impact) === descriptor.droppableId;
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/util/clip.js b/client/src/components/trello-board/dnd/lib/state/droppable/util/clip.js
new file mode 100644
index 000000000..68cb9f578
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/util/clip.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/util/get-subject.js b/client/src/components/trello-board/dnd/lib/state/droppable/util/get-subject.js
new file mode 100644
index 000000000..1a4ad9f5c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/util/get-subject.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over-from-result.js b/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over-from-result.js
new file mode 100644
index 000000000..53ce9110c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over-from-result.js
@@ -0,0 +1,10 @@
+export default (result) => {
+ const { combine, destination } = result;
+ if (destination) {
+ return destination.droppableId;
+ }
+ if (combine) {
+ return combine.droppableId;
+ }
+ return null;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over.js b/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over.js
new file mode 100644
index 000000000..cabbe4323
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/what-is-dragged-over.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/droppable/with-placeholder.js b/client/src/components/trello-board/dnd/lib/state/droppable/with-placeholder.js
new file mode 100644
index 000000000..b3756546f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/droppable/with-placeholder.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center.js
new file mode 100644
index 000000000..e65f5175b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/index.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/index.js
new file mode 100644
index 000000000..77c030ece
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-client-border-box-center/index.js
@@ -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
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/index.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/index.js
new file mode 100644
index 000000000..9bfe1d594
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/index.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-combining.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-combining.js
new file mode 100644
index 000000000..e4d28ba39
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-combining.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-reordering.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-reordering.js
new file mode 100644
index 000000000..64b31a516
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/get-page-border-box-center/when-reordering.js
@@ -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
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/move-relative-to.js b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/move-relative-to.js
new file mode 100644
index 000000000..84c37f115
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-center-from-impact/move-relative-to.js
@@ -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)
+ );
diff --git a/client/src/components/trello-board/dnd/lib/state/get-combined-item-displacement.js b/client/src/components/trello-board/dnd/lib/state/get-combined-item-displacement.js
new file mode 100644
index 000000000..844320061
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-combined-item-displacement.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-displaced-by.js b/client/src/components/trello-board/dnd/lib/state/get-displaced-by.js
new file mode 100644
index 000000000..13ede38a9
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-displaced-by.js
@@ -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)
+ };
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/get-displacement-groups.js b/client/src/components/trello-board/dnd/lib/state/get-displacement-groups.js
new file mode 100644
index 000000000..296730ac9
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-displacement-groups.js
@@ -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: {}
+ }
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-combine-impact.js b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-combine-impact.js
new file mode 100644
index 000000000..c3556bf36
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-combine-impact.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-reorder-impact.js b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-reorder-impact.js
new file mode 100644
index 000000000..656644b20
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/get-reorder-impact.js
@@ -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
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-drag-impact/index.js b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/index.js
new file mode 100644
index 000000000..f52eea7cf
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-drag-impact/index.js
@@ -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
+ })
+ );
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-draggables-inside-droppable.js b/client/src/components/trello-board/dnd/lib/state/get-draggables-inside-droppable.js
new file mode 100644
index 000000000..039f70f26
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-draggables-inside-droppable.js
@@ -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;
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/get-droppable-over.js b/client/src/components/trello-board/dnd/lib/state/get-droppable-over.js
new file mode 100644
index 000000000..dcf0cf04c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-droppable-over.js
@@ -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
+ });
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/get-frame.js b/client/src/components/trello-board/dnd/lib/state/get-frame.js
new file mode 100644
index 000000000..63a877efe
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-frame.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-home-location.js b/client/src/components/trello-board/dnd/lib/state/get-home-location.js
new file mode 100644
index 000000000..fdeb1610c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-home-location.js
@@ -0,0 +1,4 @@
+export default (descriptor) => ({
+ index: descriptor.index,
+ droppableId: descriptor.droppableId
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/get-impact-location.js b/client/src/components/trello-board/dnd/lib/state/get-impact-location.js
new file mode 100644
index 000000000..fa579ca39
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-impact-location.js
@@ -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;
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/get-is-displaced.js b/client/src/components/trello-board/dnd/lib/state/get-is-displaced.js
new file mode 100644
index 000000000..1aa6bf9f9
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-is-displaced.js
@@ -0,0 +1,3 @@
+export default function getIsDisplaced({ displaced, id }) {
+ return Boolean(displaced.visible[id] || displaced.invisible[id]);
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/get-lift-effect.js b/client/src/components/trello-board/dnd/lib/state/get-lift-effect.js
new file mode 100644
index 000000000..1adf99ba0
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-lift-effect.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/get-max-scroll.js b/client/src/components/trello-board/dnd/lib/state/get-max-scroll.js
new file mode 100644
index 000000000..12bf26db3
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/get-max-scroll.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/is-movement-allowed.js b/client/src/components/trello-board/dnd/lib/state/is-movement-allowed.js
new file mode 100644
index 000000000..f349a8c9b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/is-movement-allowed.js
@@ -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";
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/is-within.js b/client/src/components/trello-board/dnd/lib/state/is-within.js
new file mode 100644
index 000000000..6967d6da8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/is-within.js
@@ -0,0 +1,2 @@
+// is a value between two other values
+export default (lowerBound, upperBound) => (value) => lowerBound <= value && value <= upperBound;
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/auto-scroll.js b/client/src/components/trello-board/dnd/lib/state/middleware/auto-scroll.js
new file mode 100644
index 000000000..dc39bc6a0
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/auto-scroll.js
@@ -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());
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/dimension-marshal-stopper.js b/client/src/components/trello-board/dnd/lib/state/middleware/dimension-marshal-stopper.js
new file mode 100644
index 000000000..b9fee45ee
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/dimension-marshal-stopper.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-finish-middleware.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-finish-middleware.js
new file mode 100644
index 000000000..c346df76e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-finish-middleware.js
@@ -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
+ })
+ );
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-flush-on-scroll-middleware.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-flush-on-scroll-middleware.js
new file mode 100644
index 000000000..748820cb6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-animation-flush-on-scroll-middleware.js
@@ -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]);
+ });
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-middleware.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-middleware.js
new file mode 100644
index 000000000..605d1d515
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/drop-middleware.js
@@ -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));
+ };
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-duration.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-duration.js
new file mode 100644
index 000000000..99c613b3d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-duration.js
@@ -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));
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-impact.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-impact.js
new file mode 100644
index 000000000..1f8c50ec7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-drop-impact.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-new-home-client-offset.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-new-home-client-offset.js
new file mode 100644
index 000000000..82d3dfda1
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/get-new-home-client-offset.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/drop/index.js b/client/src/components/trello-board/dnd/lib/state/middleware/drop/index.js
new file mode 100644
index 000000000..ed143b0bd
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/drop/index.js
@@ -0,0 +1 @@
+export { default } from "./drop-middleware";
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/focus.js b/client/src/components/trello-board/dnd/lib/state/middleware/focus.js
new file mode 100644
index 000000000..4fa8b5461
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/focus.js
@@ -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();
+ }
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/lift.js b/client/src/components/trello-board/dnd/lib/state/middleware/lift.js
new file mode 100644
index 000000000..5820be432
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/lift.js
@@ -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
+ })
+ );
+ };
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/pending-drop.js b/client/src/components/trello-board/dnd/lib/state/middleware/pending-drop.js
new file mode 100644
index 000000000..040c4e74c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/pending-drop.js
@@ -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
+ })
+ );
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/async-marshal.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/async-marshal.js
new file mode 100644
index 000000000..a67e4282d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/async-marshal.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/expiring-announce.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/expiring-announce.js
new file mode 100644
index 000000000..e9fd7fd3b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/expiring-announce.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/index.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/index.js
new file mode 100644
index 000000000..a581340d7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/index.js
@@ -0,0 +1 @@
+export { default } from "./responders-middleware";
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/is-equal.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/is-equal.js
new file mode 100644
index 000000000..577c69da7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/is-equal.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/publisher.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/publisher.js
new file mode 100644
index 000000000..b3d6e8494
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/publisher.js
@@ -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
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/responders/responders-middleware.js b/client/src/components/trello-board/dnd/lib/state/middleware/responders/responders-middleware.js
new file mode 100644
index 000000000..b62d7dea3
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/responders/responders-middleware.js
@@ -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);
+ }
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/scroll-listener.js b/client/src/components/trello-board/dnd/lib/state/middleware/scroll-listener.js
new file mode 100644
index 000000000..b02319f79
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/scroll-listener.js
@@ -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);
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/style.js b/client/src/components/trello-board/dnd/lib/state/middleware/style.js
new file mode 100644
index 000000000..619a5b0c6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/style.js
@@ -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);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/middleware/util/validate-dimensions.js b/client/src/components/trello-board/dnd/lib/state/middleware/util/validate-dimensions.js
new file mode 100644
index 000000000..2d9d93906
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/middleware/util/validate-dimensions.js
@@ -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 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);
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/index.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/index.js
new file mode 100644
index 000000000..f171d0ca6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/index.js
@@ -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
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-best-cross-axis-droppable.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-best-cross-axis-droppable.js
new file mode 100644
index 000000000..b2e156b81
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-best-cross-axis-droppable.js
@@ -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];
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-closest-draggable.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-closest-draggable.js
new file mode 100644
index 000000000..6ceb23807
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/get-closest-draggable.js
@@ -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;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/index.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/index.js
new file mode 100644
index 000000000..2419e4540
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/index.js
@@ -0,0 +1,71 @@
+import getBestCrossAxisDroppable from "./get-best-cross-axis-droppable";
+import getClosestDraggable from "./get-closest-draggable";
+// import moveToNewDroppable from './move-to-new-droppable';
+import getDraggablesInsideDroppable from "../../get-draggables-inside-droppable";
+import getClientFromPageBorderBoxCenter from "../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center";
+import getPageBorderBoxCenter from "../../get-center-from-impact/get-page-border-box-center";
+import moveToNewDroppable from "./move-to-new-droppable";
+
+export default ({
+ isMovingForward,
+ previousPageBorderBoxCenter,
+ draggable,
+ isOver,
+ draggables,
+ droppables,
+ viewport,
+ afterCritical
+}) => {
+ // not considering the container scroll changes as container scrolling cancels a keyboard drag
+
+ const destination = getBestCrossAxisDroppable({
+ isMovingForward,
+ pageBorderBoxCenter: previousPageBorderBoxCenter,
+ source: isOver,
+ droppables,
+ viewport
+ });
+
+ // nothing available to move to
+ if (!destination) {
+ return null;
+ }
+ const insideDestination = getDraggablesInsideDroppable(destination.descriptor.id, draggables);
+ const moveRelativeTo = getClosestDraggable({
+ pageBorderBoxCenter: previousPageBorderBoxCenter,
+ viewport,
+ destination,
+ insideDestination,
+ afterCritical
+ });
+ const impact = moveToNewDroppable({
+ previousPageBorderBoxCenter,
+ destination,
+ draggable,
+ draggables,
+ moveRelativeTo,
+ insideDestination,
+ viewport,
+ afterCritical
+ });
+ if (!impact) {
+ return null;
+ }
+ const pageBorderBoxCenter = getPageBorderBoxCenter({
+ impact,
+ draggable,
+ droppable: destination,
+ draggables,
+ afterCritical
+ });
+ const clientSelection = getClientFromPageBorderBoxCenter({
+ pageBorderBoxCenter,
+ draggable,
+ viewport
+ });
+ return {
+ clientSelection,
+ impact,
+ scrollJumpRequest: null
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/move-to-new-droppable.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/move-to-new-droppable.js
new file mode 100644
index 000000000..b96e3293f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/move-to-new-droppable.js
@@ -0,0 +1,86 @@
+import getDisplacedBy from "../../get-displaced-by";
+import { emptyGroups, noDisplacedBy } from "../../no-impact";
+import getPageBorderBoxCenter from "../../get-center-from-impact/get-page-border-box-center";
+import isTotallyVisibleInNewLocation from "../move-to-next-place/is-totally-visible-in-new-location";
+import { addPlaceholder } from "../../droppable/with-placeholder";
+import isHomeOf from "../../droppable/is-home-of";
+import calculateReorderImpact from "../../calculate-drag-impact/calculate-reorder-impact";
+
+export default ({
+ previousPageBorderBoxCenter,
+ moveRelativeTo,
+ insideDestination,
+ draggable,
+ draggables,
+ destination,
+ viewport,
+ afterCritical
+}) => {
+ if (!moveRelativeTo) {
+ // Draggables available, but none are candidates for movement
+ if (insideDestination.length) {
+ return null;
+ }
+
+ // Try move to top of empty list if it is visible
+ const proposed = {
+ displaced: emptyGroups,
+ displacedBy: noDisplacedBy,
+ at: {
+ type: "REORDER",
+ destination: {
+ droppableId: destination.descriptor.id,
+ index: 0
+ }
+ }
+ };
+ const proposedPageBorderBoxCenter = getPageBorderBoxCenter({
+ impact: proposed,
+ draggable,
+ droppable: destination,
+ draggables,
+ afterCritical
+ });
+
+ // need to add room for a placeholder in a foreign list
+ const withPlaceholder = isHomeOf(draggable, destination)
+ ? destination
+ : addPlaceholder(destination, draggable, draggables);
+ const isVisibleInNewLocation = isTotallyVisibleInNewLocation({
+ draggable,
+ destination: withPlaceholder,
+ newPageBorderBoxCenter: proposedPageBorderBoxCenter,
+ viewport: viewport.frame,
+ // already taken into account by getPageBorderBoxCenter
+ withDroppableDisplacement: false,
+ onlyOnMainAxis: true
+ });
+ return isVisibleInNewLocation ? proposed : null;
+ }
+ const isGoingBeforeTarget = Boolean(
+ // Using <= as we optimise slightly for moving before items in a new list
+ // This is nicer in lists with fixed height items
+ previousPageBorderBoxCenter[destination.axis.line] <= moveRelativeTo.page.borderBox.center[destination.axis.line]
+ );
+ const proposedIndex = (() => {
+ const relativeTo = moveRelativeTo.descriptor.index;
+ if (moveRelativeTo.descriptor.id === draggable.descriptor.id) {
+ return relativeTo;
+ }
+ if (isGoingBeforeTarget) {
+ return relativeTo;
+ }
+ return relativeTo + 1;
+ })();
+ const displacedBy = getDisplacedBy(destination.axis, draggable.displaceBy);
+ return calculateReorderImpact({
+ draggable,
+ insideDestination,
+ destination,
+ viewport,
+ displacedBy,
+ // last groups won't be relevant
+ last: emptyGroups,
+ index: proposedIndex
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/without-starting-displacement.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/without-starting-displacement.js
new file mode 100644
index 000000000..5c47f5acc
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-cross-axis/without-starting-displacement.js
@@ -0,0 +1,20 @@
+import { negate, subtract } from "../../position";
+import { offsetByPosition } from "../../spacing";
+import didStartAfterCritical from "../../did-start-after-critical";
+
+export const getCurrentPageBorderBoxCenter = (draggable, afterCritical) => {
+ // If an item started displaced it is now resting
+ // in a non-displaced location
+ const original = draggable.page.borderBox.center;
+ return didStartAfterCritical(draggable.descriptor.id, afterCritical)
+ ? subtract(original, afterCritical.displacedBy.point)
+ : original;
+};
+export const getCurrentPageBorderBox = (draggable, afterCritical) => {
+ // If an item started displaced it is now resting
+ // in a non-displaced location
+ const original = draggable.page.borderBox;
+ return didStartAfterCritical(draggable.descriptor.id, afterCritical)
+ ? offsetByPosition(original, negate(afterCritical.displacedBy.point))
+ : original;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-in-direction-types.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-in-direction-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/index.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/index.js
new file mode 100644
index 000000000..7dcd313e1
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/index.js
@@ -0,0 +1,94 @@
+import getDraggablesInsideDroppable from "../../get-draggables-inside-droppable";
+import moveToNextCombine from "./move-to-next-combine";
+import moveToNextIndex from "./move-to-next-index";
+import isHomeOf from "../../droppable/is-home-of";
+import getPageBorderBoxCenter from "../../get-center-from-impact/get-page-border-box-center";
+import speculativelyIncrease from "../../update-displacement-visibility/speculatively-increase";
+import getClientFromPageBorderBoxCenter from "../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center";
+import { subtract } from "../../position";
+import isTotallyVisibleInNewLocation from "./is-totally-visible-in-new-location";
+
+export default ({
+ isMovingForward,
+ draggable,
+ destination,
+ draggables,
+ previousImpact,
+ viewport,
+ previousPageBorderBoxCenter,
+ previousClientSelection,
+ afterCritical
+}) => {
+ if (!destination.isEnabled) {
+ return null;
+ }
+ const insideDestination = getDraggablesInsideDroppable(destination.descriptor.id, draggables);
+ const isInHomeList = isHomeOf(draggable, destination);
+ const impact =
+ moveToNextCombine({
+ isMovingForward,
+ draggable,
+ destination,
+ insideDestination,
+ previousImpact
+ }) ||
+ moveToNextIndex({
+ isMovingForward,
+ isInHomeList,
+ draggable,
+ draggables,
+ destination,
+ insideDestination,
+ previousImpact,
+ viewport,
+ afterCritical
+ });
+ if (!impact) {
+ return null;
+ }
+ const pageBorderBoxCenter = getPageBorderBoxCenter({
+ impact,
+ draggable,
+ droppable: destination,
+ draggables,
+ afterCritical
+ });
+ const isVisibleInNewLocation = isTotallyVisibleInNewLocation({
+ draggable,
+ destination,
+ newPageBorderBoxCenter: pageBorderBoxCenter,
+ viewport: viewport.frame,
+ // already taken into account by getPageBorderBoxCenter
+ withDroppableDisplacement: false,
+ // we only care about it being visible relative to the main axis
+ // this is important with dynamic changes as scroll bar and toggle
+ // on the cross axis during a drag
+ onlyOnMainAxis: true
+ });
+ if (isVisibleInNewLocation) {
+ // using the client center as the selection point
+ const clientSelection = getClientFromPageBorderBoxCenter({
+ pageBorderBoxCenter,
+ draggable,
+ viewport
+ });
+ return {
+ clientSelection,
+ impact,
+ scrollJumpRequest: null
+ };
+ }
+ const distance = subtract(pageBorderBoxCenter, previousPageBorderBoxCenter);
+ const cautious = speculativelyIncrease({
+ impact,
+ viewport,
+ destination,
+ draggables,
+ maxScrollChange: distance
+ });
+ return {
+ clientSelection: previousClientSelection,
+ impact: cautious,
+ scrollJumpRequest: distance
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js
new file mode 100644
index 000000000..a740ab957
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js
@@ -0,0 +1,28 @@
+import { subtract } from "../../position";
+import { offsetByPosition } from "../../spacing";
+import { isTotallyVisible, isTotallyVisibleOnAxis } from "../../visibility/is-visible";
+
+export default ({
+ draggable,
+ destination,
+ newPageBorderBoxCenter,
+ viewport,
+ withDroppableDisplacement,
+ onlyOnMainAxis = false
+}) => {
+ // What would the location of the Draggable be once the move is completed?
+ // We are not considering margins for this calculation.
+ // This is because a move might move a Draggable slightly outside of the bounds
+ // of a Droppable (which is okay)
+ const changeNeeded = subtract(newPageBorderBoxCenter, draggable.page.borderBox.center);
+ const shifted = offsetByPosition(draggable.page.borderBox, changeNeeded);
+
+ // Must be totally visible, not just partially visible.
+ const args = {
+ target: shifted,
+ destination,
+ withDroppableDisplacement,
+ viewport
+ };
+ return onlyOnMainAxis ? isTotallyVisibleOnAxis(args) : isTotallyVisible(args);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js
new file mode 100644
index 000000000..fc617f942
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js
@@ -0,0 +1,61 @@
+import { invariant } from "../../../../invariant";
+import { tryGetDestination } from "../../../get-impact-location";
+import { findIndex } from "../../../../native-with-fallback";
+import removeDraggableFromList from "../../../remove-draggable-from-list";
+
+export default ({ isMovingForward, draggable, destination, insideDestination, previousImpact }) => {
+ if (!destination.isCombineEnabled) {
+ return null;
+ }
+ const location = tryGetDestination(previousImpact);
+ if (!location) {
+ return null;
+ }
+
+ function getImpact(target) {
+ const at = {
+ type: "COMBINE",
+ combine: {
+ draggableId: target,
+ droppableId: destination.descriptor.id
+ }
+ };
+ return {
+ ...previousImpact,
+ at
+ };
+ }
+
+ const all = previousImpact.displaced.all;
+ const closestId = all.length ? all[0] : null;
+ if (isMovingForward) {
+ return closestId ? getImpact(closestId) : null;
+ }
+ const withoutDraggable = removeDraggableFromList(draggable, insideDestination);
+
+ // Moving backwards
+
+ // if nothing is displaced - move backwards onto the last item
+ if (!closestId) {
+ if (!withoutDraggable.length) {
+ return null;
+ }
+ const last = withoutDraggable[withoutDraggable.length - 1];
+ return getImpact(last.descriptor.id);
+ }
+
+ // We are moving from being between two displaced items
+ // backwards onto the first one
+
+ // need to find the first item before the closest
+ const indexOfClosest = findIndex(withoutDraggable, (d) => d.descriptor.id === closestId);
+ invariant(indexOfClosest !== -1, "Could not find displaced item in set");
+ const proposedIndex = indexOfClosest - 1;
+
+ // There is no displaced item before
+ if (proposedIndex < 0) {
+ return null;
+ }
+ const before = withoutDraggable[proposedIndex];
+ return getImpact(before.descriptor.id);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js
new file mode 100644
index 000000000..ca5a3d312
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js
@@ -0,0 +1,21 @@
+import didStartAfterCritical from "../../../did-start-after-critical";
+
+export default ({ isMovingForward, destination, draggables, combine, afterCritical }) => {
+ if (!destination.isCombineEnabled) {
+ return null;
+ }
+ const combineId = combine.draggableId;
+ const combineWith = draggables[combineId];
+ const combineWithIndex = combineWith.descriptor.index;
+ const didCombineWithStartAfterCritical = didStartAfterCritical(combineId, afterCritical);
+ if (didCombineWithStartAfterCritical) {
+ if (isMovingForward) {
+ return combineWithIndex;
+ }
+ return combineWithIndex - 1;
+ }
+ if (isMovingForward) {
+ return combineWithIndex + 1;
+ }
+ return combineWithIndex;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js
new file mode 100644
index 000000000..14627ab13
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js
@@ -0,0 +1,22 @@
+export default ({ isMovingForward, isInHomeList, insideDestination, location }) => {
+ // cannot move in the list
+ if (!insideDestination.length) {
+ return null;
+ }
+ const currentIndex = location.index;
+ const proposedIndex = isMovingForward ? currentIndex + 1 : currentIndex - 1;
+
+ // Accounting for lists that might not start with an index of 0
+ const firstIndex = insideDestination[0].descriptor.index;
+ const lastIndex = insideDestination[insideDestination.length - 1].descriptor.index;
+
+ // When in foreign list we allow movement after the last item
+ const upperBound = isInHomeList ? lastIndex : lastIndex + 1;
+ if (proposedIndex < firstIndex) {
+ return null;
+ }
+ if (proposedIndex > upperBound) {
+ return null;
+ }
+ return proposedIndex;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/index.js b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/index.js
new file mode 100644
index 000000000..c2c743637
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/move-in-direction/move-to-next-place/move-to-next-index/index.js
@@ -0,0 +1,62 @@
+import { invariant } from "../../../../invariant";
+import calculateReorderImpact from "../../../calculate-drag-impact/calculate-reorder-impact";
+import fromCombine from "./from-combine";
+import fromReorder from "./from-reorder";
+
+export default ({
+ isMovingForward,
+ isInHomeList,
+ draggable,
+ draggables,
+ destination,
+ insideDestination,
+ previousImpact,
+ viewport,
+ afterCritical
+}) => {
+ const wasAt = previousImpact.at;
+ invariant(wasAt, "Cannot move in direction without previous impact location");
+ if (wasAt.type === "REORDER") {
+ const newIndex = fromReorder({
+ isMovingForward,
+ isInHomeList,
+ location: wasAt.destination,
+ insideDestination
+ });
+ // TODO: can we just pass new index on?
+ if (newIndex == null) {
+ return null;
+ }
+ return calculateReorderImpact({
+ draggable,
+ insideDestination,
+ destination,
+ viewport,
+ last: previousImpact.displaced,
+ displacedBy: previousImpact.displacedBy,
+ index: newIndex
+ });
+ }
+
+ // COMBINE
+ const newIndex = fromCombine({
+ isMovingForward,
+ destination,
+ displaced: previousImpact.displaced,
+ draggables,
+ combine: wasAt.combine,
+ afterCritical
+ });
+ if (newIndex == null) {
+ return null;
+ }
+ return calculateReorderImpact({
+ draggable,
+ insideDestination,
+ destination,
+ viewport,
+ last: previousImpact.displaced,
+ displacedBy: previousImpact.displacedBy,
+ index: newIndex
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/no-impact.js b/client/src/components/trello-board/dnd/lib/state/no-impact.js
new file mode 100644
index 000000000..d5548cfce
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/no-impact.js
@@ -0,0 +1,22 @@
+import { origin } from "./position";
+
+export const noDisplacedBy = {
+ point: origin,
+ value: 0
+};
+export const emptyGroups = {
+ invisible: {},
+ visible: {},
+ all: []
+};
+const noImpact = {
+ displaced: emptyGroups,
+ displacedBy: noDisplacedBy,
+ at: null
+};
+export default noImpact;
+export const noAfterCritical = {
+ inVirtualList: false,
+ effected: {},
+ displacedBy: noDisplacedBy
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/patch-dimension-map.js b/client/src/components/trello-board/dnd/lib/state/patch-dimension-map.js
new file mode 100644
index 000000000..89916c514
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/patch-dimension-map.js
@@ -0,0 +1,6 @@
+import patchDroppableMap from "./patch-droppable-map";
+
+export default (dimensions, updated) => ({
+ draggables: dimensions.draggables,
+ droppables: patchDroppableMap(dimensions.droppables, updated)
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/patch-droppable-map.js b/client/src/components/trello-board/dnd/lib/state/patch-droppable-map.js
new file mode 100644
index 000000000..835825b11
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/patch-droppable-map.js
@@ -0,0 +1,4 @@
+export default (droppables, updated) => ({
+ ...droppables,
+ [updated.descriptor.id]: updated
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/position.js b/client/src/components/trello-board/dnd/lib/state/position.js
new file mode 100644
index 000000000..87666d58c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/position.js
@@ -0,0 +1,44 @@
+export const origin = {
+ x: 0,
+ y: 0
+};
+export const add = (point1, point2) => ({
+ x: point1.x + point2.x,
+ y: point1.y + point2.y
+});
+export const subtract = (point1, point2) => ({
+ x: point1.x - point2.x,
+ y: point1.y - point2.y
+});
+export const isEqual = (point1, point2) => point1.x === point2.x && point1.y === point2.y;
+export const negate = (point) => ({
+ // if the value is already 0, do not return -0
+ x: point.x !== 0 ? -point.x : 0,
+ y: point.y !== 0 ? -point.y : 0
+});
+
+// Allows you to build a position from values.
+// Really useful when working with the Axis type
+// patch('x', 5) = { x: 5, y: 0 }
+// patch('y', 5, 1) = { x: 1, y: 5 }
+export const patch = (line, value, otherValue = 0) => ({
+ // set the value of 'x', or 'y'
+ [line]: value,
+ // set the value of the other line
+ [line === "x" ? "y" : "x"]: otherValue
+});
+
+// Returns the distance between two points
+// https://www.mathsisfun.com/algebra/distance-2-points.html
+export const distance = (point1, point2) =>
+ Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2));
+
+// When given a list of points, it finds the smallest distance to any point
+export const closest = (target, points) => Math.min(...points.map((point) => distance(target, point)));
+
+// used to apply any function to both values of a point
+// eg: const floor = apply(Math.floor)(point);
+export const apply = (fn) => (point) => ({
+ x: fn(point.x),
+ y: fn(point.y)
+});
diff --git a/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/refresh-snap.js b/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/refresh-snap.js
new file mode 100644
index 000000000..760a465c0
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/refresh-snap.js
@@ -0,0 +1,40 @@
+import { invariant } from "../../../invariant";
+import whatIsDraggedOver from "../../droppable/what-is-dragged-over";
+import recomputeDisplacementVisibility from "../../update-displacement-visibility/recompute";
+import getClientBorderBoxCenter from "../../get-center-from-impact/get-client-border-box-center";
+import update from "./update";
+
+export default ({ state, dimensions: forcedDimensions, viewport: forcedViewport }) => {
+ invariant(state.movementMode === "SNAP");
+ const needsVisibilityCheck = state.impact;
+ const viewport = forcedViewport || state.viewport;
+ const dimensions = forcedDimensions || state.dimensions;
+ const { draggables, droppables } = dimensions;
+ const draggable = draggables[state.critical.draggable.id];
+ const isOver = whatIsDraggedOver(needsVisibilityCheck);
+ invariant(isOver, "Must be over a destination in SNAP movement mode");
+ const destination = droppables[isOver];
+ const impact = recomputeDisplacementVisibility({
+ impact: needsVisibilityCheck,
+ viewport,
+ destination,
+ draggables
+ });
+ const clientSelection = getClientBorderBoxCenter({
+ impact,
+ draggable,
+ droppable: destination,
+ draggables,
+ viewport,
+ afterCritical: state.afterCritical
+ });
+ return update({
+ // new
+ impact,
+ clientSelection,
+ // pass through
+ state,
+ dimensions,
+ viewport
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/update.js b/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/update.js
new file mode 100644
index 000000000..9efcc198a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/post-reducer/when-moving/update.js
@@ -0,0 +1,82 @@
+import getDragImpact from "../../get-drag-impact";
+import { add, subtract } from "../../position";
+import recomputePlaceholders from "../../recompute-placeholders";
+
+export default ({
+ state,
+ clientSelection: forcedClientSelection,
+ dimensions: forcedDimensions,
+ viewport: forcedViewport,
+ impact: forcedImpact,
+ scrollJumpRequest
+}) => {
+ // DRAGGING: can update position and impact
+ // COLLECTING: can update position but cannot update impact
+
+ const viewport = forcedViewport || state.viewport;
+ const dimensions = forcedDimensions || state.dimensions;
+ const clientSelection = forcedClientSelection || state.current.client.selection;
+ const offset = subtract(clientSelection, state.initial.client.selection);
+ const client = {
+ offset,
+ selection: clientSelection,
+ borderBoxCenter: add(state.initial.client.borderBoxCenter, offset)
+ };
+ const page = {
+ selection: add(client.selection, viewport.scroll.current),
+ borderBoxCenter: add(client.borderBoxCenter, viewport.scroll.current),
+ offset: add(client.offset, viewport.scroll.diff.value)
+ };
+ const current = {
+ client,
+ page
+ };
+
+ // Not updating impact while bulk collecting
+ if (state.phase === "COLLECTING") {
+ return {
+ // adding phase to appease flow (even though it will be overwritten by spread)
+ phase: "COLLECTING",
+ ...state,
+ dimensions,
+ viewport,
+ current
+ };
+ }
+ const draggable = dimensions.draggables[state.critical.draggable.id];
+ const newImpact =
+ forcedImpact ||
+ getDragImpact({
+ pageOffset: page.offset,
+ draggable,
+ draggables: dimensions.draggables,
+ droppables: dimensions.droppables,
+ previousImpact: state.impact,
+ viewport,
+ afterCritical: state.afterCritical
+ });
+ const withUpdatedPlaceholders = recomputePlaceholders({
+ draggable,
+ impact: newImpact,
+ previousImpact: state.impact,
+ draggables: dimensions.draggables,
+ droppables: dimensions.droppables
+ });
+ // dragging!
+ const result = {
+ ...state,
+ current,
+ dimensions: {
+ draggables: dimensions.draggables,
+ droppables: withUpdatedPlaceholders
+ },
+ impact: newImpact,
+ viewport,
+ scrollJumpRequest: scrollJumpRequest || null,
+ // client updates can be applied as a part of a jump scroll
+ // this can be to immediately reverse movement to allow for a nice animation
+ // into the final position
+ forceShouldAnimate: scrollJumpRequest ? false : null
+ };
+ return result;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/adjust-additions-for-scroll-changes.js b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/adjust-additions-for-scroll-changes.js
new file mode 100644
index 000000000..9531a2520
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/adjust-additions-for-scroll-changes.js
@@ -0,0 +1,32 @@
+import { add } from "../position";
+import offsetDraggable from "./offset-draggable";
+import getFrame from "../get-frame";
+
+export default ({ additions, updatedDroppables, viewport }) => {
+ // We need to adjust collected draggables so that they
+ // match the model we had when the drag started.
+ // When a draggable is dynamically collected it does not have
+ // the same relative client position. We need to unwind
+ // any changes in window scroll and droppable scroll so that
+ // the newly collected draggables fit in with our other draggables
+ // and give the same dimensions that would have had if they were
+ // collected at the start of the drag.
+
+ // Need to undo the displacement caused by window scroll changes
+ const windowScrollChange = viewport.scroll.diff.value;
+ // These modified droppables have already had their scroll changes correctly updated
+
+ return additions.map((draggable) => {
+ const droppableId = draggable.descriptor.droppableId;
+ const modified = updatedDroppables[droppableId];
+ const frame = getFrame(modified);
+ const droppableScrollChange = frame.scroll.diff.value;
+ const totalChange = add(windowScrollChange, droppableScrollChange);
+ const moved = offsetDraggable({
+ draggable,
+ offset: totalChange,
+ initialWindowScroll: viewport.scroll.initial
+ });
+ return moved;
+ });
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/index.js b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/index.js
new file mode 100644
index 000000000..ea46436ac
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/index.js
@@ -0,0 +1,109 @@
+import * as timings from "../../debug/timings";
+import getDragImpact from "../get-drag-impact";
+import adjustAdditionsForScrollChanges from "./adjust-additions-for-scroll-changes";
+import { toDraggableMap, toDroppableMap } from "../dimension-structures";
+import getLiftEffect from "../get-lift-effect";
+import scrollDroppable from "../droppable/scroll-droppable";
+import whatIsDraggedOver from "../droppable/what-is-dragged-over";
+
+const timingsKey = "Processing dynamic changes";
+export default ({ state, published }) => {
+ timings.start(timingsKey);
+
+ // TODO: update window scroll (needs to be a part of the published object)
+ // TODO: validate.
+ // - Check that all additions / removals have a droppable
+ // - Check that all droppables are virtual
+
+ // The scroll might be different to what is currently in the state
+ // We want to ensure the new draggables are in step with the state
+ const withScrollChange = published.modified.map((update) => {
+ const existing = state.dimensions.droppables[update.droppableId];
+ const scrolled = scrollDroppable(existing, update.scroll);
+ return scrolled;
+ });
+ const droppables = {
+ ...state.dimensions.droppables,
+ ...toDroppableMap(withScrollChange)
+ };
+ const updatedAdditions = toDraggableMap(
+ adjustAdditionsForScrollChanges({
+ additions: published.additions,
+ updatedDroppables: droppables,
+ viewport: state.viewport
+ })
+ );
+ const draggables = {
+ ...state.dimensions.draggables,
+ ...updatedAdditions
+ };
+
+ // remove all the old ones (except for the critical)
+ // we do this so that list operations remain fast
+ // TODO: need to test the impact of this like crazy
+ published.removals.forEach((id) => {
+ delete draggables[id];
+ });
+ const dimensions = {
+ droppables,
+ draggables
+ };
+ const wasOverId = whatIsDraggedOver(state.impact);
+ const wasOver = wasOverId ? dimensions.droppables[wasOverId] : null;
+ const draggable = dimensions.draggables[state.critical.draggable.id];
+ const home = dimensions.droppables[state.critical.droppable.id];
+ const { impact: onLiftImpact, afterCritical } = getLiftEffect({
+ draggable,
+ home,
+ draggables,
+ viewport: state.viewport
+ });
+ const previousImpact =
+ wasOver && wasOver.isCombineEnabled
+ ? // Cheating here
+ // TODO: pursue a more robust approach
+ state.impact
+ : onLiftImpact;
+ const impact = getDragImpact({
+ pageOffset: state.current.page.offset,
+ draggable: dimensions.draggables[state.critical.draggable.id],
+ draggables: dimensions.draggables,
+ droppables: dimensions.droppables,
+ previousImpact,
+ viewport: state.viewport,
+ afterCritical
+ });
+ timings.finish(timingsKey);
+ const draggingState = {
+ // appeasing flow
+ phase: "DRAGGING",
+ ...state,
+ // eslint-disable-next-line
+ phase: "DRAGGING",
+ impact,
+ onLiftImpact,
+ dimensions,
+ afterCritical,
+ // not animating this movement
+ forceShouldAnimate: false
+ };
+ if (state.phase === "COLLECTING") {
+ return draggingState;
+ }
+
+ // There was a DROP_PENDING
+ // Staying in the DROP_PENDING phase
+ // setting isWaiting for false
+
+ const dropPending = {
+ // appeasing flow
+ phase: "DROP_PENDING",
+ ...draggingState,
+ // eslint-disable-next-line
+ phase: "DROP_PENDING",
+ // No longer waiting
+ reason: state.reason,
+ isWaiting: false
+ };
+ return dropPending;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/offset-draggable.js b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/offset-draggable.js
new file mode 100644
index 000000000..565300522
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/publish-while-dragging-in-virtual/offset-draggable.js
@@ -0,0 +1,16 @@
+import { offset as offsetBox, withScroll } from "css-box-model";
+
+export default ({ draggable, offset, initialWindowScroll }) => {
+ const client = offsetBox(draggable.client, offset);
+ const page = withScroll(client, initialWindowScroll);
+ const moved = {
+ ...draggable,
+ placeholder: {
+ ...draggable.placeholder,
+ client
+ },
+ client,
+ page
+ };
+ return moved;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/recompute-placeholders.js b/client/src/components/trello-board/dnd/lib/state/recompute-placeholders.js
new file mode 100644
index 000000000..4c6a119aa
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/recompute-placeholders.js
@@ -0,0 +1,51 @@
+import { addPlaceholder, removePlaceholder } from "./droppable/with-placeholder";
+import whatIsDraggedOver from "./droppable/what-is-dragged-over";
+import patchDroppableMap from "./patch-droppable-map";
+import isHomeOf from "./droppable/is-home-of";
+
+const clearUnusedPlaceholder = ({ previousImpact, impact, droppables }) => {
+ const last = whatIsDraggedOver(previousImpact);
+ const now = whatIsDraggedOver(impact);
+ if (!last) {
+ return droppables;
+ }
+
+ // no change - can keep the last state
+ if (last === now) {
+ return droppables;
+ }
+ const lastDroppable = droppables[last];
+
+ // nothing to clear
+ if (!lastDroppable.subject.withPlaceholder) {
+ return droppables;
+ }
+ const updated = removePlaceholder(lastDroppable);
+ return patchDroppableMap(droppables, updated);
+};
+export default ({ draggable, draggables, droppables, previousImpact, impact }) => {
+ const cleaned = clearUnusedPlaceholder({
+ previousImpact,
+ impact,
+ droppables
+ });
+ const isOver = whatIsDraggedOver(impact);
+ if (!isOver) {
+ return cleaned;
+ }
+ const droppable = droppables[isOver];
+
+ // no need to add additional space to home droppable
+ if (isHomeOf(draggable, droppable)) {
+ return cleaned;
+ }
+
+ // already have a placeholder - nothing to do here!
+ if (droppable.subject.withPlaceholder) {
+ return cleaned;
+ }
+
+ // Need to patch the existing droppable
+ const patched = addPlaceholder(droppable, draggable, draggables);
+ return patchDroppableMap(cleaned, patched);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/rect.js b/client/src/components/trello-board/dnd/lib/state/rect.js
new file mode 100644
index 000000000..1d2b21b17
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/rect.js
@@ -0,0 +1,4 @@
+import { getRect } from "css-box-model";
+import { offsetByPosition } from "./spacing";
+
+export const offsetRectByPosition = (rect, point) => getRect(offsetByPosition(rect, point));
diff --git a/client/src/components/trello-board/dnd/lib/state/reducer.js b/client/src/components/trello-board/dnd/lib/state/reducer.js
new file mode 100644
index 000000000..d9fdf92fc
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/reducer.js
@@ -0,0 +1,332 @@
+import { invariant } from "../invariant";
+import scrollDroppable from "./droppable/scroll-droppable";
+import moveInDirection from "./move-in-direction";
+import { add, isEqual, origin } from "./position";
+import scrollViewport from "./scroll-viewport";
+import isMovementAllowed from "./is-movement-allowed";
+import { toDroppableList } from "./dimension-structures";
+import update from "./post-reducer/when-moving/update";
+import refreshSnap from "./post-reducer/when-moving/refresh-snap";
+import getLiftEffect from "./get-lift-effect";
+import patchDimensionMap from "./patch-dimension-map";
+import publishWhileDraggingInVirtual from "./publish-while-dragging-in-virtual";
+
+const isSnapping = (state) => state.movementMode === "SNAP";
+const postDroppableChange = (state, updated, isEnabledChanging) => {
+ const dimensions = patchDimensionMap(state.dimensions, updated);
+
+ // if the enabled state is changing, we need to force a update
+ if (!isSnapping(state) || isEnabledChanging) {
+ return update({
+ state,
+ dimensions
+ });
+ }
+ return refreshSnap({
+ state,
+ dimensions
+ });
+};
+
+function removeScrollJumpRequest(state) {
+ if (state.isDragging && state.movementMode === "SNAP") {
+ return {
+ // will be overwritten by spread
+ // needed for flow
+ phase: "DRAGGING",
+ ...state,
+ scrollJumpRequest: null
+ };
+ }
+ return state;
+}
+
+const idle = {
+ phase: "IDLE",
+ completed: null,
+ shouldFlush: false
+};
+export default (state = idle, action) => {
+ if (action.type === "FLUSH") {
+ return {
+ ...idle,
+ shouldFlush: true
+ };
+ }
+ if (action.type === "INITIAL_PUBLISH") {
+ invariant(state.phase === "IDLE", "INITIAL_PUBLISH must come after a IDLE phase");
+ const { critical, clientSelection, viewport, dimensions, movementMode } = action.payload;
+ const draggable = dimensions.draggables[critical.draggable.id];
+ const home = dimensions.droppables[critical.droppable.id];
+ const client = {
+ selection: clientSelection,
+ borderBoxCenter: draggable.client.borderBox.center,
+ offset: origin
+ };
+ const initial = {
+ client,
+ page: {
+ selection: add(client.selection, viewport.scroll.initial),
+ borderBoxCenter: add(client.selection, viewport.scroll.initial),
+ offset: add(client.selection, viewport.scroll.diff.value)
+ }
+ };
+
+ // Can only auto scroll the window if every list is not fixed on the page
+ const isWindowScrollAllowed = toDroppableList(dimensions.droppables).every((item) => !item.isFixedOnPage);
+ const { impact, afterCritical } = getLiftEffect({
+ draggable,
+ home,
+ draggables: dimensions.draggables,
+ viewport
+ });
+ const result = {
+ phase: "DRAGGING",
+ isDragging: true,
+ critical,
+ movementMode,
+ dimensions,
+ initial,
+ current: initial,
+ isWindowScrollAllowed,
+ impact,
+ afterCritical,
+ onLiftImpact: impact,
+ viewport,
+ scrollJumpRequest: null,
+ forceShouldAnimate: null
+ };
+ return result;
+ }
+ if (action.type === "COLLECTION_STARTING") {
+ // A collection might have restarted. We do not care as we are already in the right phase
+ // TODO: remove?
+ if (state.phase === "COLLECTING" || state.phase === "DROP_PENDING") {
+ return state;
+ }
+ invariant(state.phase === "DRAGGING", `Collection cannot start from phase ${state.phase}`);
+ const result = {
+ // putting phase first to appease flow
+ phase: "COLLECTING",
+ ...state,
+ // eslint-disable-next-line
+ phase: "COLLECTING"
+ };
+ return result;
+ }
+ if (action.type === "PUBLISH_WHILE_DRAGGING") {
+ // Unexpected bulk publish
+ invariant(
+ state.phase === "COLLECTING" || state.phase === "DROP_PENDING",
+ `Unexpected ${action.type} received in phase ${state.phase}`
+ );
+ return publishWhileDraggingInVirtual({
+ state,
+ published: action.payload
+ });
+ }
+ if (action.type === "MOVE") {
+ // Not allowing any more movements
+ if (state.phase === "DROP_PENDING") {
+ return state;
+ }
+ invariant(isMovementAllowed(state), `${action.type} not permitted in phase ${state.phase}`);
+ const { client: clientSelection } = action.payload;
+
+ // nothing needs to be done
+ if (isEqual(clientSelection, state.current.client.selection)) {
+ return state;
+ }
+ return update({
+ state,
+ clientSelection,
+ // If we are snap moving - manual movements should not update the impact
+ impact: isSnapping(state) ? state.impact : null
+ });
+ }
+ if (action.type === "UPDATE_DROPPABLE_SCROLL") {
+ // Not allowing changes while a drop is pending
+ // Cannot get this during a DROP_ANIMATING as the dimension
+ // marshal will cancel any pending scroll updates
+ if (state.phase === "DROP_PENDING") {
+ return removeScrollJumpRequest(state);
+ }
+
+ // We will be updating the scroll in response to dynamic changes
+ // manually on the droppable so we can ignore this change
+ if (state.phase === "COLLECTING") {
+ return removeScrollJumpRequest(state);
+ }
+ invariant(isMovementAllowed(state), `${action.type} not permitted in phase ${state.phase}`);
+ const { id, newScroll } = action.payload;
+ const target = state.dimensions.droppables[id];
+
+ // This is possible if a droppable has been asked to watch scroll but
+ // the dimension has not been published yet
+ if (!target) {
+ return state;
+ }
+ const scrolled = scrollDroppable(target, newScroll);
+ return postDroppableChange(state, scrolled, false);
+ }
+ if (action.type === "UPDATE_DROPPABLE_IS_ENABLED") {
+ // Things are locked at this point
+ if (state.phase === "DROP_PENDING") {
+ return state;
+ }
+ invariant(isMovementAllowed(state), `Attempting to move in an unsupported phase ${state.phase}`);
+ const { id, isEnabled } = action.payload;
+ const target = state.dimensions.droppables[id];
+ invariant(target, `Cannot find Droppable[id: ${id}] to toggle its enabled state`);
+ invariant(
+ target.isEnabled !== isEnabled,
+ `Trying to set droppable isEnabled to ${String(isEnabled)}
+ but it is already ${String(target.isEnabled)}`
+ );
+ const updated = {
+ ...target,
+ isEnabled
+ };
+ return postDroppableChange(state, updated, true);
+ }
+ if (action.type === "UPDATE_DROPPABLE_IS_COMBINE_ENABLED") {
+ // Things are locked at this point
+ if (state.phase === "DROP_PENDING") {
+ return state;
+ }
+ invariant(isMovementAllowed(state), `Attempting to move in an unsupported phase ${state.phase}`);
+ const { id, isCombineEnabled } = action.payload;
+ const target = state.dimensions.droppables[id];
+ invariant(target, `Cannot find Droppable[id: ${id}] to toggle its isCombineEnabled state`);
+ invariant(
+ target.isCombineEnabled !== isCombineEnabled,
+ `Trying to set droppable isCombineEnabled to ${String(isCombineEnabled)}
+ but it is already ${String(target.isCombineEnabled)}`
+ );
+ const updated = {
+ ...target,
+ isCombineEnabled
+ };
+ return postDroppableChange(state, updated, true);
+ }
+ if (action.type === "MOVE_BY_WINDOW_SCROLL") {
+ // No longer accepting changes
+ if (state.phase === "DROP_PENDING" || state.phase === "DROP_ANIMATING") {
+ return state;
+ }
+ invariant(isMovementAllowed(state), `Cannot move by window in phase ${state.phase}`);
+ invariant(state.isWindowScrollAllowed, "Window scrolling is currently not supported for fixed lists");
+ const newScroll = action.payload.newScroll;
+
+ // nothing needs to be done
+ if (isEqual(state.viewport.scroll.current, newScroll)) {
+ return removeScrollJumpRequest(state);
+ }
+ const viewport = scrollViewport(state.viewport, newScroll);
+ if (isSnapping(state)) {
+ return refreshSnap({
+ state,
+ viewport
+ });
+ }
+ return update({
+ state,
+ viewport
+ });
+ }
+ if (action.type === "UPDATE_VIEWPORT_MAX_SCROLL") {
+ // Could occur if a transitionEnd occurs after a drag ends
+ if (!isMovementAllowed(state)) {
+ return state;
+ }
+ const maxScroll = action.payload.maxScroll;
+ if (isEqual(maxScroll, state.viewport.scroll.max)) {
+ return state;
+ }
+ const withMaxScroll = {
+ ...state.viewport,
+ scroll: {
+ ...state.viewport.scroll,
+ max: maxScroll
+ }
+ };
+
+ // don't need to recalc any updates
+ return {
+ // phase will be overridden - appeasing flow
+ phase: "DRAGGING",
+ ...state,
+ viewport: withMaxScroll
+ };
+ }
+ if (
+ action.type === "MOVE_UP" ||
+ action.type === "MOVE_DOWN" ||
+ action.type === "MOVE_LEFT" ||
+ action.type === "MOVE_RIGHT"
+ ) {
+ // Not doing keyboard movements during these phases
+ if (state.phase === "COLLECTING" || state.phase === "DROP_PENDING") {
+ return state;
+ }
+ invariant(state.phase === "DRAGGING", `${action.type} received while not in DRAGGING phase`);
+ const result = moveInDirection({
+ state,
+ type: action.type
+ });
+
+ // cannot move in that direction
+ if (!result) {
+ return state;
+ }
+ return update({
+ state,
+ impact: result.impact,
+ clientSelection: result.clientSelection,
+ scrollJumpRequest: result.scrollJumpRequest
+ });
+ }
+ if (action.type === "DROP_PENDING") {
+ const reason = action.payload.reason;
+ invariant(state.phase === "COLLECTING", "Can only move into the DROP_PENDING phase from the COLLECTING phase");
+ const newState = {
+ // appeasing flow
+ phase: "DROP_PENDING",
+ ...state,
+ // eslint-disable-next-line
+ phase: "DROP_PENDING",
+ isWaiting: true,
+ reason
+ };
+ return newState;
+ }
+ if (action.type === "DROP_ANIMATE") {
+ const { completed, dropDuration, newHomeClientOffset } = action.payload;
+ invariant(
+ state.phase === "DRAGGING" || state.phase === "DROP_PENDING",
+ `Cannot animate drop from phase ${state.phase}`
+ );
+
+ // Moving into a new phase
+ const result = {
+ phase: "DROP_ANIMATING",
+ completed,
+ dropDuration,
+ newHomeClientOffset,
+ dimensions: state.dimensions
+ };
+ return result;
+ }
+
+ // Action will be used by responders to call consumers
+ // We can simply return to the idle state
+ if (action.type === "DROP_COMPLETE") {
+ const { completed } = action.payload;
+ return {
+ phase: "IDLE",
+ completed,
+ shouldFlush: false
+ };
+ }
+ return state;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/registry/create-registry.js b/client/src/components/trello-board/dnd/lib/state/registry/create-registry.js
new file mode 100644
index 000000000..93edf263e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/registry/create-registry.js
@@ -0,0 +1,138 @@
+import { invariant } from "../../invariant";
+import { values } from "../../native-with-fallback";
+
+export default function createRegistry() {
+ const entries = {
+ draggables: {},
+ droppables: {}
+ };
+ const subscribers = [];
+
+ function subscribe(cb) {
+ subscribers.push(cb);
+ return function unsubscribe() {
+ const index = subscribers.indexOf(cb);
+
+ // might have been removed by a clean
+ if (index === -1) {
+ return;
+ }
+ subscribers.splice(index, 1);
+ };
+ }
+
+ function notify(event) {
+ if (subscribers.length) {
+ subscribers.forEach((cb) => cb(event));
+ }
+ }
+
+ function findDraggableById(id) {
+ return entries.draggables[id] || null;
+ }
+
+ function getDraggableById(id) {
+ const entry = findDraggableById(id);
+ invariant(entry, `Cannot find draggable entry with id [${id}]`);
+ return entry;
+ }
+
+ const draggableAPI = {
+ register: (entry) => {
+ entries.draggables[entry.descriptor.id] = entry;
+ notify({
+ type: "ADDITION",
+ value: entry
+ });
+ },
+ update: (entry, last) => {
+ const current = entries.draggables[last.descriptor.id];
+
+ // item already removed
+ if (!current) {
+ return;
+ }
+
+ // id already used for another mount
+ if (current.uniqueId !== entry.uniqueId) {
+ return;
+ }
+
+ // We are safe to delete the old entry and add a new one
+ delete entries.draggables[last.descriptor.id];
+ entries.draggables[entry.descriptor.id] = entry;
+ },
+ unregister: (entry) => {
+ const draggableId = entry.descriptor.id;
+ const current = findDraggableById(draggableId);
+
+ // can occur if cleaned before unregistration
+ if (!current) {
+ return;
+ }
+
+ // outdated uniqueId
+ if (entry.uniqueId !== current.uniqueId) {
+ return;
+ }
+ delete entries.draggables[draggableId];
+ notify({
+ type: "REMOVAL",
+ value: entry
+ });
+ },
+ getById: getDraggableById,
+ findById: findDraggableById,
+ exists: (id) => Boolean(findDraggableById(id)),
+ getAllByType: (type) => values(entries.draggables).filter((entry) => entry.descriptor.type === type)
+ };
+
+ function findDroppableById(id) {
+ return entries.droppables[id] || null;
+ }
+
+ function getDroppableById(id) {
+ const entry = findDroppableById(id);
+ invariant(entry, `Cannot find droppable entry with id [${id}]`);
+ return entry;
+ }
+
+ const droppableAPI = {
+ register: (entry) => {
+ entries.droppables[entry.descriptor.id] = entry;
+ },
+ unregister: (entry) => {
+ const current = findDroppableById(entry.descriptor.id);
+
+ // can occur if cleaned before an unregistry
+ if (!current) {
+ return;
+ }
+
+ // already changed
+ if (entry.uniqueId !== current.uniqueId) {
+ return;
+ }
+ delete entries.droppables[entry.descriptor.id];
+ },
+ getById: getDroppableById,
+ findById: findDroppableById,
+ exists: (id) => Boolean(findDroppableById(id)),
+ getAllByType: (type) => values(entries.droppables).filter((entry) => entry.descriptor.type === type)
+ };
+
+ function clean() {
+ // kill entries
+ entries.draggables = {};
+ entries.droppables = {};
+ // remove all subscribers
+ subscribers.length = 0;
+ }
+
+ return {
+ draggable: draggableAPI,
+ droppable: droppableAPI,
+ subscribe,
+ clean
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/registry/registry-types.js b/client/src/components/trello-board/dnd/lib/state/registry/registry-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/registry/use-registry.js b/client/src/components/trello-board/dnd/lib/state/registry/use-registry.js
new file mode 100644
index 000000000..f138f33d7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/registry/use-registry.js
@@ -0,0 +1,16 @@
+import { useEffect } from "react";
+import { useMemo } from "use-memo-one";
+import createRegistry from "./create-registry";
+
+export default function useRegistry() {
+ const registry = useMemo(createRegistry, []);
+ useEffect(() => {
+ return function unmount() {
+ // clean up the registry to avoid any leaks
+ // doing it after an animation frame so that other things unmounting
+ // can continue to interact with the registry
+ requestAnimationFrame(registry.clean);
+ };
+ }, [registry]);
+ return registry;
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/remove-draggable-from-list.js b/client/src/components/trello-board/dnd/lib/state/remove-draggable-from-list.js
new file mode 100644
index 000000000..cbd5c6e24
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/remove-draggable-from-list.js
@@ -0,0 +1,3 @@
+import memoizeOne from "memoize-one";
+
+export default memoizeOne((remove, list) => list.filter((item) => item.descriptor.id !== remove.descriptor.id));
diff --git a/client/src/components/trello-board/dnd/lib/state/scroll-viewport.js b/client/src/components/trello-board/dnd/lib/state/scroll-viewport.js
new file mode 100644
index 000000000..1525a81bc
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/scroll-viewport.js
@@ -0,0 +1,29 @@
+import { getRect } from "css-box-model";
+import { subtract, negate } from "./position";
+
+export default (viewport, newScroll) => {
+ const diff = subtract(newScroll, viewport.scroll.initial);
+ const displacement = negate(diff);
+
+ // We need to update the frame so that it is always a live value
+ // The top / left of the frame should always match the newScroll position
+ const frame = getRect({
+ top: newScroll.y,
+ bottom: newScroll.y + viewport.frame.height,
+ left: newScroll.x,
+ right: newScroll.x + viewport.frame.width
+ });
+ const updated = {
+ frame,
+ scroll: {
+ initial: viewport.scroll.initial,
+ max: viewport.scroll.max,
+ current: newScroll,
+ diff: {
+ value: diff,
+ displacement
+ }
+ }
+ };
+ return updated;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/spacing.js b/client/src/components/trello-board/dnd/lib/state/spacing.js
new file mode 100644
index 000000000..ac5af4342
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/spacing.js
@@ -0,0 +1,44 @@
+// TODO add test
+export const isEqual = (first, second) =>
+ first.top === second.top &&
+ first.right === second.right &&
+ first.bottom === second.bottom &&
+ first.left === second.left;
+export const offsetByPosition = (spacing, point) => ({
+ top: spacing.top + point.y,
+ left: spacing.left + point.x,
+ bottom: spacing.bottom + point.y,
+ right: spacing.right + point.x
+});
+export const expandByPosition = (spacing, position) => ({
+ // pulling back to increase size
+ top: spacing.top - position.y,
+ left: spacing.left - position.x,
+ // pushing forward to increase size
+ right: spacing.right + position.x,
+ bottom: spacing.bottom + position.y
+});
+export const getCorners = (spacing) => [
+ {
+ x: spacing.left,
+ y: spacing.top
+ },
+ {
+ x: spacing.right,
+ y: spacing.top
+ },
+ {
+ x: spacing.left,
+ y: spacing.bottom
+ },
+ {
+ x: spacing.right,
+ y: spacing.bottom
+ }
+];
+export const noSpacing = {
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/store-types.js b/client/src/components/trello-board/dnd/lib/state/store-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/recompute.js b/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/recompute.js
new file mode 100644
index 000000000..3fdf80827
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/recompute.js
@@ -0,0 +1,22 @@
+import getDisplacementGroups from "../get-displacement-groups";
+
+function getDraggables(ids, draggables) {
+ return ids.map((id) => draggables[id]);
+}
+
+export default ({ impact, viewport, draggables, destination, forceShouldAnimate }) => {
+ const last = impact.displaced;
+ const afterDragging = getDraggables(last.all, draggables);
+ const displaced = getDisplacementGroups({
+ afterDragging,
+ destination,
+ displacedBy: impact.displacedBy,
+ viewport: viewport.frame,
+ forceShouldAnimate,
+ last
+ });
+ return {
+ ...impact,
+ displaced
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/speculatively-increase.js b/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/speculatively-increase.js
new file mode 100644
index 000000000..176c71065
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/update-displacement-visibility/speculatively-increase.js
@@ -0,0 +1,69 @@
+import scrollViewport from "../scroll-viewport";
+import scrollDroppable from "../droppable/scroll-droppable";
+import { add } from "../position";
+import getDisplacementGroups from "../get-displacement-groups";
+
+function getDraggables(ids, draggables) {
+ return ids.map((id) => draggables[id]);
+}
+
+function tryGetVisible(id, groups) {
+ for (let i = 0; i < groups.length; i++) {
+ const displacement = groups[i].visible[id];
+ if (displacement) {
+ return displacement;
+ }
+ }
+ return null;
+}
+
+export default ({ impact, viewport, destination, draggables, maxScrollChange }) => {
+ const scrolledViewport = scrollViewport(viewport, add(viewport.scroll.current, maxScrollChange));
+ const scrolledDroppable = destination.frame
+ ? scrollDroppable(destination, add(destination.frame.scroll.current, maxScrollChange))
+ : destination;
+ const last = impact.displaced;
+ const withViewportScroll = getDisplacementGroups({
+ afterDragging: getDraggables(last.all, draggables),
+ destination,
+ displacedBy: impact.displacedBy,
+ viewport: scrolledViewport.frame,
+ last,
+ // we want the addition to be animated
+ forceShouldAnimate: false
+ });
+ const withDroppableScroll = getDisplacementGroups({
+ afterDragging: getDraggables(last.all, draggables),
+ destination: scrolledDroppable,
+ displacedBy: impact.displacedBy,
+ viewport: viewport.frame,
+ last,
+ // we want the addition to be animated
+ forceShouldAnimate: false
+ });
+ const invisible = {};
+ const visible = {};
+ const groups = [
+ // this will populate the previous entries with the correct animation values
+ last,
+ withViewportScroll,
+ withDroppableScroll
+ ];
+ last.all.forEach((id) => {
+ const displacement = tryGetVisible(id, groups);
+ if (displacement) {
+ visible[id] = displacement;
+ return;
+ }
+ invisible[id] = true;
+ });
+ const newImpact = {
+ ...impact,
+ displaced: {
+ all: last.all,
+ invisible,
+ visible
+ }
+ };
+ return newImpact;
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/visibility/is-partially-visible-through-frame.js b/client/src/components/trello-board/dnd/lib/state/visibility/is-partially-visible-through-frame.js
new file mode 100644
index 000000000..2209fb382
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/visibility/is-partially-visible-through-frame.js
@@ -0,0 +1,44 @@
+import isWithin from "../is-within";
+
+export default (frame) => {
+ const isWithinVertical = isWithin(frame.top, frame.bottom);
+ const isWithinHorizontal = isWithin(frame.left, frame.right);
+ return (subject) => {
+ // situations where target is visible:
+ // 1. is completely contained within frame
+ // 2. is partially visible on both axis within frame
+ // 3. is bigger than frame on both axis
+ // 4. is bigger than frame on one axis and is partially visible on the other
+
+ // completely contained
+ const isContained =
+ isWithinVertical(subject.top) &&
+ isWithinVertical(subject.bottom) &&
+ isWithinHorizontal(subject.left) &&
+ isWithinHorizontal(subject.right);
+ if (isContained) {
+ return true;
+ }
+ const isPartiallyVisibleVertically = isWithinVertical(subject.top) || isWithinVertical(subject.bottom);
+ const isPartiallyVisibleHorizontally = isWithinHorizontal(subject.left) || isWithinHorizontal(subject.right);
+
+ // partially visible on both axis
+ const isPartiallyContained = isPartiallyVisibleVertically && isPartiallyVisibleHorizontally;
+ if (isPartiallyContained) {
+ return true;
+ }
+ const isBiggerVertically = subject.top < frame.top && subject.bottom > frame.bottom;
+ const isBiggerHorizontally = subject.left < frame.left && subject.right > frame.right;
+
+ // is bigger than frame on both axis
+ const isTargetBiggerThanFrame = isBiggerVertically && isBiggerHorizontally;
+ if (isTargetBiggerThanFrame) {
+ return true;
+ }
+
+ // is bigger on one axis, and partially visible on another
+ const isTargetBiggerOnOneAxis =
+ (isBiggerVertically && isPartiallyVisibleHorizontally) || (isBiggerHorizontally && isPartiallyVisibleVertically);
+ return isTargetBiggerOnOneAxis;
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/visibility/is-position-in-frame.js b/client/src/components/trello-board/dnd/lib/state/visibility/is-position-in-frame.js
new file mode 100644
index 000000000..d7c4b3def
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/visibility/is-position-in-frame.js
@@ -0,0 +1,9 @@
+import isWithin from "../is-within";
+
+export default function isPositionInFrame(frame) {
+ const isWithinVertical = isWithin(frame.top, frame.bottom);
+ const isWithinHorizontal = isWithin(frame.left, frame.right);
+ return function run(point) {
+ return isWithinVertical(point.y) && isWithinHorizontal(point.x);
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame-on-axis.js b/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame-on-axis.js
new file mode 100644
index 000000000..16e2c86d1
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame-on-axis.js
@@ -0,0 +1,13 @@
+import isWithin from "../is-within";
+import { vertical } from "../axis";
+
+export default (axis) => (frame) => {
+ const isWithinVertical = isWithin(frame.top, frame.bottom);
+ const isWithinHorizontal = isWithin(frame.left, frame.right);
+ return (subject) => {
+ if (axis === vertical) {
+ return isWithinVertical(subject.top) && isWithinVertical(subject.bottom);
+ }
+ return isWithinHorizontal(subject.left) && isWithinHorizontal(subject.right);
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame.js b/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame.js
new file mode 100644
index 000000000..c768c1a96
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/visibility/is-totally-visible-through-frame.js
@@ -0,0 +1,14 @@
+import isWithin from "../is-within";
+
+export default (frame) => {
+ const isWithinVertical = isWithin(frame.top, frame.bottom);
+ const isWithinHorizontal = isWithin(frame.left, frame.right);
+ return (subject) => {
+ const isContained =
+ isWithinVertical(subject.top) &&
+ isWithinVertical(subject.bottom) &&
+ isWithinHorizontal(subject.left) &&
+ isWithinHorizontal(subject.right);
+ return isContained;
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/visibility/is-visible.js b/client/src/components/trello-board/dnd/lib/state/visibility/is-visible.js
new file mode 100644
index 000000000..c826b712d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/visibility/is-visible.js
@@ -0,0 +1,53 @@
+import isPartiallyVisibleThroughFrame from "./is-partially-visible-through-frame";
+import isTotallyVisibleThroughFrame from "./is-totally-visible-through-frame";
+import isTotallyVisibleThroughFrameOnAxis from "./is-totally-visible-through-frame-on-axis";
+import { offsetByPosition } from "../spacing";
+import { origin } from "../position";
+
+const getDroppableDisplaced = (target, destination) => {
+ const displacement = destination.frame ? destination.frame.scroll.diff.displacement : origin;
+ return offsetByPosition(target, displacement);
+};
+const isVisibleInDroppable = (target, destination, isVisibleThroughFrameFn) => {
+ // destination subject is totally hidden by frame
+ // this should never happen - but just guarding against it
+ if (!destination.subject.active) {
+ return false;
+ }
+
+ // When considering if the target is visible in the droppable we need
+ // to consider the change in scroll of the droppable. We need to
+ // adjust for the scroll as the clipped viewport takes into account
+ // the scroll of the droppable.
+
+ return isVisibleThroughFrameFn(destination.subject.active)(target);
+};
+const isVisibleInViewport = (target, viewport, isVisibleThroughFrameFn) => isVisibleThroughFrameFn(viewport)(target);
+const isVisible = ({
+ target: toBeDisplaced,
+ destination,
+ viewport,
+ withDroppableDisplacement,
+ isVisibleThroughFrameFn
+}) => {
+ const displacedTarget = withDroppableDisplacement ? getDroppableDisplaced(toBeDisplaced, destination) : toBeDisplaced;
+ return (
+ isVisibleInDroppable(displacedTarget, destination, isVisibleThroughFrameFn) &&
+ isVisibleInViewport(displacedTarget, viewport, isVisibleThroughFrameFn)
+ );
+};
+export const isPartiallyVisible = (args) =>
+ isVisible({
+ ...args,
+ isVisibleThroughFrameFn: isPartiallyVisibleThroughFrame
+ });
+export const isTotallyVisible = (args) =>
+ isVisible({
+ ...args,
+ isVisibleThroughFrameFn: isTotallyVisibleThroughFrame
+ });
+export const isTotallyVisibleOnAxis = (args) =>
+ isVisible({
+ ...args,
+ isVisibleThroughFrameFn: isTotallyVisibleThroughFrameOnAxis(args.destination.axis)
+ });
diff --git a/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-all-displacement.js b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-all-displacement.js
new file mode 100644
index 000000000..2da09dcbb
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-all-displacement.js
@@ -0,0 +1,5 @@
+import withDroppableDisplacement from "./with-droppable-displacement";
+import withViewportDisplacement from "./with-viewport-displacement";
+
+export default (page, droppable, viewport) =>
+ withDroppableDisplacement(droppable, withViewportDisplacement(viewport, page));
diff --git a/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-displacement.js b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-displacement.js
new file mode 100644
index 000000000..ed4bb9674
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-displacement.js
@@ -0,0 +1,9 @@
+import { add } from "../position";
+
+export default (droppable, point) => {
+ const frame = droppable.frame;
+ if (!frame) {
+ return point;
+ }
+ return add(point, frame.scroll.diff.displacement);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-scroll.js b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-scroll.js
new file mode 100644
index 000000000..d0e747ccf
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-droppable-scroll.js
@@ -0,0 +1,9 @@
+import { offsetRectByPosition } from "../rect";
+
+export default (droppable, area) => {
+ const frame = droppable.frame;
+ if (!frame) {
+ return area;
+ }
+ return offsetRectByPosition(area, frame.scroll.diff.value);
+};
diff --git a/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-viewport-displacement.js b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-viewport-displacement.js
new file mode 100644
index 000000000..c47d0e51c
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/state/with-scroll-change/with-viewport-displacement.js
@@ -0,0 +1,3 @@
+import { add } from "../position";
+
+export default (viewport, point) => add(viewport.scroll.diff.displacement, point);
diff --git a/client/src/components/trello-board/dnd/lib/types.js b/client/src/components/trello-board/dnd/lib/types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/animate-in-out/animate-in-out.js b/client/src/components/trello-board/dnd/lib/view/animate-in-out/animate-in-out.js
new file mode 100644
index 000000000..cf4773828
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/animate-in-out/animate-in-out.js
@@ -0,0 +1,72 @@
+import React from "react";
+// Using a class here rather than hooks because
+// getDerivedStateFromProps results in far less renders.
+// Using hooks to implement this was quite messy and resulted in lots of additional renders
+
+export default class AnimateInOut extends React.PureComponent {
+ state = {
+ isVisible: Boolean(this.props.on),
+ data: this.props.on,
+ // not allowing to animate close on mount
+ animate: this.props.shouldAnimate && this.props.on ? "open" : "none"
+ };
+
+ static getDerivedStateFromProps(props, state) {
+ if (!props.shouldAnimate) {
+ return {
+ isVisible: Boolean(props.on),
+ data: props.on,
+ animate: "none"
+ };
+ }
+
+ // need to animate in
+ if (props.on) {
+ return {
+ isVisible: true,
+ // have new data to animate in with
+ data: props.on,
+ animate: "open"
+ };
+ }
+
+ // need to animate out if there was data
+
+ if (state.isVisible) {
+ return {
+ isVisible: true,
+ // use old data for animating out
+ data: state.data,
+ animate: "close"
+ };
+ }
+
+ // close animation no longer visible
+ return {
+ isVisible: false,
+ animate: "close",
+ data: null
+ };
+ }
+
+ onClose = () => {
+ if (this.state.animate !== "close") {
+ return;
+ }
+ this.setState({
+ isVisible: false
+ });
+ };
+
+ render() {
+ if (!this.state.isVisible) {
+ return null;
+ }
+ const provided = {
+ onClose: this.onClose,
+ data: this.state.data,
+ animate: this.state.animate
+ };
+ return this.props.children(provided);
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/animate-in-out/index.js b/client/src/components/trello-board/dnd/lib/view/animate-in-out/index.js
new file mode 100644
index 000000000..94ff0452d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/animate-in-out/index.js
@@ -0,0 +1 @@
+export { default } from "./animate-in-out";
diff --git a/client/src/components/trello-board/dnd/lib/view/check-is-valid-inner-ref.js b/client/src/components/trello-board/dnd/lib/view/check-is-valid-inner-ref.js
new file mode 100644
index 000000000..427eec5d5
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/check-is-valid-inner-ref.js
@@ -0,0 +1,14 @@
+import { invariant } from "../invariant";
+import isHtmlElement from "./is-type-of-element/is-html-element";
+
+export default function checkIsValidInnerRef(el) {
+ invariant(
+ el && isHtmlElement(el),
+ `
+ provided.innerRef has not been provided with a HTMLElement.
+
+ You can find a guide on using the innerRef callback functions at:
+ https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/using-inner-ref.md
+ `
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/context/app-context.js b/client/src/components/trello-board/dnd/lib/view/context/app-context.js
new file mode 100644
index 000000000..20bb3218b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/context/app-context.js
@@ -0,0 +1,3 @@
+import React from "react";
+
+export default /*#__PURE__*/ React.createContext(null);
diff --git a/client/src/components/trello-board/dnd/lib/view/context/droppable-context.js b/client/src/components/trello-board/dnd/lib/view/context/droppable-context.js
new file mode 100644
index 000000000..20bb3218b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/context/droppable-context.js
@@ -0,0 +1,3 @@
+import React from "react";
+
+export default /*#__PURE__*/ React.createContext(null);
diff --git a/client/src/components/trello-board/dnd/lib/view/context/store-context.js b/client/src/components/trello-board/dnd/lib/view/context/store-context.js
new file mode 100644
index 000000000..20bb3218b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/context/store-context.js
@@ -0,0 +1,3 @@
+import React from "react";
+
+export default /*#__PURE__*/ React.createContext(null);
diff --git a/client/src/components/trello-board/dnd/lib/view/data-attributes.js b/client/src/components/trello-board/dnd/lib/view/data-attributes.js
new file mode 100644
index 000000000..80f01da4e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/data-attributes.js
@@ -0,0 +1,31 @@
+export const prefix = "data-rbd";
+export const dragHandle = (() => {
+ const base = `${prefix}-drag-handle`;
+ return {
+ base,
+ draggableId: `${base}-draggable-id`,
+ contextId: `${base}-context-id`
+ };
+})();
+export const draggable = (() => {
+ const base = `${prefix}-draggable`;
+ return {
+ base,
+ contextId: `${base}-context-id`,
+ id: `${base}-id`
+ };
+})();
+export const droppable = (() => {
+ const base = `${prefix}-droppable`;
+ return {
+ base,
+ contextId: `${base}-context-id`,
+ id: `${base}-id`
+ };
+})();
+export const placeholder = {
+ contextId: `${prefix}-placeholder-context-id`
+};
+export const scrollContainer = {
+ contextId: `${prefix}-scroll-container-context-id`
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/app.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/app.js
new file mode 100644
index 000000000..dba1a9997
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/app.js
@@ -0,0 +1,196 @@
+import React, { useEffect, useRef } from "react";
+import { bindActionCreators } from "redux";
+import { Provider } from "react-redux";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../invariant";
+import createStore from "../../state/create-store";
+import createDimensionMarshal from "../../state/dimension-marshal/dimension-marshal";
+import canStartDrag from "../../state/can-start-drag";
+import scrollWindow from "../window/scroll-window";
+import createAutoScroller from "../../state/auto-scroller";
+import useStyleMarshal from "../use-style-marshal/use-style-marshal";
+import useFocusMarshal from "../use-focus-marshal";
+import useRegistry from "../../state/registry/use-registry";
+import StoreContext from "../context/store-context";
+import {
+ collectionStarting,
+ flush,
+ move,
+ publishWhileDragging,
+ updateDroppableIsCombineEnabled,
+ updateDroppableIsEnabled,
+ updateDroppableScroll
+} from "../../state/action-creators";
+import isMovementAllowed from "../../state/is-movement-allowed";
+import useAnnouncer from "../use-announcer";
+import useHiddenTextElement from "../use-hidden-text-element";
+import AppContext from "../context/app-context";
+import useStartupValidation from "./use-startup-validation";
+import usePrevious from "../use-previous-ref";
+import { warning } from "../../dev-warning";
+import useSensorMarshal from "../use-sensor-marshal/use-sensor-marshal";
+
+const createResponders = (props) => ({
+ onBeforeCapture: props.onBeforeCapture,
+ onBeforeDragStart: props.onBeforeDragStart,
+ onDragStart: props.onDragStart,
+ onDragEnd: props.onDragEnd,
+ onDragUpdate: props.onDragUpdate
+});
+
+// flow does not support MutableRefObject
+// type LazyStoreRef = MutableRefObject;
+
+function getStore(lazyRef) {
+ invariant(lazyRef.current, "Could not find store from lazy ref");
+ return lazyRef.current;
+}
+
+export default function App(props) {
+ const { contextId, setCallbacks, sensors, nonce, dragHandleUsageInstructions } = props;
+ const lazyStoreRef = useRef(null);
+ useStartupValidation();
+
+ // lazy collection of responders using a ref - update on ever render
+ const lastPropsRef = usePrevious(props);
+ const getResponders = useCallback(() => {
+ return createResponders(lastPropsRef.current);
+ }, [lastPropsRef]);
+ const announce = useAnnouncer(contextId);
+ const dragHandleUsageInstructionsId = useHiddenTextElement({
+ contextId,
+ text: dragHandleUsageInstructions
+ });
+ const styleMarshal = useStyleMarshal(contextId, nonce);
+ const lazyDispatch = useCallback((action) => {
+ getStore(lazyStoreRef).dispatch(action);
+ }, []);
+ const marshalCallbacks = useMemo(
+ () =>
+ bindActionCreators(
+ {
+ publishWhileDragging,
+ updateDroppableScroll,
+ updateDroppableIsEnabled,
+ updateDroppableIsCombineEnabled,
+ collectionStarting
+ },
+ // $FlowFixMe - not sure why this is wrong
+ lazyDispatch
+ ),
+ [lazyDispatch]
+ );
+ const registry = useRegistry();
+ const dimensionMarshal = useMemo(() => {
+ return createDimensionMarshal(registry, marshalCallbacks);
+ }, [registry, marshalCallbacks]);
+ const autoScroller = useMemo(
+ () =>
+ createAutoScroller({
+ scrollWindow,
+ scrollDroppable: dimensionMarshal.scrollDroppable,
+ ...bindActionCreators(
+ {
+ move
+ },
+ // $FlowFixMe - not sure why this is wrong
+ lazyDispatch
+ )
+ }),
+ [dimensionMarshal.scrollDroppable, lazyDispatch]
+ );
+ const focusMarshal = useFocusMarshal(contextId);
+ const store = useMemo(
+ () =>
+ createStore({
+ announce,
+ autoScroller,
+ dimensionMarshal,
+ focusMarshal,
+ getResponders,
+ styleMarshal
+ }),
+ [announce, autoScroller, dimensionMarshal, focusMarshal, getResponders, styleMarshal]
+ );
+
+ // Checking for unexpected store changes
+ if (process.env.NODE_ENV !== "production") {
+ if (lazyStoreRef.current && lazyStoreRef.current !== store) {
+ warning("unexpected store change");
+ }
+ }
+
+ // assigning lazy store ref
+ lazyStoreRef.current = store;
+ const tryResetStore = useCallback(() => {
+ const current = getStore(lazyStoreRef);
+ const state = current.getState();
+ if (state.phase !== "IDLE") {
+ current.dispatch(flush());
+ }
+ }, []);
+ const isDragging = useCallback(() => {
+ const state = getStore(lazyStoreRef).getState();
+ return state.isDragging || state.phase === "DROP_ANIMATING";
+ }, []);
+ const appCallbacks = useMemo(
+ () => ({
+ isDragging,
+ tryAbort: tryResetStore
+ }),
+ [isDragging, tryResetStore]
+ );
+
+ // doing this in render rather than a side effect so any errors on the
+ // initial mount are caught
+ setCallbacks(appCallbacks);
+ const getCanLift = useCallback((id) => canStartDrag(getStore(lazyStoreRef).getState(), id), []);
+ const getIsMovementAllowed = useCallback(() => isMovementAllowed(getStore(lazyStoreRef).getState()), []);
+ const appContext = useMemo(
+ () => ({
+ marshal: dimensionMarshal,
+ focus: focusMarshal,
+ contextId,
+ canLift: getCanLift,
+ isMovementAllowed: getIsMovementAllowed,
+ dragHandleUsageInstructionsId,
+ registry
+ }),
+ [
+ contextId,
+ dimensionMarshal,
+ dragHandleUsageInstructionsId,
+ focusMarshal,
+ getCanLift,
+ getIsMovementAllowed,
+ registry
+ ]
+ );
+ useSensorMarshal({
+ contextId,
+ store,
+ registry,
+ customSensors: sensors,
+ // default to 'true' unless 'false' is explicitly passed
+ enableDefaultSensors: props.enableDefaultSensors !== false
+ });
+
+ // Clean store when unmounting
+ useEffect(() => {
+ return tryResetStore;
+ }, [tryResetStore]);
+ return /*#__PURE__*/ React.createElement(
+ AppContext.Provider,
+ {
+ value: appContext
+ },
+ /*#__PURE__*/ React.createElement(
+ Provider,
+ {
+ context: StoreContext,
+ store: store
+ },
+ props.children
+ )
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-doctype.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-doctype.js
new file mode 100644
index 000000000..e09e94dff
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-doctype.js
@@ -0,0 +1,34 @@
+import { warning } from "../../dev-warning";
+
+const suffix = `
+ We expect a html5 doctype:
+ This is to ensure consistent browser layout and measurement
+
+ More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/doctype.md
+`;
+export default (doc) => {
+ const doctype = doc.doctype;
+ if (!doctype) {
+ warning(`
+ No found.
+
+ ${suffix}
+ `);
+ return;
+ }
+ if (doctype.name.toLowerCase() !== "html") {
+ warning(`
+ Unexpected found: (${doctype.name})
+
+ ${suffix}
+ `);
+ }
+ if (doctype.publicId !== "") {
+ warning(`
+ Unexpected publicId found: (${doctype.publicId})
+ A html5 doctype does not have a publicId
+
+ ${suffix}
+ `);
+ }
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-react-version.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-react-version.js
new file mode 100644
index 000000000..68b11e050
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/check-react-version.js
@@ -0,0 +1,53 @@
+import { invariant } from "../../invariant";
+import { warning } from "../../dev-warning";
+// We can use a simple regex here given that:
+// - the version that react supplies is always full: eg 16.5.2
+// - our peer dependency version is to a full version (eg ^16.3.1)
+const semver = /(\d+)\.(\d+)\.(\d+)/;
+const getVersion = (value) => {
+ const result = semver.exec(value);
+ invariant(result != null, `Unable to parse React version ${value}`);
+ const major = Number(result[1]);
+ const minor = Number(result[2]);
+ const patch = Number(result[3]);
+ return {
+ major,
+ minor,
+ patch,
+ raw: value
+ };
+};
+const isSatisfied = (expected, actual) => {
+ if (actual.major > expected.major) {
+ return true;
+ }
+ if (actual.major < expected.major) {
+ return false;
+ }
+
+ // major is equal, continue on
+
+ if (actual.minor > expected.minor) {
+ return true;
+ }
+ if (actual.minor < expected.minor) {
+ return false;
+ }
+
+ // minor is equal, continue on
+
+ return actual.patch >= expected.patch;
+};
+export default (peerDepValue, actualValue) => {
+ const peerDep = getVersion(peerDepValue);
+ const actual = getVersion(actualValue);
+ if (isSatisfied(peerDep, actual)) {
+ return;
+ }
+ warning(`
+ React version: [${actual.raw}]
+ does not satisfy expected peer dependency version: [${peerDep.raw}]
+
+ This can result in run time bugs, and even fatal crashes
+ `);
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/drag-drop-context-types.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/drag-drop-context-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/drag-drop-context.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/drag-drop-context.js
new file mode 100644
index 000000000..0dea70301
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/drag-drop-context.js
@@ -0,0 +1,39 @@
+import React from "react";
+import ErrorBoundary from "./error-boundary";
+import preset from "../../screen-reader-message-preset";
+import App from "./app";
+import useUniqueContextId, { reset as resetContextId } from "./use-unique-context-id";
+import { reset as resetUniqueIds } from "../use-unique-id";
+
+// Reset any context that gets persisted across server side renders
+export function resetServerContext() {
+ resetContextId();
+ resetUniqueIds();
+}
+
+export default function DragDropContext(props) {
+ const contextId = useUniqueContextId();
+ const dragHandleUsageInstructions = props.dragHandleUsageInstructions || preset.dragHandleUsageInstructions;
+
+ // We need the error boundary to be on the outside of App
+ // so that it can catch any errors caused by App
+ return /*#__PURE__*/ React.createElement(ErrorBoundary, null, (setCallbacks) =>
+ /*#__PURE__*/ React.createElement(
+ App,
+ {
+ nonce: props.nonce,
+ contextId: contextId,
+ setCallbacks: setCallbacks,
+ dragHandleUsageInstructions: dragHandleUsageInstructions,
+ enableDefaultSensors: props.enableDefaultSensors,
+ sensors: props.sensors,
+ onBeforeCapture: props.onBeforeCapture,
+ onBeforeDragStart: props.onBeforeDragStart,
+ onDragStart: props.onDragStart,
+ onDragUpdate: props.onDragUpdate,
+ onDragEnd: props.onDragEnd
+ },
+ props.children
+ )
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/error-boundary.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/error-boundary.js
new file mode 100644
index 000000000..b12c7ad2f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/error-boundary.js
@@ -0,0 +1,73 @@
+import React from "react";
+import { error, warning } from "../../dev-warning";
+import { noop } from "../../empty";
+import bindEvents from "../event-bindings/bind-events";
+import { RbdInvariant } from "../../invariant";
+
+// Lame that this is not in flow
+
+export default class ErrorBoundary extends React.Component {
+ callbacks = null;
+ unbind = noop;
+
+ componentDidMount() {
+ this.unbind = bindEvents(window, [
+ {
+ eventName: "error",
+ fn: this.onWindowError
+ }
+ ]);
+ }
+
+ componentDidCatch(err) {
+ if (err instanceof RbdInvariant) {
+ if (process.env.NODE_ENV !== "production") {
+ error(err.message);
+ }
+ this.setState({});
+ return;
+ }
+
+ // throwing error for other error boundaries
+ // eslint-disable-next-line no-restricted-syntax
+ throw err;
+ }
+
+ componentWillUnmount() {
+ this.unbind();
+ }
+
+ onWindowError = (event) => {
+ const callbacks = this.getCallbacks();
+ if (callbacks.isDragging()) {
+ callbacks.tryAbort();
+ warning(`
+ An error was caught by our window 'error' event listener while a drag was occurring.
+ The active drag has been aborted.
+ `);
+ }
+ const err = event.error;
+ if (err instanceof RbdInvariant) {
+ // Marking the event as dealt with.
+ // This will prevent any 'uncaught' error warnings in the console
+ event.preventDefault();
+ if (process.env.NODE_ENV !== "production") {
+ error(err.message);
+ }
+ }
+ };
+ getCallbacks = () => {
+ if (!this.callbacks) {
+ // eslint-disable-next-line no-restricted-syntax
+ throw new Error("Unable to find AppCallbacks in ");
+ }
+ return this.callbacks;
+ };
+ setCallbacks = (callbacks) => {
+ this.callbacks = callbacks;
+ };
+
+ render() {
+ return this.props.children(this.setCallbacks);
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/index.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/index.js
new file mode 100644
index 000000000..0214cda4d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/index.js
@@ -0,0 +1 @@
+export { default, resetServerContext } from "./drag-drop-context";
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-startup-validation.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-startup-validation.js
new file mode 100644
index 000000000..ddae9185f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-startup-validation.js
@@ -0,0 +1,8 @@
+import checkDoctype from "./check-doctype";
+import useDevSetupWarning from "../use-dev-setup-warning";
+
+export default function useStartupValidation() {
+ useDevSetupWarning(() => {
+ checkDoctype(document);
+ }, []);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-unique-context-id.js b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-unique-context-id.js
new file mode 100644
index 000000000..4e1dd7208
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/drag-drop-context/use-unique-context-id.js
@@ -0,0 +1,11 @@
+import { useMemo } from "use-memo-one";
+
+let count = 0;
+
+export function reset() {
+ count = 0;
+}
+
+export default function useInstanceCount() {
+ return useMemo(() => `${count++}`, []);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/connected-draggable.js b/client/src/components/trello-board/dnd/lib/view/draggable/connected-draggable.js
new file mode 100644
index 000000000..95697a828
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/connected-draggable.js
@@ -0,0 +1,316 @@
+// eslint-disable-next-line
+import memoizeOne from "memoize-one";
+import { connect } from "react-redux";
+import Draggable from "./draggable";
+import { negate, origin } from "../../state/position";
+import isStrictEqual from "../is-strict-equal";
+import * as animation from "../../animation";
+import { dropAnimationFinished as dropAnimationFinishedAction } from "../../state/action-creators";
+import whatIsDraggedOver from "../../state/droppable/what-is-dragged-over";
+import StoreContext from "../context/store-context";
+import whatIsDraggedOverFromResult from "../../state/droppable/what-is-dragged-over-from-result";
+import { tryGetCombine } from "../../state/get-impact-location";
+
+const getCombineWithFromResult = (result) => {
+ return result.combine ? result.combine.draggableId : null;
+};
+const getCombineWithFromImpact = (impact) => {
+ return impact.at && impact.at.type === "COMBINE" ? impact.at.combine.draggableId : null;
+};
+
+function getDraggableSelector() {
+ const memoizedOffset = memoizeOne((x, y) => ({
+ x,
+ y
+ }));
+ const getMemoizedSnapshot = memoizeOne((mode, isClone, draggingOver, combineWith, dropping) => ({
+ isDragging: true,
+ isClone,
+ isDropAnimating: Boolean(dropping),
+ dropAnimation: dropping,
+ mode,
+ draggingOver,
+ combineWith,
+ combineTargetFor: null
+ }));
+ const getMemoizedProps = memoizeOne(
+ (offset, mode, dimension, isClone, draggingOver, combineWith, forceShouldAnimate) => ({
+ mapped: {
+ type: "DRAGGING",
+ dropping: null,
+ draggingOver,
+ combineWith,
+ mode,
+ offset,
+ dimension,
+ forceShouldAnimate,
+ snapshot: getMemoizedSnapshot(mode, isClone, draggingOver, combineWith, null)
+ }
+ })
+ );
+ const selector = (state, ownProps) => {
+ // Dragging
+ if (state.isDragging) {
+ // not the dragging item
+ if (state.critical.draggable.id !== ownProps.draggableId) {
+ return null;
+ }
+ const offset = state.current.client.offset;
+ const dimension = state.dimensions.draggables[ownProps.draggableId];
+ // const shouldAnimateDragMovement: boolean = state.shouldAnimate;
+ const draggingOver = whatIsDraggedOver(state.impact);
+ const combineWith = getCombineWithFromImpact(state.impact);
+ const forceShouldAnimate = state.forceShouldAnimate;
+ return getMemoizedProps(
+ memoizedOffset(offset.x, offset.y),
+ state.movementMode,
+ dimension,
+ ownProps.isClone,
+ draggingOver,
+ combineWith,
+ forceShouldAnimate
+ );
+ }
+
+ // Dropping
+ if (state.phase === "DROP_ANIMATING") {
+ const completed = state.completed;
+ if (completed.result.draggableId !== ownProps.draggableId) {
+ return null;
+ }
+ const isClone = ownProps.isClone;
+ const dimension = state.dimensions.draggables[ownProps.draggableId];
+ const result = completed.result;
+ const mode = result.mode;
+ // these need to be pulled from the result as they can be different to the final impact
+ const draggingOver = whatIsDraggedOverFromResult(result);
+ const combineWith = getCombineWithFromResult(result);
+ const duration = state.dropDuration;
+
+ // not memoized as it is the only execution
+ const dropping = {
+ duration,
+ curve: animation.curves.drop,
+ moveTo: state.newHomeClientOffset,
+ opacity: combineWith ? animation.combine.opacity.drop : null,
+ scale: combineWith ? animation.combine.scale.drop : null
+ };
+ return {
+ mapped: {
+ type: "DRAGGING",
+ offset: state.newHomeClientOffset,
+ dimension,
+ dropping,
+ draggingOver,
+ combineWith,
+ mode,
+ forceShouldAnimate: null,
+ snapshot: getMemoizedSnapshot(mode, isClone, draggingOver, combineWith, dropping)
+ }
+ };
+ }
+ return null;
+ };
+ return selector;
+}
+
+function getSecondarySnapshot(combineTargetFor) {
+ return {
+ isDragging: false,
+ isDropAnimating: false,
+ isClone: false,
+ dropAnimation: null,
+ mode: null,
+ draggingOver: null,
+ combineTargetFor,
+ combineWith: null
+ };
+}
+
+const atRest = {
+ mapped: {
+ type: "SECONDARY",
+ offset: origin,
+ combineTargetFor: null,
+ shouldAnimateDisplacement: true,
+ snapshot: getSecondarySnapshot(null)
+ }
+};
+
+function getSecondarySelector() {
+ const memoizedOffset = memoizeOne((x, y) => ({
+ x,
+ y
+ }));
+ const getMemoizedSnapshot = memoizeOne(getSecondarySnapshot);
+ const getMemoizedProps = memoizeOne((offset, combineTargetFor = null, shouldAnimateDisplacement) => ({
+ mapped: {
+ type: "SECONDARY",
+ offset,
+ combineTargetFor,
+ shouldAnimateDisplacement,
+ snapshot: getMemoizedSnapshot(combineTargetFor)
+ }
+ }));
+
+ // Is we are the combine target for something then we need to publish that
+ // otherwise we will return null to get the default props
+ const getFallback = (combineTargetFor) => {
+ return combineTargetFor ? getMemoizedProps(origin, combineTargetFor, true) : null;
+ };
+ const getProps = (ownId, draggingId, impact, afterCritical, overrideDisplacement) => {
+ const visualDisplacement = impact.displaced.visible[ownId];
+ const isAfterCriticalInVirtualList = Boolean(afterCritical.inVirtualList && afterCritical.effected[ownId]);
+ const combine = tryGetCombine(impact);
+ const combineTargetFor = combine && combine.draggableId === ownId ? draggingId : null;
+ if (!visualDisplacement) {
+ if (!isAfterCriticalInVirtualList) {
+ return getFallback(combineTargetFor);
+ }
+
+ // After critical but not visibly displaced in a virtual list
+ // This can occur if:
+ // 1. the item is not visible (displaced.invisible)
+ // 2. We have moved out of the home list.
+
+ // Don't need to do anything - item is invisible
+ if (impact.displaced.invisible[ownId]) {
+ return null;
+ }
+
+ // We are no longer over the home list.
+ // We need to move backwards to close the gap that the dragging item has left
+ const change = negate(afterCritical.displacedBy.point);
+ const offset = memoizedOffset(change.x, change.y);
+ return getMemoizedProps(offset, combineTargetFor, true);
+ }
+ if (isAfterCriticalInVirtualList) {
+ // In a virtual list the removal of a dragging item does
+ // not cause the list to collapse. So when something is 'displaced'
+ // we can just leave it in the original spot.
+ return getFallback(combineTargetFor);
+ }
+ const displaceBy = overrideDisplacement || impact.displacedBy.point;
+ const offset = memoizedOffset(displaceBy.x, displaceBy.y);
+ return getMemoizedProps(offset, combineTargetFor, visualDisplacement.shouldAnimate);
+ };
+ const getDisplacementOverride = (state, ownProps) => {
+ if (!state.impact || !state.onLiftImpact || !state.dimensions) return null;
+ let impact = state.onLiftImpact;
+ if (impact.displaced.all.length < state.impact.displaced.all.length) impact = state.impact;
+ const dims = state.dimensions.draggables;
+ const displaced = impact.displaced.all;
+ const index = displaced.indexOf(ownProps.draggableId);
+ if (index > -1 && displaced.length > index + 1 && impact.displaced.visible[ownProps.draggableId]) {
+ const ownDimensions = dims[ownProps.draggableId].client.borderBox;
+ if (index > 0) {
+ const prevDraggableId = displaced[index - 1];
+ const prevDraggableDimensions = dims[prevDraggableId].client.borderBox;
+ if (prevDraggableDimensions.y !== ownDimensions.y) {
+ if (!impact.displaced.visible[ownProps.draggableId].shouldAnimate) {
+ return {
+ x: ownDimensions.x - prevDraggableDimensions.x,
+ y: ownDimensions.y - prevDraggableDimensions.y
+ };
+ }
+ }
+ }
+ const nextDraggableId = displaced[index + 1];
+ const nextDraggableDimensions = dims[nextDraggableId].client.borderBox;
+ if (nextDraggableDimensions.y !== ownDimensions.y) {
+ if (!impact.displaced.visible[ownProps.draggableId].shouldAnimate)
+ return {
+ x: impact.displacedBy.point.x,
+ y: 0
+ };
+ return {
+ x: nextDraggableDimensions.x - ownDimensions.x,
+ y: nextDraggableDimensions.y - ownDimensions.y
+ };
+ }
+ }
+ return null;
+ };
+ const selector = (state, ownProps) => {
+ // Dragging
+ if (state.isDragging) {
+ // we do not care about the dragging item
+ if (state.critical.draggable.id === ownProps.draggableId) {
+ return null;
+ }
+ if (state.critical.droppable && state.dimensions.droppables[state.critical.droppable.id].axis.grid) {
+ return getProps(
+ ownProps.draggableId,
+ state.critical.draggable.id,
+ state.impact,
+ state.afterCritical,
+ getDisplacementOverride(state, ownProps)
+ );
+ }
+ return getProps(ownProps.draggableId, state.critical.draggable.id, state.impact, state.afterCritical);
+ }
+
+ // Dropping
+ if (state.phase === "DROP_ANIMATING") {
+ const completed = state.completed;
+ // do nothing if this was the dragging item
+ if (completed.result.draggableId === ownProps.draggableId) {
+ return null;
+ }
+ if (
+ state.completed.critical.droppable &&
+ state.dimensions.droppables[state.completed.critical.droppable.id].axis.grid
+ ) {
+ return getProps(
+ ownProps.draggableId,
+ completed.result.draggableId,
+ completed.impact,
+ completed.afterCritical,
+ getDisplacementOverride(state, ownProps)
+ );
+ }
+ return getProps(ownProps.draggableId, completed.result.draggableId, completed.impact, completed.afterCritical);
+ }
+
+ // Otherwise
+ return null;
+ };
+ return selector;
+}
+
+// Returning a function to ensure each
+// Draggable gets its own selector
+export const makeMapStateToProps = () => {
+ const draggingSelector = getDraggableSelector();
+ const secondarySelector = getSecondarySelector();
+ const selector = (state, ownProps) =>
+ draggingSelector(state, ownProps) || secondarySelector(state, ownProps) || atRest;
+ return selector;
+};
+const mapDispatchToProps = {
+ dropAnimationFinished: dropAnimationFinishedAction
+};
+
+// Leaning heavily on the default shallow equality checking
+// that `connect` provides.
+// It avoids needing to do it own within ``
+const ConnectedDraggable = connect(
+ // returning a function so each component can do its own memoization
+ makeMapStateToProps,
+ mapDispatchToProps,
+ // mergeProps: use default
+ null,
+ // options
+ // $FlowFixMe: current react-redux type does not know about context property
+ {
+ // Using our own context for the store to avoid clashing with consumers
+ context: StoreContext,
+ // Default value, but being really clear
+ pure: true,
+ // When pure, compares the result of mapStateToProps to its previous value.
+ // Default value: shallowEqual
+ // Switching to a strictEqual as we return a memoized object on changes
+ areStatePropsEqual: isStrictEqual
+ }
+)(Draggable);
+export default ConnectedDraggable;
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/draggable-api.js b/client/src/components/trello-board/dnd/lib/view/draggable/draggable-api.js
new file mode 100644
index 000000000..d0445d37b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/draggable-api.js
@@ -0,0 +1,49 @@
+import React from "react";
+import ConnectedDraggable from "./connected-draggable";
+import useRequiredContext from "../use-required-context";
+import DroppableContext from "../context/droppable-context"; // We can use this to render a draggable with more control
+
+function _extends() {
+ return (
+ (_extends = Object.assign
+ ? Object.assign.bind()
+ : function (n) {
+ for (var e = 1; e < arguments.length; e++) {
+ var t = arguments[e];
+ for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);
+ }
+ return n;
+ }),
+ _extends.apply(null, arguments)
+ );
+}
+
+// We can use this to render a draggable with more control
+// It is used by a Droppable to render a clone
+export function PrivateDraggable(props) {
+ const droppableContext = useRequiredContext(DroppableContext);
+ // The droppable can render a clone of the draggable item.
+ // In that case we unmount the existing dragging item
+ const isUsingCloneFor = droppableContext.isUsingCloneFor;
+ if (isUsingCloneFor === props.draggableId && !props.isClone) {
+ return null;
+ }
+ return /*#__PURE__*/ React.createElement(ConnectedDraggable, props);
+}
+
+// What we give to consumers
+export function PublicDraggable(props) {
+ // default values for props
+ const isEnabled = typeof props.isDragDisabled === "boolean" ? !props.isDragDisabled : true;
+ const canDragInteractiveElements = Boolean(props.disableInteractiveElementBlocking);
+ const shouldRespectForcePress = Boolean(props.shouldRespectForcePress);
+ return /*#__PURE__*/ React.createElement(
+ PrivateDraggable,
+ _extends({}, props, {
+ isClone: false,
+ isEnabled: isEnabled,
+ canDragInteractiveElements: canDragInteractiveElements,
+ shouldRespectForcePress: shouldRespectForcePress
+ })
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/draggable-types.js b/client/src/components/trello-board/dnd/lib/view/draggable/draggable-types.js
new file mode 100644
index 000000000..0f7e527b2
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/draggable-types.js
@@ -0,0 +1,3 @@
+// Props that can be spread onto the element directly
+
+// to easily enable patching of styles
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/draggable.js b/client/src/components/trello-board/dnd/lib/view/draggable/draggable.js
new file mode 100644
index 000000000..1943459ff
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/draggable.js
@@ -0,0 +1,138 @@
+import { useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import getStyle from "./get-style";
+import useDraggablePublisher from "../use-draggable-publisher/use-draggable-publisher";
+import AppContext from "../context/app-context";
+import DroppableContext from "../context/droppable-context";
+import { useClonePropValidation, useValidation } from "./use-validation";
+import useRequiredContext from "../use-required-context";
+
+function preventHtml5Dnd(event) {
+ event.preventDefault();
+}
+
+export default function Draggable(props) {
+ // reference to DOM node
+ const ref = useRef(null);
+ const setRef = useCallback((el) => {
+ ref.current = el;
+ }, []);
+ const getRef = useCallback(() => ref.current, []);
+
+ // context
+ const { contextId, dragHandleUsageInstructionsId, registry } = useRequiredContext(AppContext);
+ const { type, droppableId } = useRequiredContext(DroppableContext);
+ const descriptor = useMemo(
+ () => ({
+ id: props.draggableId,
+ index: props.index,
+ type,
+ droppableId
+ }),
+ [props.draggableId, props.index, type, droppableId]
+ );
+
+ // props
+ const {
+ // ownProps
+ children,
+ draggableId,
+ isEnabled,
+ shouldRespectForcePress,
+ canDragInteractiveElements,
+ isClone,
+ // mapProps
+ mapped,
+ // dispatchProps
+ dropAnimationFinished: dropAnimationFinishedAction
+ } = props;
+
+ // Validating props and innerRef
+ useValidation(props, contextId, getRef);
+
+ // Clones do not speak to the dimension marshal
+ // We are violating the rules of hooks here: conditional hooks.
+ // In this specific use case it is okay as an item will always either be a
+ // clone or not for it's whole lifecycle
+ /* eslint-disable react-hooks/rules-of-hooks */
+
+ // Being super sure that isClone is not changing during a draggable lifecycle
+ useClonePropValidation(isClone);
+ if (!isClone) {
+ const forPublisher = useMemo(
+ () => ({
+ descriptor,
+ registry,
+ getDraggableRef: getRef,
+ canDragInteractiveElements,
+ shouldRespectForcePress,
+ isEnabled
+ }),
+ [descriptor, registry, getRef, canDragInteractiveElements, shouldRespectForcePress, isEnabled]
+ );
+ useDraggablePublisher(forPublisher);
+ }
+ /* eslint-enable react-hooks/rules-of-hooks */
+
+ const dragHandleProps = useMemo(
+ () =>
+ isEnabled
+ ? {
+ // See `draggable-types` for an explanation of why these are used
+ tabIndex: 0,
+ role: "button",
+ "aria-describedby": dragHandleUsageInstructionsId,
+ "data-rbd-drag-handle-draggable-id": draggableId,
+ "data-rbd-drag-handle-context-id": contextId,
+ draggable: false,
+ onDragStart: preventHtml5Dnd
+ }
+ : null,
+ [contextId, dragHandleUsageInstructionsId, draggableId, isEnabled]
+ );
+ const onMoveEnd = useCallback(
+ (event) => {
+ if (mapped.type !== "DRAGGING") {
+ return;
+ }
+ if (!mapped.dropping) {
+ return;
+ }
+
+ // There might be other properties on the element that are
+ // being transitioned. We do not want those to end a drop animation!
+ if (event.propertyName !== "transform") {
+ return;
+ }
+ dropAnimationFinishedAction();
+ },
+ [dropAnimationFinishedAction, mapped]
+ );
+ const provided = useMemo(() => {
+ const style = getStyle(mapped);
+ const onTransitionEnd = mapped.type === "DRAGGING" && mapped.dropping ? onMoveEnd : null;
+ const result = {
+ innerRef: setRef,
+ draggableProps: {
+ "data-rbd-draggable-context-id": contextId,
+ "data-rbd-draggable-id": draggableId,
+ style,
+ onTransitionEnd
+ },
+ dragHandleProps
+ };
+ return result;
+ }, [contextId, dragHandleProps, draggableId, mapped, onMoveEnd, setRef]);
+ const rubric = useMemo(
+ () => ({
+ draggableId: descriptor.id,
+ type: descriptor.type,
+ source: {
+ index: descriptor.index,
+ droppableId: descriptor.droppableId
+ }
+ }),
+ [descriptor.droppableId, descriptor.id, descriptor.index, descriptor.type]
+ );
+ return children(provided, mapped.snapshot, rubric);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/get-style.js b/client/src/components/trello-board/dnd/lib/view/draggable/get-style.js
new file mode 100644
index 000000000..df3127fd2
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/get-style.js
@@ -0,0 +1,73 @@
+import { combine, transforms, transitions } from "../../animation";
+
+export const zIndexOptions = {
+ dragging: 5000,
+ dropAnimating: 4500
+};
+const getDraggingTransition = (shouldAnimateDragMovement, dropping) => {
+ if (dropping) {
+ return transitions.drop(dropping.duration);
+ }
+ if (shouldAnimateDragMovement) {
+ return transitions.snap;
+ }
+ return transitions.fluid;
+};
+const getDraggingOpacity = (isCombining, isDropAnimating) => {
+ // if not combining: no not impact opacity
+ if (!isCombining) {
+ return null;
+ }
+ return isDropAnimating ? combine.opacity.drop : combine.opacity.combining;
+};
+const getShouldDraggingAnimate = (dragging) => {
+ if (dragging.forceShouldAnimate != null) {
+ return dragging.forceShouldAnimate;
+ }
+ return dragging.mode === "SNAP";
+};
+
+function getDraggingStyle(dragging) {
+ const dimension = dragging.dimension;
+ const box = dimension.client;
+ const { offset, combineWith, dropping } = dragging;
+ const isCombining = Boolean(combineWith);
+ const shouldAnimate = getShouldDraggingAnimate(dragging);
+ const isDropAnimating = Boolean(dropping);
+ const transform = isDropAnimating ? transforms.drop(offset, isCombining) : transforms.moveTo(offset);
+ const style = {
+ // ## Placement
+ position: "fixed",
+ // As we are applying the margins we need to align to the start of the marginBox
+ top: box.marginBox.top,
+ left: box.marginBox.left,
+ // ## Sizing
+ // Locking these down as pulling the node out of the DOM could cause it to change size
+ boxSizing: "border-box",
+ width: box.borderBox.width,
+ height: box.borderBox.height,
+ // ## Movement
+ // Opting out of the standard css transition for the dragging item
+ transition: getDraggingTransition(shouldAnimate, dropping),
+ transform,
+ opacity: getDraggingOpacity(isCombining, isDropAnimating),
+ // ## Layering
+ zIndex: isDropAnimating ? zIndexOptions.dropAnimating : zIndexOptions.dragging,
+ // ## Blocking any pointer events on the dragging or dropping item
+ // global styles on cover while dragging
+ pointerEvents: "none"
+ };
+ return style;
+}
+
+function getSecondaryStyle(secondary) {
+ return {
+ transform: transforms.moveTo(secondary.offset),
+ // transition style is applied in the head
+ transition: secondary.shouldAnimateDisplacement ? null : "none"
+ };
+}
+
+export default function getStyle(mapped) {
+ return mapped.type === "DRAGGING" ? getDraggingStyle(mapped) : getSecondaryStyle(mapped);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/index.js b/client/src/components/trello-board/dnd/lib/view/draggable/index.js
new file mode 100644
index 000000000..0015e7d8d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/index.js
@@ -0,0 +1 @@
+export { PublicDraggable as default } from "./draggable-api";
diff --git a/client/src/components/trello-board/dnd/lib/view/draggable/use-validation.js b/client/src/components/trello-board/dnd/lib/view/draggable/use-validation.js
new file mode 100644
index 000000000..009bc1677
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/draggable/use-validation.js
@@ -0,0 +1,52 @@
+import { useRef } from "react";
+import { invariant } from "../../invariant";
+import { isInteger } from "../../native-with-fallback";
+import checkIsValidInnerRef from "../check-is-valid-inner-ref";
+import findDragHandle from "../get-elements/find-drag-handle";
+import useDevSetupWarning from "../use-dev-setup-warning";
+import useDev from "../use-dev";
+
+export function useValidation(props, contextId, getRef) {
+ // running after every update in development
+ useDevSetupWarning(() => {
+ function prefix(id) {
+ return `Draggable[id: ${id}]: `;
+ }
+
+ // wrapping entire block for better minification
+ const id = props.draggableId;
+ invariant(id, "Draggable requires a draggableId");
+ invariant(
+ typeof id === "string",
+ `Draggable requires a [string] draggableId.
+ Provided: [type: ${typeof id}] (value: ${id})`
+ );
+ invariant(isInteger(props.index), `${prefix(id)} requires an integer index prop`);
+ if (props.mapped.type === "DRAGGING") {
+ return;
+ }
+
+ // Checking provided ref (only when not dragging as it might be removed)
+ checkIsValidInnerRef(getRef());
+
+ // Checking that drag handle is provided
+ // Only running check when enabled.
+ // When not enabled there is no drag handle props
+ if (props.isEnabled) {
+ invariant(findDragHandle(contextId, id), `${prefix(id)} Unable to find drag handle`);
+ }
+ });
+}
+
+// we expect isClone not to change for entire component's life
+export function useClonePropValidation(isClone) {
+ useDev(() => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const initialRef = useRef(isClone);
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useDevSetupWarning(() => {
+ invariant(isClone === initialRef.current, "Draggable isClone prop value changed during component life");
+ }, [isClone]);
+ });
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/droppable/connected-droppable.js b/client/src/components/trello-board/dnd/lib/view/droppable/connected-droppable.js
new file mode 100644
index 000000000..07bb341dd
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/droppable/connected-droppable.js
@@ -0,0 +1,204 @@
+// eslint-disable-next-line no-unused-vars
+import { connect } from "react-redux";
+import memoizeOne from "memoize-one";
+import { invariant } from "../../invariant";
+import Droppable from "./droppable";
+import isStrictEqual from "../is-strict-equal";
+import whatIsDraggedOver from "../../state/droppable/what-is-dragged-over";
+import { updateViewportMaxScroll as updateViewportMaxScrollAction } from "../../state/action-creators";
+import StoreContext from "../context/store-context";
+import whatIsDraggedOverFromResult from "../../state/droppable/what-is-dragged-over-from-result";
+
+const isMatchingType = (type, critical) => type === critical.droppable.type;
+const getDraggable = (critical, dimensions) => dimensions.draggables[critical.draggable.id];
+
+// Returning a function to ensure each
+// Droppable gets its own selector
+export const makeMapStateToProps = () => {
+ const idleWithAnimation = {
+ placeholder: null,
+ shouldAnimatePlaceholder: true,
+ snapshot: {
+ isDraggingOver: false,
+ draggingOverWith: null,
+ draggingFromThisWith: null,
+ isUsingPlaceholder: false
+ },
+ useClone: null
+ };
+ const idleWithoutAnimation = {
+ ...idleWithAnimation,
+ shouldAnimatePlaceholder: false
+ };
+ const getDraggableRubric = memoizeOne((descriptor) => ({
+ draggableId: descriptor.id,
+ type: descriptor.type,
+ source: {
+ index: descriptor.index,
+ droppableId: descriptor.droppableId
+ }
+ }));
+ const getMapProps = memoizeOne(
+ (id, isEnabled, isDraggingOverForConsumer, isDraggingOverForImpact, dragging, renderClone) => {
+ const draggableId = dragging.descriptor.id;
+ const isHome = dragging.descriptor.droppableId === id;
+ if (isHome) {
+ const useClone = renderClone
+ ? {
+ render: renderClone,
+ dragging: getDraggableRubric(dragging.descriptor)
+ }
+ : null;
+ const snapshot = {
+ isDraggingOver: isDraggingOverForConsumer,
+ draggingOverWith: isDraggingOverForConsumer ? draggableId : null,
+ draggingFromThisWith: draggableId,
+ isUsingPlaceholder: true
+ };
+ return {
+ placeholder: dragging.placeholder,
+ shouldAnimatePlaceholder: false,
+ snapshot,
+ useClone
+ };
+ }
+ if (!isEnabled) {
+ return idleWithoutAnimation;
+ }
+
+ // not over foreign list - return idle
+ if (!isDraggingOverForImpact) {
+ return idleWithAnimation;
+ }
+ const snapshot = {
+ isDraggingOver: isDraggingOverForConsumer,
+ draggingOverWith: draggableId,
+ draggingFromThisWith: null,
+ isUsingPlaceholder: true
+ };
+ return {
+ placeholder: dragging.placeholder,
+ // Animating placeholder in foreign list
+ shouldAnimatePlaceholder: true,
+ snapshot,
+ useClone: null
+ };
+ }
+ );
+ const selector = (state, ownProps) => {
+ // not checking if item is disabled as we need the home list to display a placeholder
+
+ const id = ownProps.droppableId;
+ const type = ownProps.type;
+ const isEnabled = !ownProps.isDropDisabled;
+ const renderClone = ownProps.renderClone;
+ if (state.isDragging) {
+ const critical = state.critical;
+ if (!isMatchingType(type, critical)) {
+ return idleWithoutAnimation;
+ }
+ const dragging = getDraggable(critical, state.dimensions);
+ const isDraggingOver = whatIsDraggedOver(state.impact) === id;
+ return getMapProps(id, isEnabled, isDraggingOver, isDraggingOver, dragging, renderClone);
+ }
+ if (state.phase === "DROP_ANIMATING") {
+ const completed = state.completed;
+ if (!isMatchingType(type, completed.critical)) {
+ return idleWithoutAnimation;
+ }
+ const dragging = getDraggable(completed.critical, state.dimensions);
+
+ // Snapshot based on result and not impact
+ // The result might be null (cancel) but the impact is populated
+ // to move everything back
+ return getMapProps(
+ id,
+ isEnabled,
+ whatIsDraggedOverFromResult(completed.result) === id,
+ whatIsDraggedOver(completed.impact) === id,
+ dragging,
+ renderClone
+ );
+ }
+ if (state.phase === "IDLE" && state.completed && !state.shouldFlush) {
+ const completed = state.completed;
+ if (!isMatchingType(type, completed.critical)) {
+ return idleWithoutAnimation;
+ }
+
+ // Looking at impact as this controls the placeholder
+ const wasOver = whatIsDraggedOver(completed.impact) === id;
+ const wasCombining = Boolean(completed.impact.at && completed.impact.at.type === "COMBINE");
+ const isHome = completed.critical.droppable.id === id;
+ if (wasOver) {
+ // if reordering we need to cut an animation immediately
+ // if merging: animate placeholder closed after drop
+ return wasCombining ? idleWithAnimation : idleWithoutAnimation;
+ }
+
+ // we need to animate the home placeholder closed if it is not
+ // being dropped into
+ if (isHome) {
+ return idleWithAnimation;
+ }
+ return idleWithoutAnimation;
+ }
+
+ // default: including when flushed
+ return idleWithoutAnimation;
+ };
+ return selector;
+};
+const mapDispatchToProps = {
+ updateViewportMaxScroll: updateViewportMaxScrollAction
+};
+
+function getBody() {
+ invariant(document.body, "document.body is not ready");
+ return document.body;
+}
+
+const defaultProps = {
+ mode: "standard",
+ type: "DEFAULT",
+ direction: "vertical",
+ isDropDisabled: false,
+ isCombineEnabled: false,
+ ignoreContainerClipping: false,
+ renderClone: null,
+ getContainerForClone: getBody
+};
+
+// Abstract class allows to specify props and defaults to component.
+// All other ways give any or do not let add default props.
+// eslint-disable-next-line
+/*::
+class DroppableType extends Component {
+ static defaultProps = defaultProps;
+}
+*/
+
+// Leaning heavily on the default shallow equality checking
+// that `connect` provides.
+// It avoids needing to do it own within `Droppable`
+const ConnectedDroppable = connect(
+ // returning a function so each component can do its own memoization
+ makeMapStateToProps,
+ // no dispatch props for droppable
+ mapDispatchToProps,
+ // mergeProps - using default
+ null,
+ // $FlowFixMe: current react-redux type does not know about context property
+ {
+ // Ensuring our context does not clash with consumers
+ context: StoreContext,
+ // pure: true is default value, but being really clear
+ pure: true,
+ // When pure, compares the result of mapStateToProps to its previous value.
+ // Default value: shallowEqual
+ // Switching to a strictEqual as we return a memoized object on changes
+ areStatePropsEqual: isStrictEqual
+ }
+)(Droppable);
+ConnectedDroppable.defaultProps = defaultProps;
+export default ConnectedDroppable;
diff --git a/client/src/components/trello-board/dnd/lib/view/droppable/droppable-types.js b/client/src/components/trello-board/dnd/lib/view/droppable/droppable-types.js
new file mode 100644
index 000000000..5ae7b4bd6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/droppable/droppable-types.js
@@ -0,0 +1,2 @@
+// Having issues getting the correct type
+// export type Selector = OutputSelector;
diff --git a/client/src/components/trello-board/dnd/lib/view/droppable/droppable.js b/client/src/components/trello-board/dnd/lib/view/droppable/droppable.js
new file mode 100644
index 000000000..b38daa7f6
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/droppable/droppable.js
@@ -0,0 +1,136 @@
+import ReactDOM from "react-dom";
+import { useCallback, useMemo } from "use-memo-one";
+import React, { useContext, useRef } from "react";
+import { invariant } from "../../invariant";
+import useDroppablePublisher from "../use-droppable-publisher";
+import Placeholder from "../placeholder";
+import AppContext from "../context/app-context";
+import DroppableContext from "../context/droppable-context";
+// import useAnimateInOut from '../use-animate-in-out/use-animate-in-out';
+import getMaxWindowScroll from "../window/get-max-window-scroll";
+import useValidation from "./use-validation";
+import AnimateInOut from "../animate-in-out/animate-in-out";
+import { PrivateDraggable } from "../draggable/draggable-api";
+
+export default function Droppable(props) {
+ const appContext = useContext(AppContext);
+ invariant(appContext, "Could not find app context");
+ const { contextId, isMovementAllowed } = appContext;
+ const droppableRef = useRef(null);
+ const placeholderRef = useRef(null);
+ const {
+ // own props
+ children,
+ droppableId,
+ type,
+ mode,
+ direction,
+ ignoreContainerClipping,
+ isDropDisabled,
+ isCombineEnabled,
+ // map props
+ snapshot,
+ useClone,
+ // dispatch props
+ updateViewportMaxScroll,
+ // clone (ownProps)
+ getContainerForClone
+ } = props;
+ const getDroppableRef = useCallback(() => droppableRef.current, []);
+ const setDroppableRef = useCallback((value) => {
+ droppableRef.current = value;
+ }, []);
+ const getPlaceholderRef = useCallback(() => placeholderRef.current, []);
+ const setPlaceholderRef = useCallback((value) => {
+ placeholderRef.current = value;
+ }, []);
+ useValidation({
+ props,
+ getDroppableRef,
+ getPlaceholderRef
+ });
+ const onPlaceholderTransitionEnd = useCallback(() => {
+ // A placeholder change can impact the window's max scroll
+ if (isMovementAllowed()) {
+ updateViewportMaxScroll({
+ maxScroll: getMaxWindowScroll()
+ });
+ }
+ }, [isMovementAllowed, updateViewportMaxScroll]);
+ useDroppablePublisher({
+ droppableId,
+ type,
+ mode,
+ direction,
+ isDropDisabled,
+ isCombineEnabled,
+ ignoreContainerClipping,
+ getDroppableRef
+ });
+ const placeholder = /*#__PURE__*/ React.createElement(
+ AnimateInOut,
+ {
+ on: props.placeholder,
+ shouldAnimate: props.shouldAnimatePlaceholder
+ },
+ ({ onClose, data, animate }) =>
+ /*#__PURE__*/ React.createElement(Placeholder, {
+ placeholder: data,
+ onClose: onClose,
+ innerRef: setPlaceholderRef,
+ animate: animate,
+ contextId: contextId,
+ onTransitionEnd: onPlaceholderTransitionEnd
+ })
+ );
+ const provided = useMemo(
+ () => ({
+ innerRef: setDroppableRef,
+ placeholder,
+ droppableProps: {
+ "data-rbd-droppable-id": droppableId,
+ "data-rbd-droppable-context-id": contextId
+ }
+ }),
+ [contextId, droppableId, placeholder, setDroppableRef]
+ );
+ const isUsingCloneFor = useClone ? useClone.dragging.draggableId : null;
+ const droppableContext = useMemo(
+ () => ({
+ droppableId,
+ type,
+ isUsingCloneFor
+ }),
+ [droppableId, isUsingCloneFor, type]
+ );
+
+ function getClone() {
+ if (!useClone) {
+ return null;
+ }
+ const { dragging, render } = useClone;
+ const node = /*#__PURE__*/ React.createElement(
+ PrivateDraggable,
+ {
+ draggableId: dragging.draggableId,
+ index: dragging.source.index,
+ isClone: true,
+ isEnabled: true,
+ // not important as drag has already started
+ shouldRespectForcePress: false,
+ canDragInteractiveElements: true
+ },
+ (draggableProvided, draggableSnapshot) => render(draggableProvided, draggableSnapshot, dragging)
+ );
+ return /*#__PURE__*/ ReactDOM.createPortal(node, getContainerForClone());
+ }
+
+ return /*#__PURE__*/ React.createElement(
+ DroppableContext.Provider,
+ {
+ value: droppableContext
+ },
+ children(provided, snapshot),
+ getClone()
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/droppable/index.js b/client/src/components/trello-board/dnd/lib/view/droppable/index.js
new file mode 100644
index 000000000..f5e80da74
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/droppable/index.js
@@ -0,0 +1 @@
+export { default } from "./connected-droppable";
diff --git a/client/src/components/trello-board/dnd/lib/view/droppable/use-validation.js b/client/src/components/trello-board/dnd/lib/view/droppable/use-validation.js
new file mode 100644
index 000000000..ed95405f7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/droppable/use-validation.js
@@ -0,0 +1,68 @@
+import { invariant } from "../../invariant";
+import { warning } from "../../dev-warning";
+import checkIsValidInnerRef from "../check-is-valid-inner-ref";
+import useDevSetupWarning from "../use-dev-setup-warning";
+
+function isBoolean(value) {
+ return typeof value === "boolean";
+}
+
+function runChecks(args, checks) {
+ checks.forEach((check) => check(args));
+}
+
+const shared = [
+ function required({ props }) {
+ invariant(props.droppableId, "A Droppable requires a droppableId prop");
+ invariant(
+ typeof props.droppableId === "string",
+ `A Droppable requires a [string] droppableId. Provided: [${typeof props.droppableId}]`
+ );
+ },
+ function boolean({ props }) {
+ invariant(isBoolean(props.isDropDisabled), "isDropDisabled must be a boolean");
+ invariant(isBoolean(props.isCombineEnabled), "isCombineEnabled must be a boolean");
+ invariant(isBoolean(props.ignoreContainerClipping), "ignoreContainerClipping must be a boolean");
+ },
+ function ref({ getDroppableRef }) {
+ checkIsValidInnerRef(getDroppableRef());
+ }
+];
+const standard = [
+ function placeholder({ props, getPlaceholderRef }) {
+ if (!props.placeholder) {
+ return;
+ }
+ const ref = getPlaceholderRef();
+ if (ref) {
+ return;
+ }
+ warning(`
+ Droppable setup issue [droppableId: "${props.droppableId}"]:
+ DroppableProvided > placeholder could not be found.
+
+ Please be sure to add the {provided.placeholder} React Node as a child of your Droppable.
+ More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md
+ `);
+ }
+];
+const virtual = [
+ function hasClone({ props }) {
+ invariant(props.renderClone, "Must provide a clone render function (renderClone) for virtual lists");
+ },
+ function hasNoPlaceholder({ getPlaceholderRef }) {
+ invariant(!getPlaceholderRef(), "Expected virtual list to not have a placeholder");
+ }
+];
+export default function useValidation(args) {
+ useDevSetupWarning(() => {
+ // wrapping entire block for better minification
+ runChecks(args, shared);
+ if (args.props.mode === "standard") {
+ runChecks(args, standard);
+ }
+ if (args.props.mode === "virtual") {
+ runChecks(args, virtual);
+ }
+ });
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/event-bindings/bind-events.js b/client/src/components/trello-board/dnd/lib/view/event-bindings/bind-events.js
new file mode 100644
index 000000000..379110d8e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/event-bindings/bind-events.js
@@ -0,0 +1,23 @@
+function getOptions(shared, fromBinding) {
+ return {
+ ...shared,
+ ...fromBinding
+ };
+}
+
+export default function bindEvents(el, bindings, sharedOptions) {
+ const unbindings = bindings.map((binding) => {
+ const options = getOptions(sharedOptions, binding.options);
+ el.addEventListener(binding.eventName, binding.fn, options);
+ return function unbind() {
+ el.removeEventListener(binding.eventName, binding.fn, options);
+ };
+ });
+
+ // Return a function to unbind events
+ return function unbindAll() {
+ unbindings.forEach((unbind) => {
+ unbind();
+ });
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/event-bindings/event-types.js b/client/src/components/trello-board/dnd/lib/view/event-bindings/event-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/get-body-element.js b/client/src/components/trello-board/dnd/lib/view/get-body-element.js
new file mode 100644
index 000000000..13db93c74
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/get-body-element.js
@@ -0,0 +1,7 @@
+import { invariant } from "../invariant";
+
+export default () => {
+ const body = document.body;
+ invariant(body, "Cannot find document.body");
+ return body;
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/get-border-box-center-position.js b/client/src/components/trello-board/dnd/lib/view/get-border-box-center-position.js
new file mode 100644
index 000000000..ecd466255
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/get-border-box-center-position.js
@@ -0,0 +1,3 @@
+import { getRect } from "css-box-model";
+
+export default (el) => getRect(el.getBoundingClientRect()).center;
diff --git a/client/src/components/trello-board/dnd/lib/view/get-document-element.js b/client/src/components/trello-board/dnd/lib/view/get-document-element.js
new file mode 100644
index 000000000..628ff5880
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/get-document-element.js
@@ -0,0 +1,7 @@
+import { invariant } from "../invariant";
+
+export default () => {
+ const doc = document.documentElement;
+ invariant(doc, "Cannot find document.documentElement");
+ return doc;
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/get-elements/find-drag-handle.js b/client/src/components/trello-board/dnd/lib/view/get-elements/find-drag-handle.js
new file mode 100644
index 000000000..1ee0484e9
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/get-elements/find-drag-handle.js
@@ -0,0 +1,26 @@
+import { dragHandle as dragHandleAttr } from "../data-attributes";
+import { warning } from "../../dev-warning";
+import { find, toArray } from "../../native-with-fallback";
+import isHtmlElement from "../is-type-of-element/is-html-element";
+
+export default function findDragHandle(contextId, draggableId) {
+ // cannot create a selector with the draggable id as it might not be a valid attribute selector
+ const selector = `[${dragHandleAttr.contextId}="${contextId}"]`;
+ const possible = toArray(document.querySelectorAll(selector));
+ if (!possible.length) {
+ warning(`Unable to find any drag handles in the context "${contextId}"`);
+ return null;
+ }
+ const handle = find(possible, (el) => {
+ return el.getAttribute(dragHandleAttr.draggableId) === draggableId;
+ });
+ if (!handle) {
+ warning(`Unable to find drag handle with id "${draggableId}" as no handle with a matching id was found`);
+ return null;
+ }
+ if (!isHtmlElement(handle)) {
+ warning("drag handle needs to be a HTMLElement");
+ return null;
+ }
+ return handle;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/get-elements/find-draggable.js b/client/src/components/trello-board/dnd/lib/view/get-elements/find-draggable.js
new file mode 100644
index 000000000..e64a59dda
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/get-elements/find-draggable.js
@@ -0,0 +1,21 @@
+import * as attributes from "../data-attributes";
+import { find, toArray } from "../../native-with-fallback";
+import { warning } from "../../dev-warning";
+import isHtmlElement from "../is-type-of-element/is-html-element";
+
+export default function findDraggable(contextId, draggableId) {
+ // cannot create a selector with the draggable id as it might not be a valid attribute selector
+ const selector = `[${attributes.draggable.contextId}="${contextId}"]`;
+ const possible = toArray(document.querySelectorAll(selector));
+ const draggable = find(possible, (el) => {
+ return el.getAttribute(attributes.draggable.id) === draggableId;
+ });
+ if (!draggable) {
+ return null;
+ }
+ if (!isHtmlElement(draggable)) {
+ warning("Draggable element is not a HTMLElement");
+ return null;
+ }
+ return draggable;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/is-strict-equal.js b/client/src/components/trello-board/dnd/lib/view/is-strict-equal.js
new file mode 100644
index 000000000..9cd904a16
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/is-strict-equal.js
@@ -0,0 +1 @@
+export default (a, b) => a === b;
diff --git a/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-element.js b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-element.js
new file mode 100644
index 000000000..ff85d3780
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-element.js
@@ -0,0 +1,5 @@
+import getWindowFromEl from "../window/get-window-from-el";
+
+export default function isElement(el) {
+ return el instanceof getWindowFromEl(el).Element;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-html-element.js b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-html-element.js
new file mode 100644
index 000000000..89f59b17d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-html-element.js
@@ -0,0 +1,5 @@
+import getWindowFromEl from "../window/get-window-from-el";
+
+export default function isHtmlElement(el) {
+ return el instanceof getWindowFromEl(el).HTMLElement;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-svg-element.js b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-svg-element.js
new file mode 100644
index 000000000..fe8e15099
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/is-type-of-element/is-svg-element.js
@@ -0,0 +1,8 @@
+import getWindowFromEl from "../window/get-window-from-el";
+
+export default function isSvgElement(el) {
+ // Some environments do not support SVGElement
+ // Doing a double lookup rather than storing the window
+ // as a %checks function can only be a 'simple predicate'
+ return Boolean(getWindowFromEl(el).SVGElement) && el instanceof getWindowFromEl(el).SVGElement;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/key-codes.js b/client/src/components/trello-board/dnd/lib/view/key-codes.js
new file mode 100644
index 000000000..5a7159e2b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/key-codes.js
@@ -0,0 +1,12 @@
+export const tab = 9;
+export const enter = 13;
+export const escape = 27;
+export const space = 32;
+export const pageUp = 33;
+export const pageDown = 34;
+export const end = 35;
+export const home = 36;
+export const arrowLeft = 37;
+export const arrowUp = 38;
+export const arrowRight = 39;
+export const arrowDown = 40;
diff --git a/client/src/components/trello-board/dnd/lib/view/placeholder/index.js b/client/src/components/trello-board/dnd/lib/view/placeholder/index.js
new file mode 100644
index 000000000..11a7c8426
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/placeholder/index.js
@@ -0,0 +1 @@
+export { default } from "./placeholder";
diff --git a/client/src/components/trello-board/dnd/lib/view/placeholder/placeholder-types.js b/client/src/components/trello-board/dnd/lib/view/placeholder/placeholder-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/placeholder/placeholder.js b/client/src/components/trello-board/dnd/lib/view/placeholder/placeholder.js
new file mode 100644
index 000000000..f3775bfe3
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/placeholder/placeholder.js
@@ -0,0 +1,134 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useCallback } from "use-memo-one";
+import { transitions } from "../../animation";
+import { noSpacing } from "../../state/spacing";
+
+function noop() {}
+
+const empty = {
+ width: 0,
+ height: 0,
+ margin: noSpacing
+};
+const getSize = ({ isAnimatingOpenOnMount, placeholder, animate }) => {
+ if (isAnimatingOpenOnMount) {
+ return empty;
+ }
+ if (animate === "close") {
+ return empty;
+ }
+ return {
+ height: placeholder.client.borderBox.height,
+ width: placeholder.client.borderBox.width,
+ margin: placeholder.client.margin
+ };
+};
+const getStyle = ({ isAnimatingOpenOnMount, placeholder, animate }) => {
+ const size = getSize({
+ isAnimatingOpenOnMount,
+ placeholder,
+ animate
+ });
+ return {
+ display: placeholder.display,
+ // ## Recreating the box model
+ // We created the borderBox and then apply the margins directly
+ // this is to maintain any margin collapsing behaviour
+
+ // creating borderBox
+ // background: 'green',
+ boxSizing: "border-box",
+ width: size.width,
+ height: size.height,
+ // creating marginBox
+ marginTop: size.margin.top,
+ marginRight: size.margin.right,
+ marginBottom: size.margin.bottom,
+ marginLeft: size.margin.left,
+ // ## Avoiding collapsing
+ // Avoiding the collapsing or growing of this element when pushed by flex child siblings.
+ // We have already taken a snapshot the current dimensions we do not want this element
+ // to recalculate its dimensions
+ // It is okay for these properties to be applied on elements that are not flex children
+ flexShrink: "0",
+ flexGrow: "0",
+ // Just a little performance optimisation: avoiding the browser needing
+ // to worry about pointer events for this element
+ pointerEvents: "none",
+ // Animate the placeholder size and margin
+ transition: animate !== "none" ? transitions.placeholder : null
+ };
+};
+
+function Placeholder(props) {
+ const animateOpenTimerRef = useRef(null);
+ const tryClearAnimateOpenTimer = useCallback(() => {
+ if (!animateOpenTimerRef.current) {
+ return;
+ }
+ clearTimeout(animateOpenTimerRef.current);
+ animateOpenTimerRef.current = null;
+ }, []);
+ const { animate, onTransitionEnd, onClose, contextId } = props;
+ const [isAnimatingOpenOnMount, setIsAnimatingOpenOnMount] = useState(props.animate === "open");
+
+ // Will run after a render is flushed
+ // Still need to wait a timeout to ensure that the
+ // update is completely applied to the DOM
+ useEffect(() => {
+ // No need to do anything
+ if (!isAnimatingOpenOnMount) {
+ return noop;
+ }
+
+ // might need to clear the timer
+ if (animate !== "open") {
+ tryClearAnimateOpenTimer();
+ setIsAnimatingOpenOnMount(false);
+ return noop;
+ }
+
+ // timer already pending
+ if (animateOpenTimerRef.current) {
+ return noop;
+ }
+ animateOpenTimerRef.current = setTimeout(() => {
+ animateOpenTimerRef.current = null;
+ setIsAnimatingOpenOnMount(false);
+ });
+
+ // clear the timer if needed
+ return tryClearAnimateOpenTimer;
+ }, [animate, isAnimatingOpenOnMount, tryClearAnimateOpenTimer]);
+ const onSizeChangeEnd = useCallback(
+ (event) => {
+ // We transition height, width and margin
+ // each of those transitions will independently call this callback
+ // Because they all have the same duration we can just respond to one of them
+ // 'height' was chosen for no particular reason :D
+ if (event.propertyName !== "height") {
+ return;
+ }
+ onTransitionEnd();
+ if (animate === "close") {
+ onClose();
+ }
+ },
+ [animate, onClose, onTransitionEnd]
+ );
+ const style = getStyle({
+ isAnimatingOpenOnMount,
+ animate: props.animate,
+ placeholder: props.placeholder
+ });
+ return /*#__PURE__*/ React.createElement(props.placeholder.tagName, {
+ style,
+ "data-rbd-placeholder-context-id": contextId,
+ onTransitionEnd: onSizeChangeEnd,
+ ref: props.innerRef
+ });
+}
+
+export default /*#__PURE__*/ React.memo(Placeholder);
+// enzyme does not work well with memo, so exporting the non-memo version
+export const WithoutMemo = Placeholder;
diff --git a/client/src/components/trello-board/dnd/lib/view/scroll-listener.js b/client/src/components/trello-board/dnd/lib/view/scroll-listener.js
new file mode 100644
index 000000000..9df44bd8f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/scroll-listener.js
@@ -0,0 +1,64 @@
+import rafSchd from "raf-schd";
+import { invariant } from "../invariant";
+import bindEvents from "./event-bindings/bind-events";
+import getWindowScroll from "./window/get-window-scroll";
+import { noop } from "../empty";
+
+function getWindowScrollBinding(update) {
+ return {
+ eventName: "scroll",
+ // ## Passive: true
+ // Eventual consistency is fine because we use position: fixed on the item
+ // ## Capture: false
+ // Scroll events on elements do not bubble, but they go through the capture phase
+ // https://twitter.com/alexandereardon/status/985994224867819520
+ // Using capture: false here as we want to avoid intercepting droppable scroll requests
+ options: {
+ passive: true,
+ capture: false
+ },
+ fn: (event) => {
+ // IE11 fix
+ // All scrollable events still bubble up and are caught by this handler in ie11.
+ // On a window scroll the event.target should be the window or the document.
+ // If this is not the case then it is not a 'window' scroll event and can be ignored
+ if (event.target !== window && event.target !== window.document) {
+ return;
+ }
+ update();
+ }
+ };
+}
+
+export default function getScrollListener({ onWindowScroll }) {
+ function updateScroll() {
+ // letting the update function read the latest scroll when called
+ onWindowScroll(getWindowScroll());
+ }
+
+ const scheduled = rafSchd(updateScroll);
+ const binding = getWindowScrollBinding(scheduled);
+ let unbind = noop;
+
+ function isActive() {
+ return unbind !== noop;
+ }
+
+ function start() {
+ invariant(!isActive(), "Cannot start scroll listener when already active");
+ unbind = bindEvents(window, [binding]);
+ }
+
+ function stop() {
+ invariant(isActive(), "Cannot stop scroll listener when not active");
+ scheduled.cancel();
+ unbind();
+ unbind = noop;
+ }
+
+ return {
+ start,
+ stop,
+ isActive
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/throw-if-invalid-inner-ref.js b/client/src/components/trello-board/dnd/lib/view/throw-if-invalid-inner-ref.js
new file mode 100644
index 000000000..908ee154e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/throw-if-invalid-inner-ref.js
@@ -0,0 +1,14 @@
+import { invariant } from "../invariant";
+import isHtmlElement from "./is-type-of-element/is-html-element";
+
+export default (ref) => {
+ invariant(
+ ref && isHtmlElement(ref),
+ `
+ provided.innerRef has not been provided with a HTMLElement.
+
+ You can find a guide on using the innerRef callback functions at:
+ https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/using-inner-ref.md
+ `
+ );
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-announcer/index.js b/client/src/components/trello-board/dnd/lib/view/use-announcer/index.js
new file mode 100644
index 000000000..2edb90680
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-announcer/index.js
@@ -0,0 +1 @@
+export { default } from "./use-announcer";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-announcer/use-announcer.js b/client/src/components/trello-board/dnd/lib/view/use-announcer/use-announcer.js
new file mode 100644
index 000000000..ee0c7720e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-announcer/use-announcer.js
@@ -0,0 +1,71 @@
+import { useEffect, useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { warning } from "../../dev-warning";
+import getBodyElement from "../get-body-element";
+import visuallyHidden from "../visually-hidden-style";
+
+export const getId = (contextId) => `rbd-announcement-${contextId}`;
+export default function useAnnouncer(contextId) {
+ const id = useMemo(() => getId(contextId), [contextId]);
+ const ref = useRef(null);
+ useEffect(
+ function setup() {
+ const el = document.createElement("div");
+ // storing reference for usage in announce
+ ref.current = el;
+
+ // identifier
+ el.id = id;
+
+ // Aria live region
+
+ // will force itself to be read
+ el.setAttribute("aria-live", "assertive");
+ // must read the whole thing every time
+ el.setAttribute("aria-atomic", "true");
+
+ // hide the element visually
+ Object.assign(el.style, visuallyHidden);
+
+ // Add to body
+ getBodyElement().appendChild(el);
+ return function cleanup() {
+ // Not clearing the ref as it might be used by announce before the timeout expires
+
+ // unmounting after a timeout to let any announcements
+ // during a mount be published
+ setTimeout(function remove() {
+ // checking if element exists as the body might have been changed by things like 'turbolinks'
+ const body = getBodyElement();
+ if (body.contains(el)) {
+ body.removeChild(el);
+ }
+ // if el was the current ref - clear it so that
+ // we can get a warning if announce is called
+ if (el === ref.current) {
+ ref.current = null;
+ }
+ });
+ };
+ },
+ [id]
+ );
+ const announce = useCallback((message) => {
+ const el = ref.current;
+ if (el) {
+ el.textContent = message;
+ return;
+ }
+ warning(`
+ A screen reader message was trying to be announced but it was unable to do so.
+ This can occur if you unmount your in your onDragEnd.
+ Consider calling provided.announce() before the unmount so that the instruction will
+ not be lost for users relying on a screen reader.
+
+ Message not passed to screen reader:
+
+ "${message}"
+ `);
+ }, []);
+ return announce;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-dev-setup-warning.js b/client/src/components/trello-board/dnd/lib/view/use-dev-setup-warning.js
new file mode 100644
index 000000000..cbf393099
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-dev-setup-warning.js
@@ -0,0 +1,21 @@
+import { useEffect } from "react";
+import { error } from "../dev-warning";
+import useDev from "./use-dev";
+
+export default function useDevSetupWarning(fn, inputs) {
+ useDev(() => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ try {
+ fn();
+ } catch (e) {
+ error(`
+ A setup problem was encountered.
+
+ > ${e.message}
+ `);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, inputs);
+ });
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-dev.js b/client/src/components/trello-board/dnd/lib/view/use-dev.js
new file mode 100644
index 000000000..fdcb5d501
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-dev.js
@@ -0,0 +1,7 @@
+export default function useDev(useHook) {
+ // Don't run any validation in production
+ if (process.env.NODE_ENV !== "production") {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useHook();
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/get-dimension.js b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/get-dimension.js
new file mode 100644
index 000000000..2c12553a7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/get-dimension.js
@@ -0,0 +1,26 @@
+import { calculateBox, withScroll } from "css-box-model";
+import { origin } from "../../state/position";
+
+export default function getDimension(descriptor, el, windowScroll = origin) {
+ const computedStyles = window.getComputedStyle(el);
+ const borderBox = el.getBoundingClientRect();
+ const client = calculateBox(borderBox, computedStyles);
+ const page = withScroll(client, windowScroll);
+ const placeholder = {
+ client,
+ tagName: el.tagName.toLowerCase(),
+ display: computedStyles.display
+ };
+ const displaceBy = {
+ x: client.marginBox.width,
+ y: client.marginBox.height
+ };
+ const dimension = {
+ descriptor,
+ placeholder,
+ displaceBy,
+ client,
+ page
+ };
+ return dimension;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/index.js b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/index.js
new file mode 100644
index 000000000..a98d7703f
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/index.js
@@ -0,0 +1 @@
+export { default } from "./use-draggable-publisher";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/use-draggable-publisher.js b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/use-draggable-publisher.js
new file mode 100644
index 000000000..d5b3bd305
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-draggable-publisher/use-draggable-publisher.js
@@ -0,0 +1,56 @@
+import { useCallback, useMemo } from "use-memo-one";
+import { useRef } from "react";
+import { invariant } from "../../invariant";
+import makeDimension from "./get-dimension";
+import useLayoutEffect from "../use-isomorphic-layout-effect";
+import useUniqueId from "../use-unique-id";
+
+export default function useDraggablePublisher(args) {
+ const uniqueId = useUniqueId("draggable");
+ const { descriptor, registry, getDraggableRef, canDragInteractiveElements, shouldRespectForcePress, isEnabled } =
+ args;
+ const options = useMemo(
+ () => ({
+ canDragInteractiveElements,
+ shouldRespectForcePress,
+ isEnabled
+ }),
+ [canDragInteractiveElements, isEnabled, shouldRespectForcePress]
+ );
+ const getDimension = useCallback(
+ (windowScroll) => {
+ const el = getDraggableRef();
+ invariant(el, "Cannot get dimension when no ref is set");
+ return makeDimension(descriptor, el, windowScroll);
+ },
+ [descriptor, getDraggableRef]
+ );
+ const entry = useMemo(
+ () => ({
+ uniqueId,
+ descriptor,
+ options,
+ getDimension
+ }),
+ [descriptor, getDimension, options, uniqueId]
+ );
+ const publishedRef = useRef(entry);
+ const isFirstPublishRef = useRef(true);
+
+ // mounting and unmounting
+ useLayoutEffect(() => {
+ registry.draggable.register(publishedRef.current);
+ return () => registry.draggable.unregister(publishedRef.current);
+ }, [registry.draggable]);
+
+ // updates while mounted
+ useLayoutEffect(() => {
+ if (isFirstPublishRef.current) {
+ isFirstPublishRef.current = false;
+ return;
+ }
+ const last = publishedRef.current;
+ publishedRef.current = entry;
+ registry.draggable.update(entry, last);
+ }, [entry, registry.draggable]);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/check-for-nested-scroll-container.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/check-for-nested-scroll-container.js
new file mode 100644
index 000000000..67644c04b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/check-for-nested-scroll-container.js
@@ -0,0 +1,21 @@
+import getClosestScrollable from "./get-closest-scrollable";
+import { warning } from "../../dev-warning";
+
+// We currently do not support nested scroll containers
+// But will hopefully support this soon!
+export default (scrollable) => {
+ if (!scrollable) {
+ return;
+ }
+ const anotherScrollParent = getClosestScrollable(scrollable.parentElement);
+ if (!anotherScrollParent) {
+ return;
+ }
+ warning(`
+ Droppable: unsupported nested scroll container detected.
+ A Droppable can only have one scroll parent (which can be itself)
+ Nested scroll containers are currently not supported.
+
+ We hope to support nested scroll containers soon: https://github.com/atlassian/react-beautiful-dnd/issues/131
+ `);
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-closest-scrollable.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-closest-scrollable.js
new file mode 100644
index 000000000..f221c5d60
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-closest-scrollable.js
@@ -0,0 +1,78 @@
+import { invariant } from "../../invariant";
+import { warning } from "../../dev-warning";
+import getBodyElement from "../get-body-element";
+
+const isEqual = (base) => (value) => base === value;
+const isScroll = isEqual("scroll");
+const isAuto = isEqual("auto");
+const isVisible = isEqual("visible");
+const isEither = (overflow, fn) => fn(overflow.overflowX) || fn(overflow.overflowY);
+const isBoth = (overflow, fn) => fn(overflow.overflowX) && fn(overflow.overflowY);
+const isElementScrollable = (el) => {
+ const style = window.getComputedStyle(el);
+ const overflow = {
+ overflowX: style.overflowX,
+ overflowY: style.overflowY
+ };
+ return isEither(overflow, isScroll) || isEither(overflow, isAuto);
+};
+
+// Special case for a body element
+// Playground: https://codepen.io/alexreardon/pen/ZmyLgX?editors=1111
+const isBodyScrollable = () => {
+ // Because we always return false for now, we can skip any actual processing in production
+ if (process.env.NODE_ENV === "production") {
+ return false;
+ }
+ const body = getBodyElement();
+ const html = document.documentElement;
+ invariant(html);
+
+ // 1. The `body` has `overflow-[x|y]: auto | scroll`
+ if (!isElementScrollable(body)) {
+ return false;
+ }
+ const htmlStyle = window.getComputedStyle(html);
+ const htmlOverflow = {
+ overflowX: htmlStyle.overflowX,
+ overflowY: htmlStyle.overflowY
+ };
+ if (isBoth(htmlOverflow, isVisible)) {
+ return false;
+ }
+ warning(`
+ We have detected that your element might be a scroll container.
+ We have found no reliable way of detecting whether the element is a scroll container.
+ Under most circumstances a scroll bar will be on the element (document.documentElement)
+
+ Because we cannot determine if the is a scroll container, and generally it is not one,
+ we will be treating the as *not* a scroll container
+
+ More information: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/how-we-detect-scroll-containers.md
+ `);
+ return false;
+};
+const getClosestScrollable = (el) => {
+ // cannot do anything else!
+ if (el == null) {
+ return null;
+ }
+
+ // not allowing us to go higher then body
+ if (el === document.body) {
+ return isBodyScrollable() ? el : null;
+ }
+
+ // Should never get here, but just being safe
+ if (el === document.documentElement) {
+ return null;
+ }
+ if (!isElementScrollable(el)) {
+ // keep recursing
+ return getClosestScrollable(el.parentElement);
+ }
+
+ // success!
+ return el;
+};
+export default getClosestScrollable;
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-dimension.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-dimension.js
new file mode 100644
index 000000000..0b365ae2b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-dimension.js
@@ -0,0 +1,101 @@
+import { createBox, expand, getBox, withScroll } from "css-box-model";
+import getDroppableDimension from "../../state/droppable/get-droppable";
+import getScroll from "./get-scroll";
+
+const getClient = (targetRef, closestScrollable) => {
+ const base = getBox(targetRef);
+
+ // Droppable has no scroll parent
+ if (!closestScrollable) {
+ return base;
+ }
+
+ // Droppable is not the same as the closest scrollable
+ if (targetRef !== closestScrollable) {
+ return base;
+ }
+
+ // Droppable is scrollable
+
+ // Element.getBoundingClient() returns a clipped padding box:
+ // When not scrollable: the full size of the element
+ // When scrollable: the visible size of the element
+ // (which is not the full width of its scrollable content)
+ // So we recalculate the borderBox of a scrollable droppable to give
+ // it its full dimensions. This will be cut to the correct size by the frame
+
+ // Creating the paddingBox based on scrollWidth / scrollTop
+ // scrollWidth / scrollHeight are based on the paddingBox of an element
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
+ const top = base.paddingBox.top - closestScrollable.scrollTop;
+ const left = base.paddingBox.left - closestScrollable.scrollLeft;
+ const bottom = top + closestScrollable.scrollHeight;
+ const right = left + closestScrollable.scrollWidth;
+
+ // unclipped padding box
+ const paddingBox = {
+ top,
+ right,
+ bottom,
+ left
+ };
+
+ // Creating the borderBox by adding the borders to the paddingBox
+ const borderBox = expand(paddingBox, base.border);
+
+ // We are not accounting for scrollbars
+ // Adjusting for scrollbars is hard because:
+ // - they are different between browsers
+ // - scrollbars can be activated and removed during a drag
+ // We instead account for this slightly in our auto scroller
+
+ const client = createBox({
+ borderBox,
+ margin: base.margin,
+ border: base.border,
+ padding: base.padding
+ });
+ return client;
+};
+export default ({
+ ref,
+ descriptor,
+ env,
+ windowScroll,
+ direction,
+ isDropDisabled,
+ isCombineEnabled,
+ shouldClipSubject
+}) => {
+ const closestScrollable = env.closestScrollable;
+ const client = getClient(ref, closestScrollable);
+ const page = withScroll(client, windowScroll);
+ const closest = (() => {
+ if (!closestScrollable) {
+ return null;
+ }
+ const frameClient = getBox(closestScrollable);
+ const scrollSize = {
+ scrollHeight: closestScrollable.scrollHeight,
+ scrollWidth: closestScrollable.scrollWidth
+ };
+ return {
+ client: frameClient,
+ page: withScroll(frameClient, windowScroll),
+ scroll: getScroll(closestScrollable),
+ scrollSize,
+ shouldClipSubject
+ };
+ })();
+ const dimension = getDroppableDimension({
+ descriptor,
+ isEnabled: !isDropDisabled,
+ isCombineEnabled,
+ isFixedOnPage: env.isFixedOnPage,
+ direction,
+ client,
+ page,
+ closest
+ });
+ return dimension;
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-env.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-env.js
new file mode 100644
index 000000000..d13543707
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-env.js
@@ -0,0 +1,22 @@
+import getClosestScrollable from "./get-closest-scrollable";
+// TODO: do this check at the same time as the closest scrollable
+// in order to avoid double calling getComputedStyle
+// Do this when we move to multiple scroll containers
+const getIsFixed = (el) => {
+ if (!el) {
+ return false;
+ }
+ const style = window.getComputedStyle(el);
+ if (style.position === "fixed") {
+ return true;
+ }
+ return getIsFixed(el.parentElement);
+};
+export default (start) => {
+ const closestScrollable = getClosestScrollable(start);
+ const isFixedOnPage = getIsFixed(start);
+ return {
+ closestScrollable,
+ isFixedOnPage
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-listener-options.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-listener-options.js
new file mode 100644
index 000000000..8c5e00ba0
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-listener-options.js
@@ -0,0 +1,7 @@
+const immediate = {
+ passive: false
+};
+const delayed = {
+ passive: true
+};
+export default (options) => (options.shouldPublishImmediately ? immediate : delayed);
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-scroll.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-scroll.js
new file mode 100644
index 000000000..032f9c544
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/get-scroll.js
@@ -0,0 +1,4 @@
+export default (el) => ({
+ x: el.scrollLeft,
+ y: el.scrollTop
+});
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/index.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/index.js
new file mode 100644
index 000000000..9d3b57b9a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/index.js
@@ -0,0 +1 @@
+export { default } from "./use-droppable-publisher";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/is-in-fixed-container.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/is-in-fixed-container.js
new file mode 100644
index 000000000..d208badfb
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/is-in-fixed-container.js
@@ -0,0 +1,16 @@
+const isElementFixed = (el) => window.getComputedStyle(el).position === "fixed";
+const find = (el) => {
+ // cannot do anything else!
+ if (el == null) {
+ return false;
+ }
+
+ // keep looking
+ if (!isElementFixed(el)) {
+ return find(el.parentElement);
+ }
+
+ // success!
+ return true;
+};
+export default find;
diff --git a/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/use-droppable-publisher.js b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/use-droppable-publisher.js
new file mode 100644
index 000000000..2649b342a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-droppable-publisher/use-droppable-publisher.js
@@ -0,0 +1,194 @@
+import { useRef } from "react";
+import rafSchedule from "raf-schd";
+import { useCallback, useMemo } from "use-memo-one";
+import memoizeOne from "memoize-one";
+import { invariant } from "../../invariant";
+import checkForNestedScrollContainers from "./check-for-nested-scroll-container";
+import * as dataAttr from "../data-attributes";
+import { origin } from "../../state/position";
+import getScroll from "./get-scroll";
+import getEnv from "./get-env";
+import getDimension from "./get-dimension";
+import AppContext from "../context/app-context";
+import { warning } from "../../dev-warning";
+import getListenerOptions from "./get-listener-options";
+import useRequiredContext from "../use-required-context";
+import usePreviousRef from "../use-previous-ref";
+import useLayoutEffect from "../use-isomorphic-layout-effect";
+import useUniqueId from "../use-unique-id";
+
+const getClosestScrollableFromDrag = (dragging) => (dragging && dragging.env.closestScrollable) || null;
+export default function useDroppablePublisher(args) {
+ const whileDraggingRef = useRef(null);
+ const appContext = useRequiredContext(AppContext);
+ const uniqueId = useUniqueId("droppable");
+ const { registry, marshal } = appContext;
+ const previousRef = usePreviousRef(args);
+ const descriptor = useMemo(
+ () => ({
+ id: args.droppableId,
+ type: args.type,
+ mode: args.mode
+ }),
+ [args.droppableId, args.mode, args.type]
+ );
+ const publishedDescriptorRef = useRef(descriptor);
+ const memoizedUpdateScroll = useMemo(
+ () =>
+ memoizeOne((x, y) => {
+ invariant(whileDraggingRef.current, "Can only update scroll when dragging");
+ const scroll = {
+ x,
+ y
+ };
+ marshal.updateDroppableScroll(descriptor.id, scroll);
+ }),
+ [descriptor.id, marshal]
+ );
+ const getClosestScroll = useCallback(() => {
+ const dragging = whileDraggingRef.current;
+ if (!dragging || !dragging.env.closestScrollable) {
+ return origin;
+ }
+ return getScroll(dragging.env.closestScrollable);
+ }, []);
+ const updateScroll = useCallback(() => {
+ // reading scroll value when called so value will be the latest
+ const scroll = getClosestScroll();
+ memoizedUpdateScroll(scroll.x, scroll.y);
+ }, [getClosestScroll, memoizedUpdateScroll]);
+ const scheduleScrollUpdate = useMemo(() => rafSchedule(updateScroll), [updateScroll]);
+ const onClosestScroll = useCallback(() => {
+ const dragging = whileDraggingRef.current;
+ const closest = getClosestScrollableFromDrag(dragging);
+ invariant(dragging && closest, "Could not find scroll options while scrolling");
+ const options = dragging.scrollOptions;
+ if (options.shouldPublishImmediately) {
+ updateScroll();
+ return;
+ }
+ scheduleScrollUpdate();
+ }, [scheduleScrollUpdate, updateScroll]);
+ const getDimensionAndWatchScroll = useCallback(
+ (windowScroll, options) => {
+ invariant(!whileDraggingRef.current, "Cannot collect a droppable while a drag is occurring");
+ const previous = previousRef.current;
+ const ref = previous.getDroppableRef();
+ invariant(ref, "Cannot collect without a droppable ref");
+ const env = getEnv(ref);
+ const dragging = {
+ ref,
+ descriptor,
+ env,
+ scrollOptions: options
+ };
+ // side effect
+ whileDraggingRef.current = dragging;
+ const dimension = getDimension({
+ ref,
+ descriptor,
+ env,
+ windowScroll,
+ direction: previous.direction,
+ isDropDisabled: previous.isDropDisabled,
+ isCombineEnabled: previous.isCombineEnabled,
+ shouldClipSubject: !previous.ignoreContainerClipping
+ });
+ const scrollable = env.closestScrollable;
+ if (scrollable) {
+ scrollable.setAttribute(dataAttr.scrollContainer.contextId, appContext.contextId);
+
+ // bind scroll listener
+ scrollable.addEventListener("scroll", onClosestScroll, getListenerOptions(dragging.scrollOptions));
+ // print a debug warning if using an unsupported nested scroll container setup
+ if (process.env.NODE_ENV !== "production") {
+ checkForNestedScrollContainers(scrollable);
+ }
+ }
+ return dimension;
+ },
+ [appContext.contextId, descriptor, onClosestScroll, previousRef]
+ );
+ const getScrollWhileDragging = useCallback(() => {
+ const dragging = whileDraggingRef.current;
+ const closest = getClosestScrollableFromDrag(dragging);
+ invariant(dragging && closest, "Can only recollect Droppable client for Droppables that have a scroll container");
+ return getScroll(closest);
+ }, []);
+ const dragStopped = useCallback(() => {
+ const dragging = whileDraggingRef.current;
+ invariant(dragging, "Cannot stop drag when no active drag");
+ const closest = getClosestScrollableFromDrag(dragging);
+
+ // goodbye old friend
+ whileDraggingRef.current = null;
+ if (!closest) {
+ return;
+ }
+
+ // unwatch scroll
+ scheduleScrollUpdate.cancel();
+ closest.removeAttribute(dataAttr.scrollContainer.contextId);
+ closest.removeEventListener("scroll", onClosestScroll, getListenerOptions(dragging.scrollOptions));
+ }, [onClosestScroll, scheduleScrollUpdate]);
+ const scroll = useCallback((change) => {
+ // arrange
+ const dragging = whileDraggingRef.current;
+ invariant(dragging, "Cannot scroll when there is no drag");
+ const closest = getClosestScrollableFromDrag(dragging);
+ invariant(closest, "Cannot scroll a droppable with no closest scrollable");
+
+ // act
+ closest.scrollTop += change.y;
+ closest.scrollLeft += change.x;
+ }, []);
+ const callbacks = useMemo(() => {
+ return {
+ getDimensionAndWatchScroll,
+ getScrollWhileDragging,
+ dragStopped,
+ scroll
+ };
+ }, [dragStopped, getDimensionAndWatchScroll, getScrollWhileDragging, scroll]);
+ const entry = useMemo(
+ () => ({
+ uniqueId,
+ descriptor,
+ callbacks
+ }),
+ [callbacks, descriptor, uniqueId]
+ );
+
+ // Register with the marshal and let it know of:
+ // - any descriptor changes
+ // - when it unmounts
+ useLayoutEffect(() => {
+ publishedDescriptorRef.current = entry.descriptor;
+ registry.droppable.register(entry);
+ return () => {
+ if (whileDraggingRef.current) {
+ warning("Unsupported: changing the droppableId or type of a Droppable during a drag");
+ dragStopped();
+ }
+ registry.droppable.unregister(entry);
+ };
+ }, [callbacks, descriptor, dragStopped, entry, marshal, registry.droppable]);
+
+ // update is enabled with the marshal
+ // only need to update when there is a drag
+ useLayoutEffect(() => {
+ if (!whileDraggingRef.current) {
+ return;
+ }
+ marshal.updateDroppableIsEnabled(publishedDescriptorRef.current.id, !args.isDropDisabled);
+ }, [args.isDropDisabled, marshal]);
+
+ // update is combine enabled with the marshal
+ // only need to update when there is a drag
+ useLayoutEffect(() => {
+ if (!whileDraggingRef.current) {
+ return;
+ }
+ marshal.updateDroppableIsCombineEnabled(publishedDescriptorRef.current.id, args.isCombineEnabled);
+ }, [args.isCombineEnabled, marshal]);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/focus-marshal-types.js b/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/focus-marshal-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/index.js b/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/index.js
new file mode 100644
index 000000000..53351e9a4
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/index.js
@@ -0,0 +1 @@
+export { default } from "./use-focus-marshal";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/use-focus-marshal.js b/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/use-focus-marshal.js
new file mode 100644
index 000000000..1e4350c5b
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-focus-marshal/use-focus-marshal.js
@@ -0,0 +1,99 @@
+import { useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { dragHandle as dragHandleAttr } from "../data-attributes";
+import useLayoutEffect from "../use-isomorphic-layout-effect";
+import findDragHandle from "../get-elements/find-drag-handle";
+
+export default function useFocusMarshal(contextId) {
+ const entriesRef = useRef({});
+ const recordRef = useRef(null);
+ const restoreFocusFrameRef = useRef(null);
+ const isMountedRef = useRef(false);
+ const register = useCallback(function register(id, focus) {
+ const entry = {
+ id,
+ focus
+ };
+ entriesRef.current[id] = entry;
+ return function unregister() {
+ const entries = entriesRef.current;
+ const current = entries[id];
+ // entry might have been overrided by another registration
+ if (current !== entry) {
+ delete entries[id];
+ }
+ };
+ }, []);
+ const tryGiveFocus = useCallback(
+ function tryGiveFocus(tryGiveFocusTo) {
+ const handle = findDragHandle(contextId, tryGiveFocusTo);
+ if (handle && handle !== document.activeElement) {
+ handle.focus();
+ }
+ },
+ [contextId]
+ );
+ const tryShiftRecord = useCallback(function tryShiftRecord(previous, redirectTo) {
+ if (recordRef.current === previous) {
+ recordRef.current = redirectTo;
+ }
+ }, []);
+ const tryRestoreFocusRecorded = useCallback(
+ function tryRestoreFocusRecorded() {
+ // frame already queued
+ if (restoreFocusFrameRef.current) {
+ return;
+ }
+
+ // cannot give focus if unmounted
+ // this code path is generally not hit expect for some hot-reloading flows
+ if (!isMountedRef.current) {
+ return;
+ }
+ restoreFocusFrameRef.current = requestAnimationFrame(() => {
+ restoreFocusFrameRef.current = null;
+ const record = recordRef.current;
+ if (record) {
+ tryGiveFocus(record);
+ }
+ });
+ },
+ [tryGiveFocus]
+ );
+ const tryRecordFocus = useCallback(function tryRecordFocus(id) {
+ // clear any existing record
+ recordRef.current = null;
+ const focused = document.activeElement;
+
+ // no item focused so it cannot be our item
+ if (!focused) {
+ return;
+ }
+
+ // focused element is not a drag handle or does not have the right id
+ if (focused.getAttribute(dragHandleAttr.draggableId) !== id) {
+ return;
+ }
+ recordRef.current = id;
+ }, []);
+ useLayoutEffect(() => {
+ isMountedRef.current = true;
+ return function clearFrameOnUnmount() {
+ isMountedRef.current = false;
+ const frameId = restoreFocusFrameRef.current;
+ if (frameId) {
+ cancelAnimationFrame(frameId);
+ }
+ };
+ }, []);
+ const marshal = useMemo(
+ () => ({
+ register,
+ tryRecordFocus,
+ tryRestoreFocusRecorded,
+ tryShiftRecord
+ }),
+ [register, tryRecordFocus, tryRestoreFocusRecorded, tryShiftRecord]
+ );
+ return marshal;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/index.js b/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/index.js
new file mode 100644
index 000000000..950a90796
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/index.js
@@ -0,0 +1 @@
+export { default } from "./use-hidden-text-element";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/use-hidden-text-element.js b/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/use-hidden-text-element.js
new file mode 100644
index 000000000..55472f51d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-hidden-text-element/use-hidden-text-element.js
@@ -0,0 +1,48 @@
+import { useEffect } from "react";
+import { useMemo } from "use-memo-one";
+import getBodyElement from "../get-body-element";
+import useUniqueId from "../use-unique-id";
+
+export function getElementId({ contextId, uniqueId }) {
+ return `rbd-hidden-text-${contextId}-${uniqueId}`;
+}
+
+export default function useHiddenTextElement({ contextId, text }) {
+ const uniqueId = useUniqueId("hidden-text", {
+ separator: "-"
+ });
+ const id = useMemo(
+ () =>
+ getElementId({
+ contextId,
+ uniqueId
+ }),
+ [uniqueId, contextId]
+ );
+ useEffect(
+ function mount() {
+ const el = document.createElement("div");
+
+ // identifier
+ el.id = id;
+
+ // add the description text
+ el.textContent = text;
+
+ // Using `display: none` prevent screen readers from reading this element in the document flow
+ el.style.display = "none";
+
+ // Add to body
+ getBodyElement().appendChild(el);
+ return function unmount() {
+ // checking if element exists as the body might have been changed by things like 'turbolinks'
+ const body = getBodyElement();
+ if (body.contains(el)) {
+ body.removeChild(el);
+ }
+ };
+ },
+ [id, text]
+ );
+ return id;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-isomorphic-layout-effect.js b/client/src/components/trello-board/dnd/lib/view/use-isomorphic-layout-effect.js
new file mode 100644
index 000000000..a90252d59
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-isomorphic-layout-effect.js
@@ -0,0 +1,16 @@
+// eslint-disable-next-line no-restricted-imports
+import { useEffect, useLayoutEffect } from "react"; // https://github.com/reduxjs/react-redux/blob/v7-beta/src/components/connectAdvanced.js#L35
+
+// https://github.com/reduxjs/react-redux/blob/v7-beta/src/components/connectAdvanced.js#L35
+// React currently throws a warning when using useLayoutEffect on the server.
+// To get around it, we can conditionally useEffect on the server (no-op) and
+// useLayoutEffect in the browser. We need useLayoutEffect because we want
+// `connect` to perform sync updates to a ref to save the latest props after
+// a render is actually committed to the DOM.
+const useIsomorphicLayoutEffect =
+ typeof window !== "undefined" &&
+ typeof window.document !== "undefined" &&
+ typeof window.document.createElement !== "undefined"
+ ? useLayoutEffect
+ : useEffect;
+export default useIsomorphicLayoutEffect;
diff --git a/client/src/components/trello-board/dnd/lib/view/use-previous-ref.js b/client/src/components/trello-board/dnd/lib/view/use-previous-ref.js
new file mode 100644
index 000000000..ec5aa0272
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-previous-ref.js
@@ -0,0 +1,13 @@
+import { useEffect, useRef } from "react";
+
+export default function usePrevious(current) {
+ const ref = useRef(current);
+
+ // will be updated on the next render
+ useEffect(() => {
+ ref.current = current;
+ });
+
+ // return the existing current (pre render)
+ return ref;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-required-context.js b/client/src/components/trello-board/dnd/lib/view/use-required-context.js
new file mode 100644
index 000000000..808659d03
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-required-context.js
@@ -0,0 +1,8 @@
+import { useContext } from "react";
+import { invariant } from "../invariant";
+
+export default function useRequiredContext(Context) {
+ const result = useContext(Context);
+ invariant(result, "Could not find required context");
+ return result;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/closest.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/closest.js
new file mode 100644
index 000000000..c87e1054a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/closest.js
@@ -0,0 +1,40 @@
+import { find } from "../../native-with-fallback";
+
+const supportedMatchesName = (() => {
+ const base = "matches";
+
+ // Server side rendering
+ if (typeof document === "undefined") {
+ return base;
+ }
+
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
+ const candidates = [base, "msMatchesSelector", "webkitMatchesSelector"];
+ const value = find(candidates, (name) => name in Element.prototype);
+ return value || base;
+})();
+
+function closestPonyfill(el, selector) {
+ if (el == null) {
+ return null;
+ }
+
+ // Element.prototype.matches is supported in ie11 with a different name
+ // https://caniuse.com/#feat=matchesselector
+ // $FlowFixMe - dynamic property
+ if (el[supportedMatchesName](selector)) {
+ return el;
+ }
+
+ // recursively look up the tree
+ return closestPonyfill(el.parentElement, selector);
+}
+
+export default function closest(el, selector) {
+ // Using native closest for maximum speed where we can
+ if (el.closest) {
+ return el.closest(selector);
+ }
+ // ie11: damn you!
+ return closestPonyfill(el, selector);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/find-closest-draggable-id-from-event.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/find-closest-draggable-id-from-event.js
new file mode 100644
index 000000000..60285d2a4
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/find-closest-draggable-id-from-event.js
@@ -0,0 +1,35 @@
+import * as attributes from "../data-attributes";
+import isElement from "../is-type-of-element/is-element";
+import isHtmlElement from "../is-type-of-element/is-html-element";
+import closest from "./closest";
+import { warning } from "../../dev-warning";
+
+function getSelector(contextId) {
+ return `[${attributes.dragHandle.contextId}="${contextId}"]`;
+}
+
+function findClosestDragHandleFromEvent(contextId, event) {
+ const target = event.target;
+ if (!isElement(target)) {
+ warning("event.target must be a Element");
+ return null;
+ }
+ const selector = getSelector(contextId);
+ const handle = closest(target, selector);
+ if (!handle) {
+ return null;
+ }
+ if (!isHtmlElement(handle)) {
+ warning("drag handle must be a HTMLElement");
+ return null;
+ }
+ return handle;
+}
+
+export default function tryGetClosestDraggableIdFromEvent(contextId, event) {
+ const handle = findClosestDragHandleFromEvent(contextId, event);
+ if (!handle) {
+ return null;
+ }
+ return handle.getAttribute(attributes.dragHandle.draggableId);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/index.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/index.js
new file mode 100644
index 000000000..5f13d0aca
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/index.js
@@ -0,0 +1,4 @@
+export { default } from "./use-sensor-marshal";
+export { default as useMouseSensor } from "./sensors/use-mouse-sensor";
+export { default as useTouchSensor } from "./sensors/use-touch-sensor";
+export { default as useKeyboardSensor } from "./sensors/use-keyboard-sensor";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/is-event-in-interactive-element.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/is-event-in-interactive-element.js
new file mode 100644
index 000000000..d6aa3ce9e
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/is-event-in-interactive-element.js
@@ -0,0 +1,53 @@
+import isHtmlElement from "../is-type-of-element/is-html-element";
+
+export const interactiveTagNames = {
+ input: true,
+ button: true,
+ textarea: true,
+ select: true,
+ option: true,
+ optgroup: true,
+ video: true,
+ audio: true
+};
+
+function isAnInteractiveElement(parent, current) {
+ if (current == null) {
+ return false;
+ }
+
+ // Most interactive elements cannot have children. However, some can such as 'button'.
+ // See 'Permitted content' on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button
+ // Rather than having two different functions we can consolidate our checks into this single
+ // function to keep things simple.
+ // There is no harm checking if the parent has an interactive tag name even if it cannot have
+ // any children. We need to perform this loop anyway to check for the contenteditable attribute
+ const hasAnInteractiveTag = Boolean(interactiveTagNames[current.tagName.toLowerCase()]);
+ if (hasAnInteractiveTag) {
+ return true;
+ }
+
+ // contenteditable="true" or contenteditable="" are valid ways
+ // of creating a contenteditable container
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable
+ const attribute = current.getAttribute("contenteditable");
+ if (attribute === "true" || attribute === "") {
+ return true;
+ }
+
+ // nothing more can be done and no results found
+ if (current === parent) {
+ return false;
+ }
+
+ // recursion to check parent
+ return isAnInteractiveElement(parent, current.parentElement);
+}
+
+export default function isEventInInteractiveElement(draggable, event) {
+ const target = event.target;
+ if (!isHtmlElement(target)) {
+ return false;
+ }
+ return isAnInteractiveElement(draggable, target);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/lock.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/lock.js
new file mode 100644
index 000000000..397d2f1c8
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/lock.js
@@ -0,0 +1,44 @@
+import { invariant } from "../../invariant";
+
+export default function create() {
+ let lock = null;
+
+ function isClaimed() {
+ return Boolean(lock);
+ }
+
+ function isActive(value) {
+ return value === lock;
+ }
+
+ function claim(abandon) {
+ invariant(!lock, "Cannot claim lock as it is already claimed");
+ const newLock = {
+ abandon
+ };
+ // update singleton
+ lock = newLock;
+ // return lock
+ return newLock;
+ }
+
+ function release() {
+ invariant(lock, "Cannot release lock when there is no lock");
+ lock = null;
+ }
+
+ function tryAbandon() {
+ if (lock) {
+ lock.abandon();
+ release();
+ }
+ }
+
+ return {
+ isClaimed,
+ isActive,
+ claim,
+ release,
+ tryAbandon
+ };
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-keyboard-sensor.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-keyboard-sensor.js
new file mode 100644
index 000000000..896a918e0
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-keyboard-sensor.js
@@ -0,0 +1,210 @@
+import { useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../../invariant";
+import * as keyCodes from "../../key-codes";
+import bindEvents from "../../event-bindings/bind-events";
+import preventStandardKeyEvents from "./util/prevent-standard-key-events";
+import supportedPageVisibilityEventName from "./util/supported-page-visibility-event-name";
+import useLayoutEffect from "../../use-isomorphic-layout-effect";
+
+function noop() {}
+
+const scrollJumpKeys = {
+ [keyCodes.pageDown]: true,
+ [keyCodes.pageUp]: true,
+ [keyCodes.home]: true,
+ [keyCodes.end]: true
+};
+
+function getDraggingBindings(actions, stop) {
+ function cancel() {
+ stop();
+ actions.cancel();
+ }
+
+ function drop() {
+ stop();
+ actions.drop();
+ }
+
+ return [
+ {
+ eventName: "keydown",
+ fn: (event) => {
+ if (event.keyCode === keyCodes.escape) {
+ event.preventDefault();
+ cancel();
+ return;
+ }
+
+ // Dropping
+ if (event.keyCode === keyCodes.space) {
+ // need to stop parent Draggable's thinking this is a lift
+ event.preventDefault();
+ drop();
+ return;
+ }
+
+ // Movement
+
+ if (event.keyCode === keyCodes.arrowDown) {
+ event.preventDefault();
+ actions.moveDown();
+ return;
+ }
+ if (event.keyCode === keyCodes.arrowUp) {
+ event.preventDefault();
+ actions.moveUp();
+ return;
+ }
+ if (event.keyCode === keyCodes.arrowRight) {
+ event.preventDefault();
+ actions.moveRight();
+ return;
+ }
+ if (event.keyCode === keyCodes.arrowLeft) {
+ event.preventDefault();
+ actions.moveLeft();
+ return;
+ }
+
+ // preventing scroll jumping at this time
+ if (scrollJumpKeys[event.keyCode]) {
+ event.preventDefault();
+ return;
+ }
+ preventStandardKeyEvents(event);
+ }
+ },
+ // any mouse actions kills a drag
+ {
+ eventName: "mousedown",
+ fn: cancel
+ },
+ {
+ eventName: "mouseup",
+ fn: cancel
+ },
+ {
+ eventName: "click",
+ fn: cancel
+ },
+ {
+ eventName: "touchstart",
+ fn: cancel
+ },
+ // resizing the browser kills a drag
+ {
+ eventName: "resize",
+ fn: cancel
+ },
+ // kill if the user is using the mouse wheel
+ // We are not supporting wheel / trackpad scrolling with keyboard dragging
+ {
+ eventName: "wheel",
+ fn: cancel,
+ // chrome says it is a violation for this to not be passive
+ // it is fine for it to be passive as we just cancel as soon as we get
+ // any event
+ options: {
+ passive: true
+ }
+ },
+ // Cancel on page visibility change
+ {
+ eventName: supportedPageVisibilityEventName,
+ fn: cancel
+ }
+ ];
+}
+
+export default function useKeyboardSensor(api) {
+ const unbindEventsRef = useRef(noop);
+ const startCaptureBinding = useMemo(
+ () => ({
+ eventName: "keydown",
+ fn: function onKeyDown(event) {
+ // Event already used
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ // Need to start drag with a spacebar press
+ if (event.keyCode !== keyCodes.space) {
+ return;
+ }
+ const draggableId = api.findClosestDraggableId(event);
+ if (!draggableId) {
+ return;
+ }
+ const preDrag = api.tryGetLock(
+ draggableId,
+ // abort function not defined yet
+ // eslint-disable-next-line no-use-before-define
+ stop,
+ {
+ sourceEvent: event
+ }
+ );
+
+ // Cannot start capturing at this time
+ if (!preDrag) {
+ return;
+ }
+
+ // we are consuming the event
+ event.preventDefault();
+ let isCapturing = true;
+
+ // There is no pending period for a keyboard drag
+ // We can lift immediately
+ const actions = preDrag.snapLift();
+
+ // unbind this listener
+ unbindEventsRef.current();
+
+ // setup our function to end everything
+ function stop() {
+ invariant(isCapturing, "Cannot stop capturing a keyboard drag when not capturing");
+ isCapturing = false;
+
+ // unbind dragging bindings
+ unbindEventsRef.current();
+ // start listening for capture again
+ // eslint-disable-next-line no-use-before-define
+ listenForCapture();
+ }
+
+ // bind dragging listeners
+ unbindEventsRef.current = bindEvents(window, getDraggingBindings(actions, stop), {
+ capture: true,
+ passive: false
+ });
+ }
+ }),
+ // not including startPendingDrag as it is not defined initially
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [api]
+ );
+ const listenForCapture = useCallback(
+ function tryStartCapture() {
+ const options = {
+ passive: false,
+ capture: true
+ };
+ unbindEventsRef.current = bindEvents(window, [startCaptureBinding], options);
+ },
+ [startCaptureBinding]
+ );
+ useLayoutEffect(
+ function mount() {
+ listenForCapture();
+
+ // kill any pending window events when unmounting
+ return function unmount() {
+ unbindEventsRef.current();
+ };
+ },
+ [listenForCapture]
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-mouse-sensor.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-mouse-sensor.js
new file mode 100644
index 000000000..5f393ed19
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-mouse-sensor.js
@@ -0,0 +1,316 @@
+import { useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../../invariant";
+import bindEvents from "../../event-bindings/bind-events";
+import * as keyCodes from "../../key-codes";
+import preventStandardKeyEvents from "./util/prevent-standard-key-events";
+import supportedPageVisibilityEventName from "./util/supported-page-visibility-event-name";
+import useLayoutEffect from "../../use-isomorphic-layout-effect";
+import { noop } from "../../../empty";
+
+// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
+export const primaryButton = 0;
+export const sloppyClickThreshold = 5;
+
+function isSloppyClickThresholdExceeded(original, current) {
+ return (
+ Math.abs(current.x - original.x) >= sloppyClickThreshold || Math.abs(current.y - original.y) >= sloppyClickThreshold
+ );
+}
+
+const idle = {
+ type: "IDLE"
+};
+
+function getCaptureBindings({ cancel, completed, getPhase, setPhase }) {
+ return [
+ {
+ eventName: "mousemove",
+ fn: (event) => {
+ const { button, clientX, clientY } = event;
+ if (button !== primaryButton) {
+ return;
+ }
+ const point = {
+ x: clientX,
+ y: clientY
+ };
+ const phase = getPhase();
+
+ // Already dragging
+ if (phase.type === "DRAGGING") {
+ // preventing default as we are using this event
+ event.preventDefault();
+ phase.actions.move(point);
+ return;
+ }
+
+ // There should be a pending drag at this point
+ invariant(phase.type === "PENDING", "Cannot be IDLE");
+ const pending = phase.point;
+
+ // threshold not yet exceeded
+ if (!isSloppyClickThresholdExceeded(pending, point)) {
+ return;
+ }
+
+ // preventing default as we are using this event
+ event.preventDefault();
+
+ // Lifting at the current point to prevent the draggable item from
+ // jumping by the sloppyClickThreshold
+ const actions = phase.actions.fluidLift(point);
+ setPhase({
+ type: "DRAGGING",
+ actions
+ });
+ }
+ },
+ {
+ eventName: "mouseup",
+ fn: (event) => {
+ const phase = getPhase();
+ if (phase.type !== "DRAGGING") {
+ cancel();
+ return;
+ }
+
+ // preventing default as we are using this event
+ event.preventDefault();
+ phase.actions.drop({
+ shouldBlockNextClick: true
+ });
+ completed();
+ }
+ },
+ {
+ eventName: "mousedown",
+ fn: (event) => {
+ // this can happen during a drag when the user clicks a button
+ // other than the primary mouse button
+ if (getPhase().type === "DRAGGING") {
+ event.preventDefault();
+ }
+ cancel();
+ }
+ },
+ {
+ eventName: "keydown",
+ fn: (event) => {
+ const phase = getPhase();
+ // Abort if any keystrokes while a drag is pending
+ if (phase.type === "PENDING") {
+ cancel();
+ return;
+ }
+
+ // cancelling a drag
+ if (event.keyCode === keyCodes.escape) {
+ event.preventDefault();
+ cancel();
+ return;
+ }
+ preventStandardKeyEvents(event);
+ }
+ },
+ {
+ eventName: "resize",
+ fn: cancel
+ },
+ {
+ eventName: "scroll",
+ // kill a pending drag if there is a window scroll
+ options: {
+ passive: true,
+ capture: false
+ },
+ fn: () => {
+ if (getPhase().type === "PENDING") {
+ cancel();
+ }
+ }
+ },
+ // Need to opt out of dragging if the user is a force press
+ // Only for safari which has decided to introduce its own custom way of doing things
+ // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
+ {
+ eventName: "webkitmouseforcedown",
+ // it is considered a indirect cancel so we do not
+ // prevent default in any situation.
+ fn: (event) => {
+ const phase = getPhase();
+ invariant(phase.type !== "IDLE", "Unexpected phase");
+ if (phase.actions.shouldRespectForcePress()) {
+ cancel();
+ return;
+ }
+
+ // This technically doesn't do anything.
+ // It won't do anything if `webkitmouseforcewillbegin` is prevented.
+ // But it is a good signal that we want to opt out of this
+
+ event.preventDefault();
+ }
+ },
+ // Cancel on page visibility change
+ {
+ eventName: supportedPageVisibilityEventName,
+ fn: cancel
+ }
+ ];
+}
+
+export default function useMouseSensor(api) {
+ const phaseRef = useRef(idle);
+ const unbindEventsRef = useRef(noop);
+ const startCaptureBinding = useMemo(
+ () => ({
+ eventName: "mousedown",
+ fn: function onMouseDown(event) {
+ // Event already used
+ if (event.defaultPrevented) {
+ return;
+ }
+ // only starting a drag if dragging with the primary mouse button
+ if (event.button !== primaryButton) {
+ return;
+ }
+
+ // Do not start a drag if any modifier key is pressed
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
+ return;
+ }
+ const draggableId = api.findClosestDraggableId(event);
+ if (!draggableId) {
+ return;
+ }
+ const actions = api.tryGetLock(
+ draggableId,
+ // stop is defined later
+ // eslint-disable-next-line no-use-before-define
+ stop,
+ {
+ sourceEvent: event
+ }
+ );
+ if (!actions) {
+ return;
+ }
+
+ // consuming the event
+ event.preventDefault();
+ const point = {
+ x: event.clientX,
+ y: event.clientY
+ };
+
+ // unbind this listener
+ unbindEventsRef.current();
+ // using this function before it is defined as their is a circular usage pattern
+ // eslint-disable-next-line no-use-before-define
+ startPendingDrag(actions, point);
+ }
+ }),
+ // not including startPendingDrag as it is not defined initially
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [api]
+ );
+ const preventForcePressBinding = useMemo(
+ () => ({
+ eventName: "webkitmouseforcewillbegin",
+ fn: (event) => {
+ if (event.defaultPrevented) {
+ return;
+ }
+ const id = api.findClosestDraggableId(event);
+ if (!id) {
+ return;
+ }
+ const options = api.findOptionsForDraggable(id);
+ if (!options) {
+ return;
+ }
+ if (options.shouldRespectForcePress) {
+ return;
+ }
+ if (!api.canGetLock(id)) {
+ return;
+ }
+ event.preventDefault();
+ }
+ }),
+ [api]
+ );
+ const listenForCapture = useCallback(
+ function listenForCapture() {
+ const options = {
+ passive: false,
+ capture: true
+ };
+ unbindEventsRef.current = bindEvents(window, [preventForcePressBinding, startCaptureBinding], options);
+ },
+ [preventForcePressBinding, startCaptureBinding]
+ );
+ const stop = useCallback(() => {
+ const current = phaseRef.current;
+ if (current.type === "IDLE") {
+ return;
+ }
+ phaseRef.current = idle;
+ unbindEventsRef.current();
+ listenForCapture();
+ }, [listenForCapture]);
+ const cancel = useCallback(() => {
+ const phase = phaseRef.current;
+ stop();
+ if (phase.type === "DRAGGING") {
+ phase.actions.cancel({
+ shouldBlockNextClick: true
+ });
+ }
+ if (phase.type === "PENDING") {
+ phase.actions.abort();
+ }
+ }, [stop]);
+ const bindCapturingEvents = useCallback(
+ function bindCapturingEvents() {
+ const options = {
+ capture: true,
+ passive: false
+ };
+ const bindings = getCaptureBindings({
+ cancel,
+ completed: stop,
+ getPhase: () => phaseRef.current,
+ setPhase: (phase) => {
+ phaseRef.current = phase;
+ }
+ });
+ unbindEventsRef.current = bindEvents(window, bindings, options);
+ },
+ [cancel, stop]
+ );
+ const startPendingDrag = useCallback(
+ function startPendingDrag(actions, point) {
+ invariant(phaseRef.current.type === "IDLE", "Expected to move from IDLE to PENDING drag");
+ phaseRef.current = {
+ type: "PENDING",
+ point,
+ actions
+ };
+ bindCapturingEvents();
+ },
+ [bindCapturingEvents]
+ );
+ useLayoutEffect(
+ function mount() {
+ listenForCapture();
+
+ // kill any pending window events when unmounting
+ return function unmount() {
+ unbindEventsRef.current();
+ };
+ },
+ [listenForCapture]
+ );
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-touch-sensor.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-touch-sensor.js
new file mode 100644
index 000000000..3210e2188
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/use-touch-sensor.js
@@ -0,0 +1,385 @@
+import { useRef } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../../invariant";
+import bindEvents from "../../event-bindings/bind-events";
+import * as keyCodes from "../../key-codes";
+import supportedPageVisibilityEventName from "./util/supported-page-visibility-event-name";
+import { noop } from "../../../empty";
+import useLayoutEffect from "../../use-isomorphic-layout-effect";
+
+const idle = {
+ type: "IDLE"
+};
+// Decreased from 150 as a work around for an issue for forcepress on iOS
+// https://github.com/atlassian/react-beautiful-dnd/issues/1401
+export const timeForLongPress = 120;
+export const forcePressThreshold = 0.15;
+
+function getWindowBindings({ cancel, getPhase }) {
+ return [
+ // If the orientation of the device changes - kill the drag
+ // https://davidwalsh.name/orientation-change
+ {
+ eventName: "orientationchange",
+ fn: cancel
+ },
+ // some devices fire resize if the orientation changes
+ {
+ eventName: "resize",
+ fn: cancel
+ },
+ // Long press can bring up a context menu
+ // need to opt out of this behavior
+ {
+ eventName: "contextmenu",
+ fn: (event) => {
+ // always opting out of context menu events
+ event.preventDefault();
+ }
+ },
+ // On some devices it is possible to have a touch interface with a keyboard.
+ // On any keyboard event we cancel a touch drag
+ {
+ eventName: "keydown",
+ fn: (event) => {
+ if (getPhase().type !== "DRAGGING") {
+ cancel();
+ return;
+ }
+
+ // direct cancel: we are preventing the default action
+ // indirect cancel: we are not preventing the default action
+
+ // escape is a direct cancel
+ if (event.keyCode === keyCodes.escape) {
+ event.preventDefault();
+ }
+ cancel();
+ }
+ },
+ // Cancel on page visibility change
+ {
+ eventName: supportedPageVisibilityEventName,
+ fn: cancel
+ }
+ ];
+}
+
+// All of the touch events get applied to the drag handle of the touch interaction
+// This plays well with the event.target being unmounted during a drag
+function getHandleBindings({ cancel, completed, getPhase }) {
+ return [
+ {
+ eventName: "touchmove",
+ // Opting out of passive touchmove (default) so as to prevent scrolling while moving
+ // Not worried about performance as effect of move is throttled in requestAnimationFrame
+ // Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393
+ options: {
+ capture: false
+ },
+ fn: (event) => {
+ const phase = getPhase();
+ // Drag has not yet started and we are waiting for a long press.
+ if (phase.type !== "DRAGGING") {
+ cancel();
+ return;
+ }
+
+ // At this point we are dragging
+ phase.hasMoved = true;
+ const { clientX, clientY } = event.touches[0];
+ const point = {
+ x: clientX,
+ y: clientY
+ };
+
+ // We need to prevent the default event in order to block native scrolling
+ // Also because we are using it as part of a drag we prevent the default action
+ // as a sign that we are using the event
+ event.preventDefault();
+ phase.actions.move(point);
+ }
+ },
+ {
+ eventName: "touchend",
+ fn: (event) => {
+ const phase = getPhase();
+ // drag had not started yet - do not prevent the default action
+ if (phase.type !== "DRAGGING") {
+ cancel();
+ return;
+ }
+
+ // ending the drag
+ event.preventDefault();
+ phase.actions.drop({
+ shouldBlockNextClick: true
+ });
+ completed();
+ }
+ },
+ {
+ eventName: "touchcancel",
+ fn: (event) => {
+ // drag had not started yet - do not prevent the default action
+ if (getPhase().type !== "DRAGGING") {
+ cancel();
+ return;
+ }
+
+ // already dragging - this event is directly ending a drag
+ event.preventDefault();
+ cancel();
+ }
+ },
+ // Need to opt out of dragging if the user is a force press
+ // Only for webkit which has decided to introduce its own custom way of doing things
+ // https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
+ {
+ eventName: "touchforcechange",
+ fn: (event) => {
+ const phase = getPhase();
+
+ // needed to use phase.actions
+ invariant(phase.type !== "IDLE");
+
+ // This is not fantastic logic, but it is done to account for
+ // and issue with forcepress on iOS
+ // Calling event.preventDefault() will currently opt out of scrolling and clicking
+ // https://github.com/atlassian/react-beautiful-dnd/issues/1401
+
+ const touch = event.touches[0];
+ if (!touch) {
+ return;
+ }
+ const isForcePress = touch.force >= forcePressThreshold;
+ if (!isForcePress) {
+ return;
+ }
+ const shouldRespect = phase.actions.shouldRespectForcePress();
+ if (phase.type === "PENDING") {
+ if (shouldRespect) {
+ cancel();
+ }
+ // If not respecting we just let the event go through
+ // It will not have an impact on the browser until
+ // there has been a sufficient time ellapsed
+ return;
+ }
+
+ // 'DRAGGING'
+
+ if (shouldRespect) {
+ if (phase.hasMoved) {
+ // After the user has moved we do not allow the dragging item to be force pressed
+ // This prevents strange behaviour such as a link preview opening mid drag
+ event.preventDefault();
+ return;
+ }
+ // indirect cancel
+ cancel();
+ return;
+ }
+
+ // not respecting during a drag
+ event.preventDefault();
+ }
+ },
+ // Cancel on page visibility change
+ {
+ eventName: supportedPageVisibilityEventName,
+ fn: cancel
+ }
+ // Not adding a cancel on touchstart as this handler will pick up the initial touchstart event
+ ];
+}
+
+export default function useTouchSensor(api) {
+ const phaseRef = useRef(idle);
+ const unbindEventsRef = useRef(noop);
+ const getPhase = useCallback(function getPhase() {
+ return phaseRef.current;
+ }, []);
+ const setPhase = useCallback(function setPhase(phase) {
+ phaseRef.current = phase;
+ }, []);
+ const startCaptureBinding = useMemo(
+ () => ({
+ eventName: "touchstart",
+ fn: function onTouchStart(event) {
+ // Event already used by something else
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ // We need to NOT call event.preventDefault() so as to maintain as much standard
+ // browser interactions as possible.
+ // This includes navigation on anchors which we want to preserve
+
+ const draggableId = api.findClosestDraggableId(event);
+ if (!draggableId) {
+ return;
+ }
+ const actions = api.tryGetLock(
+ draggableId,
+ // eslint-disable-next-line no-use-before-define
+ stop,
+ {
+ sourceEvent: event
+ }
+ );
+
+ // could not start a drag
+ if (!actions) {
+ return;
+ }
+ const touch = event.touches[0];
+ const { clientX, clientY } = touch;
+ const point = {
+ x: clientX,
+ y: clientY
+ };
+
+ // unbind this event handler
+ unbindEventsRef.current();
+
+ // eslint-disable-next-line no-use-before-define
+ startPendingDrag(actions, point);
+ }
+ }),
+ // not including stop or startPendingDrag as it is not defined initially
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [api]
+ );
+ const listenForCapture = useCallback(
+ function listenForCapture() {
+ const options = {
+ capture: true,
+ passive: false
+ };
+ unbindEventsRef.current = bindEvents(window, [startCaptureBinding], options);
+ },
+ [startCaptureBinding]
+ );
+ const stop = useCallback(() => {
+ const current = phaseRef.current;
+ if (current.type === "IDLE") {
+ return;
+ }
+
+ // aborting any pending drag
+ if (current.type === "PENDING") {
+ clearTimeout(current.longPressTimerId);
+ }
+ setPhase(idle);
+ unbindEventsRef.current();
+ listenForCapture();
+ }, [listenForCapture, setPhase]);
+ const cancel = useCallback(() => {
+ const phase = phaseRef.current;
+ stop();
+ if (phase.type === "DRAGGING") {
+ phase.actions.cancel({
+ shouldBlockNextClick: true
+ });
+ }
+ if (phase.type === "PENDING") {
+ phase.actions.abort();
+ }
+ }, [stop]);
+ const bindCapturingEvents = useCallback(
+ function bindCapturingEvents() {
+ const options = {
+ capture: true,
+ passive: false
+ };
+ const args = {
+ cancel,
+ completed: stop,
+ getPhase
+ };
+
+ // In prior versions of iOS it was required that touch listeners be added
+ // to the handle to work correctly (even if the handle got removed in a portal / clone)
+ // In the latest version it appears to be the opposite: for reparenting to work
+ // the events need to be attached to the window.
+ // For now i'll keep these two functions seperate in case we need to swap it back again
+ // Old behaviour:
+ // https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d
+ // https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
+ const unbindTarget = bindEvents(window, getHandleBindings(args), options);
+ const unbindWindow = bindEvents(window, getWindowBindings(args), options);
+ unbindEventsRef.current = function unbindAll() {
+ unbindTarget();
+ unbindWindow();
+ };
+ },
+ [cancel, getPhase, stop]
+ );
+ const startDragging = useCallback(
+ function startDragging() {
+ const phase = getPhase();
+ invariant(phase.type === "PENDING", `Cannot start dragging from phase ${phase.type}`);
+ const actions = phase.actions.fluidLift(phase.point);
+ setPhase({
+ type: "DRAGGING",
+ actions,
+ hasMoved: false
+ });
+ },
+ [getPhase, setPhase]
+ );
+ const startPendingDrag = useCallback(
+ function startPendingDrag(actions, point) {
+ invariant(getPhase().type === "IDLE", "Expected to move from IDLE to PENDING drag");
+ const longPressTimerId = setTimeout(startDragging, timeForLongPress);
+ setPhase({
+ type: "PENDING",
+ point,
+ actions,
+ longPressTimerId
+ });
+ bindCapturingEvents();
+ },
+ [bindCapturingEvents, getPhase, setPhase, startDragging]
+ );
+ useLayoutEffect(
+ function mount() {
+ listenForCapture();
+ return function unmount() {
+ // remove any existing listeners
+ unbindEventsRef.current();
+
+ // need to kill any pending drag start timer
+ const phase = getPhase();
+ if (phase.type === "PENDING") {
+ clearTimeout(phase.longPressTimerId);
+ setPhase(idle);
+ }
+ };
+ },
+ [getPhase, listenForCapture, setPhase]
+ );
+
+ // This is needed for safari
+ // Simply adding a non capture, non passive 'touchmove' listener.
+ // This forces event.preventDefault() in dynamically added
+ // touchmove event handlers to actually work
+ // https://github.com/atlassian/react-beautiful-dnd/issues/1374
+ useLayoutEffect(function webkitHack() {
+ const unbind = bindEvents(window, [
+ {
+ eventName: "touchmove",
+ // using a new noop function for each usage as a single `removeEventListener()`
+ // call will remove all handlers with the same reference
+ // https://codesandbox.io/s/removing-multiple-handlers-with-same-reference-fxe15
+ fn: () => {},
+ options: {
+ capture: false,
+ passive: false
+ }
+ }
+ ]);
+ return unbind;
+ }, []);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js
new file mode 100644
index 000000000..c15d9a427
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/prevent-standard-key-events.js
@@ -0,0 +1,13 @@
+import * as keyCodes from "../../../key-codes";
+
+const preventedKeys = {
+ // submission
+ [keyCodes.enter]: true,
+ // tabbing
+ [keyCodes.tab]: true
+};
+export default (event) => {
+ if (preventedKeys[event.keyCode]) {
+ event.preventDefault();
+ }
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js
new file mode 100644
index 000000000..39a82525d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/sensors/util/supported-page-visibility-event-name.js
@@ -0,0 +1,16 @@
+import { find } from "../../../../native-with-fallback";
+
+const supportedEventName = (() => {
+ const base = "visibilitychange";
+
+ // Server side rendering
+ if (typeof document === "undefined") {
+ return base;
+ }
+
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
+ const candidates = [base, `ms${base}`, `webkit${base}`, `moz${base}`, `o${base}`];
+ const supported = find(candidates, (eventName) => `on${eventName}` in document);
+ return supported || base;
+})();
+export default supportedEventName;
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-sensor-marshal.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-sensor-marshal.js
new file mode 100644
index 000000000..492105c14
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-sensor-marshal.js
@@ -0,0 +1,380 @@
+import rafSchd from "raf-schd";
+import { useState } from "react";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../invariant";
+import create from "./lock";
+import canStartDrag from "../../state/can-start-drag";
+import {
+ drop as dropAction,
+ flush,
+ lift as liftAction,
+ move as moveAction,
+ moveDown as moveDownAction,
+ moveLeft as moveLeftAction,
+ moveRight as moveRightAction,
+ moveUp as moveUpAction
+} from "../../state/action-creators";
+import useMouseSensor from "./sensors/use-mouse-sensor";
+import useKeyboardSensor from "./sensors/use-keyboard-sensor";
+import useTouchSensor from "./sensors/use-touch-sensor";
+import useValidateSensorHooks from "./use-validate-sensor-hooks";
+import isEventInInteractiveElement from "./is-event-in-interactive-element";
+import getBorderBoxCenterPosition from "../get-border-box-center-position";
+import { warning } from "../../dev-warning";
+import useLayoutEffect from "../use-isomorphic-layout-effect";
+import { noop } from "../../empty";
+import findClosestDraggableIdFromEvent from "./find-closest-draggable-id-from-event";
+import findDraggable from "../get-elements/find-draggable";
+import bindEvents from "../event-bindings/bind-events";
+
+function preventDefault(event) {
+ event.preventDefault();
+}
+
+function isActive({ expected, phase, isLockActive, shouldWarn }) {
+ // lock is no longer active
+ if (!isLockActive()) {
+ if (shouldWarn) {
+ warning(`
+ Cannot perform action.
+ The sensor no longer has an action lock.
+
+ Tips:
+
+ - Throw away your action handlers when forceStop() is called
+ - Check actions.isActive() if you really need to
+ `);
+ }
+ return false;
+ }
+ // wrong phase
+ if (expected !== phase) {
+ if (shouldWarn) {
+ warning(`
+ Cannot perform action.
+ The actions you used belong to an outdated phase
+
+ Current phase: ${expected}
+ You called an action from outdated phase: ${phase}
+
+ Tips:
+
+ - Do not use preDragActions actions after calling preDragActions.lift()
+ `);
+ }
+ return false;
+ }
+ return true;
+}
+
+function canStart({ lockAPI, store, registry, draggableId }) {
+ // lock is already claimed - cannot start
+ if (lockAPI.isClaimed()) {
+ return false;
+ }
+ const entry = registry.draggable.findById(draggableId);
+ if (!entry) {
+ warning(`Unable to find draggable with id: ${draggableId}`);
+ return false;
+ }
+
+ // draggable is not enabled - cannot start
+ if (!entry.options.isEnabled) {
+ return false;
+ }
+
+ // Application might now allow dragging right now
+ if (!canStartDrag(store.getState(), draggableId)) {
+ return false;
+ }
+ return true;
+}
+
+function tryStart({ lockAPI, contextId, store, registry, draggableId, forceSensorStop, sourceEvent }) {
+ const shouldStart = canStart({
+ lockAPI,
+ store,
+ registry,
+ draggableId
+ });
+ if (!shouldStart) {
+ return null;
+ }
+ const entry = registry.draggable.getById(draggableId);
+ const el = findDraggable(contextId, entry.descriptor.id);
+ if (!el) {
+ warning(`Unable to find draggable element with id: ${draggableId}`);
+ return null;
+ }
+
+ // Do not allow dragging from interactive elements
+ if (sourceEvent && !entry.options.canDragInteractiveElements && isEventInInteractiveElement(el, sourceEvent)) {
+ return null;
+ }
+
+ // claiming lock
+ const lock = lockAPI.claim(forceSensorStop || noop);
+ let phase = "PRE_DRAG";
+
+ function getShouldRespectForcePress() {
+ // not looking up the entry as it might have been removed in a virtual list
+ return entry.options.shouldRespectForcePress;
+ }
+
+ function isLockActive() {
+ return lockAPI.isActive(lock);
+ }
+
+ function tryDispatch(expected, getAction) {
+ if (
+ isActive({
+ expected,
+ phase,
+ isLockActive,
+ shouldWarn: true
+ })
+ ) {
+ store.dispatch(getAction());
+ }
+ }
+
+ const tryDispatchWhenDragging = tryDispatch.bind(null, "DRAGGING");
+
+ function lift(args) {
+ function completed() {
+ lockAPI.release();
+ phase = "COMPLETED";
+ }
+
+ // Double lift = bad
+ if (phase !== "PRE_DRAG") {
+ completed();
+ invariant(phase === "PRE_DRAG", `Cannot lift in phase ${phase}`);
+ }
+ store.dispatch(liftAction(args.liftActionArgs));
+
+ // We are now in the DRAGGING phase
+ phase = "DRAGGING";
+
+ function finish(
+ reason,
+ options = {
+ shouldBlockNextClick: false
+ }
+ ) {
+ args.cleanup();
+
+ // block next click if requested
+ if (options.shouldBlockNextClick) {
+ const unbind = bindEvents(window, [
+ {
+ eventName: "click",
+ fn: preventDefault,
+ options: {
+ // only blocking a single click
+ once: true,
+ passive: false,
+ capture: true
+ }
+ }
+ ]);
+ // Sometimes the click is swallowed, such as when there is reparenting
+ // The click event (in the message queue) will occur before the next setTimeout expiry
+ // https://codesandbox.io/s/click-behaviour-pkfk2
+ setTimeout(unbind);
+ }
+
+ // releasing
+ completed();
+ store.dispatch(
+ dropAction({
+ reason
+ })
+ );
+ }
+
+ return {
+ isActive: () =>
+ isActive({
+ expected: "DRAGGING",
+ phase,
+ isLockActive,
+ // Do not want to want warnings for boolean checks
+ shouldWarn: false
+ }),
+ shouldRespectForcePress: getShouldRespectForcePress,
+ drop: (options) => finish("DROP", options),
+ cancel: (options) => finish("CANCEL", options),
+ ...args.actions
+ };
+ }
+
+ function fluidLift(clientSelection) {
+ const move = rafSchd((client) => {
+ tryDispatchWhenDragging(() =>
+ moveAction({
+ client
+ })
+ );
+ });
+ const api = lift({
+ liftActionArgs: {
+ id: draggableId,
+ clientSelection,
+ movementMode: "FLUID"
+ },
+ cleanup: () => move.cancel(),
+ actions: {
+ move
+ }
+ });
+ return {
+ ...api,
+ move
+ };
+ }
+
+ function snapLift() {
+ const actions = {
+ moveUp: () => tryDispatchWhenDragging(moveUpAction),
+ moveRight: () => tryDispatchWhenDragging(moveRightAction),
+ moveDown: () => tryDispatchWhenDragging(moveDownAction),
+ moveLeft: () => tryDispatchWhenDragging(moveLeftAction)
+ };
+ return lift({
+ liftActionArgs: {
+ id: draggableId,
+ clientSelection: getBorderBoxCenterPosition(el),
+ movementMode: "SNAP"
+ },
+ cleanup: noop,
+ actions
+ });
+ }
+
+ function abortPreDrag() {
+ const shouldRelease = isActive({
+ expected: "PRE_DRAG",
+ phase,
+ isLockActive,
+ shouldWarn: true
+ });
+ if (shouldRelease) {
+ lockAPI.release();
+ }
+ }
+
+ const preDrag = {
+ isActive: () =>
+ isActive({
+ expected: "PRE_DRAG",
+ phase,
+ isLockActive,
+ // Do not want to want warnings for boolean checks
+ shouldWarn: false
+ }),
+ shouldRespectForcePress: getShouldRespectForcePress,
+ fluidLift,
+ snapLift,
+ abort: abortPreDrag
+ };
+ return preDrag;
+}
+
+// default sensors are now exported to library consumers
+const defaultSensors = [useMouseSensor, useKeyboardSensor, useTouchSensor];
+export default function useSensorMarshal({ contextId, store, registry, customSensors, enableDefaultSensors }) {
+ const useSensors = [...(enableDefaultSensors ? defaultSensors : []), ...(customSensors || [])];
+ const lockAPI = useState(() => create())[0];
+ const tryAbandonLock = useCallback(
+ function tryAbandonLock(previous, current) {
+ if (previous.isDragging && !current.isDragging) {
+ lockAPI.tryAbandon();
+ }
+ },
+ [lockAPI]
+ );
+
+ // We need to abort any capturing if there is no longer a drag
+ useLayoutEffect(
+ function listenToStore() {
+ let previous = store.getState();
+ const unsubscribe = store.subscribe(() => {
+ const current = store.getState();
+ tryAbandonLock(previous, current);
+ previous = current;
+ });
+
+ // unsubscribe from store when unmounting
+ return unsubscribe;
+ },
+ [lockAPI, store, tryAbandonLock]
+ );
+
+ // abort any lock on unmount
+ useLayoutEffect(() => {
+ return lockAPI.tryAbandon;
+ }, [lockAPI.tryAbandon]);
+ const canGetLock = useCallback(
+ (draggableId) => {
+ return canStart({
+ lockAPI,
+ registry,
+ store,
+ draggableId
+ });
+ },
+ [lockAPI, registry, store]
+ );
+ const tryGetLock = useCallback(
+ (draggableId, forceStop, options) =>
+ tryStart({
+ lockAPI,
+ registry,
+ contextId,
+ store,
+ draggableId,
+ forceSensorStop: forceStop,
+ sourceEvent: options && options.sourceEvent ? options.sourceEvent : null
+ }),
+ [contextId, lockAPI, registry, store]
+ );
+ const findClosestDraggableId = useCallback((event) => findClosestDraggableIdFromEvent(contextId, event), [contextId]);
+ const findOptionsForDraggable = useCallback(
+ (id) => {
+ const entry = registry.draggable.findById(id);
+ return entry ? entry.options : null;
+ },
+ [registry.draggable]
+ );
+ const tryReleaseLock = useCallback(
+ function tryReleaseLock() {
+ if (!lockAPI.isClaimed()) {
+ return;
+ }
+ lockAPI.tryAbandon();
+ if (store.getState().phase !== "IDLE") {
+ store.dispatch(flush());
+ }
+ },
+ [lockAPI, store]
+ );
+ const isLockClaimed = useCallback(lockAPI.isClaimed, [lockAPI]);
+ const api = useMemo(
+ () => ({
+ canGetLock,
+ tryGetLock,
+ findClosestDraggableId,
+ findOptionsForDraggable,
+ tryReleaseLock,
+ isLockClaimed
+ }),
+ [canGetLock, tryGetLock, findClosestDraggableId, findOptionsForDraggable, tryReleaseLock, isLockClaimed]
+ );
+
+ // Bad ass
+ useValidateSensorHooks(useSensors);
+ for (let i = 0; i < useSensors.length; i++) {
+ useSensors[i](api);
+ }
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-validate-sensor-hooks.js b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-validate-sensor-hooks.js
new file mode 100644
index 000000000..c343e0d9a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-sensor-marshal/use-validate-sensor-hooks.js
@@ -0,0 +1,17 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import {invariant} from "../../invariant";
+import usePreviousRef from "../use-previous-ref";
+import useDevSetupWarning from "../use-dev-setup-warning";
+import useDev from "../use-dev";
+
+export default function useValidateSensorHooks(sensorHooks) {
+ useDev(() => {
+ const previousRef = usePreviousRef(sensorHooks);
+ useDevSetupWarning(() => {
+ invariant(
+ previousRef.current.length === sensorHooks.length,
+ "Cannot change the amount of sensor hooks after mounting"
+ );
+ });
+ });
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-style-marshal/get-styles.js b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/get-styles.js
new file mode 100644
index 000000000..2611cba4a
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/get-styles.js
@@ -0,0 +1,142 @@
+import { transitions } from "../../animation";
+import * as attributes from "../data-attributes";
+
+const makeGetSelector = (context) => (attribute) => `[${attribute}="${context}"]`;
+const getStyles = (rules, property) =>
+ rules
+ .map((rule) => {
+ const value = rule.styles[property];
+ if (!value) {
+ return "";
+ }
+ return `${rule.selector} { ${value} }`;
+ })
+ .join(" ");
+const noPointerEvents = "pointer-events: none;";
+export default (contextId) => {
+ const getSelector = makeGetSelector(contextId);
+
+ // ## Drag handle styles
+
+ // -webkit-touch-callout
+ // A long press on anchors usually pops a content menu that has options for
+ // the link such as 'Open in new tab'. Because long press is used to start
+ // a drag we need to opt out of this behavior
+
+ // -webkit-tap-highlight-color
+ // Webkit based browsers add a grey overlay to anchors when they are active.
+ // We remove this tap overlay as it is confusing for users
+ // https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/
+
+ // touch-action: manipulation
+ // Avoid the *pull to refresh action* and *delayed anchor focus* on Android Chrome
+
+ // cursor: grab
+ // We apply this by default for an improved user experience. It is such a common default that we
+ // bake it right in. Consumers can opt out of this by adding a selector with higher specificity
+ // The cursor will not apply when pointer-events is set to none
+
+ // pointer-events: none
+ // this is used to prevent pointer events firing on draggables during a drag
+ // Reasons:
+ // 1. performance: it stops the other draggables from processing mouse events
+ // 2. scrolling: it allows the user to scroll through the current draggable
+ // to scroll the list behind
+ // 3.* function: it blocks other draggables from starting. This is not relied on though as there
+ // is a function on the context (canLift) which is a more robust way of controlling this
+
+ const dragHandle = (() => {
+ const grabCursor = `
+ cursor: -webkit-grab;
+ cursor: grab;
+ `;
+ return {
+ selector: getSelector(attributes.dragHandle.contextId),
+ styles: {
+ always: `
+ -webkit-touch-callout: none;
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ touch-action: manipulation;
+ `,
+ resting: grabCursor,
+ dragging: noPointerEvents,
+ // it is fine for users to start dragging another item when a drop animation is occurring
+ dropAnimating: grabCursor
+ // Not applying grab cursor during a user cancel as it is not possible for users to reorder
+ // items during a cancel
+ }
+ };
+ })();
+
+ // ## Draggable styles
+
+ // transition: transform
+ // This controls the animation of draggables that are moving out of the way
+ // The main draggable is controlled by react-motion.
+
+ const draggable = (() => {
+ const transition = `
+ transition: ${transitions.outOfTheWay};
+ `;
+ return {
+ selector: getSelector(attributes.draggable.contextId),
+ styles: {
+ dragging: transition,
+ dropAnimating: transition,
+ userCancel: transition
+ }
+ };
+ })();
+
+ // ## Droppable styles
+
+ // overflow-anchor: none;
+ // Opting out of the browser feature which tries to maintain
+ // the scroll position when the DOM changes above the fold.
+ // This does not work well with reordering DOM nodes.
+ // When we drop a Draggable it already has the correct scroll applied.
+
+ const droppable = {
+ selector: getSelector(attributes.droppable.contextId),
+ styles: {
+ always: `overflow-anchor: none;`
+ // need pointer events on the droppable to allow manual scrolling
+ }
+ };
+
+ // ## Body styles
+
+ // cursor: grab
+ // We apply this by default for an improved user experience. It is such a common default that we
+ // bake it right in. Consumers can opt out of this by adding a selector with higher specificity
+
+ // user-select: none
+ // This prevents the user from selecting text on the page while dragging
+
+ // overflow-anchor: none
+ // We are in control and aware of all of the window scrolls that occur
+ // we do not want the browser to have behaviors we do not expect
+
+ const body = {
+ selector: "body",
+ styles: {
+ dragging: `
+ cursor: grabbing;
+ cursor: -webkit-grabbing;
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ overflow-anchor: none;
+ `
+ }
+ };
+ const rules = [draggable, dragHandle, droppable, body];
+ return {
+ always: getStyles(rules, "always"),
+ resting: getStyles(rules, "resting"),
+ dragging: getStyles(rules, "dragging"),
+ dropAnimating: getStyles(rules, "dropAnimating"),
+ userCancel: getStyles(rules, "userCancel")
+ };
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/use-style-marshal/index.js b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/index.js
new file mode 100644
index 000000000..fac45bc8d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/index.js
@@ -0,0 +1 @@
+export { default } from "./use-style-marshal";
diff --git a/client/src/components/trello-board/dnd/lib/view/use-style-marshal/style-marshal-types.js b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/style-marshal-types.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/components/trello-board/dnd/lib/view/use-style-marshal/use-style-marshal.js b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/use-style-marshal.js
new file mode 100644
index 000000000..0c66eae65
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-style-marshal/use-style-marshal.js
@@ -0,0 +1,100 @@
+import { useRef } from "react";
+import memoizeOne from "memoize-one";
+import { useCallback, useMemo } from "use-memo-one";
+import { invariant } from "../../invariant";
+import getStyles from "./get-styles";
+import { prefix } from "../data-attributes";
+import useLayoutEffect from "../use-isomorphic-layout-effect";
+
+const getHead = () => {
+ const head = document.querySelector("head");
+ invariant(head, "Cannot find the head to append a style to");
+ return head;
+};
+const createStyleEl = (nonce) => {
+ const el = document.createElement("style");
+ if (nonce) {
+ el.setAttribute("nonce", nonce);
+ }
+ el.type = "text/css";
+ return el;
+};
+export default function useStyleMarshal(contextId, nonce) {
+ const styles = useMemo(() => getStyles(contextId), [contextId]);
+ const alwaysRef = useRef(null);
+ const dynamicRef = useRef(null);
+ const setDynamicStyle = useCallback(
+ // Using memoizeOne to prevent frequent updates to textContext
+ memoizeOne((proposed) => {
+ const el = dynamicRef.current;
+ invariant(el, "Cannot set dynamic style element if it is not set");
+ el.textContent = proposed;
+ }),
+ []
+ );
+ const setAlwaysStyle = useCallback((proposed) => {
+ const el = alwaysRef.current;
+ invariant(el, "Cannot set dynamic style element if it is not set");
+ el.textContent = proposed;
+ }, []);
+
+ // using layout effect as programatic dragging might start straight away (such as for cypress)
+ useLayoutEffect(() => {
+ invariant(!alwaysRef.current && !dynamicRef.current, "style elements already mounted");
+ const always = createStyleEl(nonce);
+ const dynamic = createStyleEl(nonce);
+
+ // store their refs
+ alwaysRef.current = always;
+ dynamicRef.current = dynamic;
+
+ // for easy identification
+ always.setAttribute(`${prefix}-always`, contextId);
+ dynamic.setAttribute(`${prefix}-dynamic`, contextId);
+
+ // add style tags to head
+ getHead().appendChild(always);
+ getHead().appendChild(dynamic);
+
+ // set initial style
+ setAlwaysStyle(styles.always);
+ setDynamicStyle(styles.resting);
+ return () => {
+ const remove = (ref) => {
+ const current = ref.current;
+ invariant(current, "Cannot unmount ref as it is not set");
+ getHead().removeChild(current);
+ ref.current = null;
+ };
+ remove(alwaysRef);
+ remove(dynamicRef);
+ };
+ }, [nonce, setAlwaysStyle, setDynamicStyle, styles.always, styles.resting, contextId]);
+ const dragging = useCallback(() => setDynamicStyle(styles.dragging), [setDynamicStyle, styles.dragging]);
+ const dropping = useCallback(
+ (reason) => {
+ if (reason === "DROP") {
+ setDynamicStyle(styles.dropAnimating);
+ return;
+ }
+ setDynamicStyle(styles.userCancel);
+ },
+ [setDynamicStyle, styles.dropAnimating, styles.userCancel]
+ );
+ const resting = useCallback(() => {
+ // Can be called defensively
+ if (!dynamicRef.current) {
+ return;
+ }
+ setDynamicStyle(styles.resting);
+ }, [setDynamicStyle, styles.resting]);
+ const marshal = useMemo(
+ () => ({
+ dragging,
+ dropping,
+ resting
+ }),
+ [dragging, dropping, resting]
+ );
+ return marshal;
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/use-unique-id.js b/client/src/components/trello-board/dnd/lib/view/use-unique-id.js
new file mode 100644
index 000000000..0abf93e86
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/use-unique-id.js
@@ -0,0 +1,14 @@
+import { useMemo } from "use-memo-one";
+
+let count = 0;
+const defaults = {
+ separator: "::"
+};
+
+export function reset() {
+ count = 0;
+}
+
+export default function useUniqueId(prefix, options = defaults) {
+ return useMemo(() => `${prefix}${options.separator}${count++}`, [options.separator, prefix]);
+}
diff --git a/client/src/components/trello-board/dnd/lib/view/visually-hidden-style.js b/client/src/components/trello-board/dnd/lib/view/visually-hidden-style.js
new file mode 100644
index 000000000..66aed279d
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/visually-hidden-style.js
@@ -0,0 +1,14 @@
+// https://allyjs.io/tutorials/hiding-elements.html
+// Element is visually hidden but is readable by screen readers
+const visuallyHidden = {
+ position: "absolute",
+ width: "1px",
+ height: "1px",
+ margin: "-1px",
+ border: "0",
+ padding: "0",
+ overflow: "hidden",
+ clip: "rect(0 0 0 0)",
+ "clip-path": "inset(100%)"
+};
+export default visuallyHidden;
diff --git a/client/src/components/trello-board/dnd/lib/view/window/get-max-window-scroll.js b/client/src/components/trello-board/dnd/lib/view/window/get-max-window-scroll.js
new file mode 100644
index 000000000..de4114526
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/window/get-max-window-scroll.js
@@ -0,0 +1,15 @@
+import getMaxScroll from "../../state/get-max-scroll";
+import getDocumentElement from "../get-document-element";
+
+export default () => {
+ const doc = getDocumentElement();
+ const maxScroll = getMaxScroll({
+ // unclipped padding box, with scrollbar
+ scrollHeight: doc.scrollHeight,
+ scrollWidth: doc.scrollWidth,
+ // clipped padding box, without scrollbar
+ width: doc.clientWidth,
+ height: doc.clientHeight
+ });
+ return maxScroll;
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/window/get-viewport.js b/client/src/components/trello-board/dnd/lib/view/window/get-viewport.js
new file mode 100644
index 000000000..954e03647
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/window/get-viewport.js
@@ -0,0 +1,43 @@
+import { getRect } from "css-box-model";
+import { origin } from "../../state/position";
+import getWindowScroll from "./get-window-scroll";
+import getMaxWindowScroll from "./get-max-window-scroll";
+import getDocumentElement from "../get-document-element";
+
+export default () => {
+ const scroll = getWindowScroll();
+ const maxScroll = getMaxWindowScroll();
+ const top = scroll.y;
+ const left = scroll.x;
+
+ // window.innerHeight: includes scrollbars (not what we want)
+ // document.clientHeight gives us the correct value when using the html5 doctype
+ const doc = getDocumentElement();
+ // Using these values as they do not consider scrollbars
+ // padding box, without scrollbar
+ const width = doc.clientWidth;
+ const height = doc.clientHeight;
+
+ // Computed
+ const right = left + width;
+ const bottom = top + height;
+ const frame = getRect({
+ top,
+ left,
+ right,
+ bottom
+ });
+ const viewport = {
+ frame,
+ scroll: {
+ initial: scroll,
+ current: scroll,
+ max: maxScroll,
+ diff: {
+ value: origin,
+ displacement: origin
+ }
+ }
+ };
+ return viewport;
+};
diff --git a/client/src/components/trello-board/dnd/lib/view/window/get-window-from-el.js b/client/src/components/trello-board/dnd/lib/view/window/get-window-from-el.js
new file mode 100644
index 000000000..c9c2b17fb
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/window/get-window-from-el.js
@@ -0,0 +1 @@
+export default (el) => (el && el.ownerDocument ? el.ownerDocument.defaultView : window);
diff --git a/client/src/components/trello-board/dnd/lib/view/window/get-window-scroll.js b/client/src/components/trello-board/dnd/lib/view/window/get-window-scroll.js
new file mode 100644
index 000000000..5c4929efc
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/window/get-window-scroll.js
@@ -0,0 +1,21 @@
+// The browsers update document.documentElement.scrollTop and window.pageYOffset
+// differently as the window scrolls.
+// Webkit
+// documentElement.scrollTop: no update. Stays at 0
+// window.pageYOffset: updates to whole number
+// Chrome
+// documentElement.scrollTop: update with fractional value
+// window.pageYOffset: update with fractional value
+// FireFox
+// documentElement.scrollTop: updates to whole number
+// window.pageYOffset: updates to whole number
+// IE11 (same as firefox)
+// documentElement.scrollTop: updates to whole number
+// window.pageYOffset: updates to whole number
+// Edge (same as webkit)
+// documentElement.scrollTop: no update. Stays at 0
+// window.pageYOffset: updates to whole number
+export default () => ({
+ x: window.pageXOffset,
+ y: window.pageYOffset
+});
diff --git a/client/src/components/trello-board/dnd/lib/view/window/scroll-window.js b/client/src/components/trello-board/dnd/lib/view/window/scroll-window.js
new file mode 100644
index 000000000..3749dd5f7
--- /dev/null
+++ b/client/src/components/trello-board/dnd/lib/view/window/scroll-window.js
@@ -0,0 +1,4 @@
+// Not guarenteed to scroll by the entire amount
+export default (change) => {
+ window.scrollBy(change.x, change.y);
+};