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