From b9d69f219b9313421b89e3f17de7184b6f703f1f Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Tue, 26 Feb 2019 10:44:10 +1100 Subject: [PATCH 01/25] Animated placeholders (#1096) --- .size-snapshot.json | 24 +- .storybook/config.js | 3 +- .storybook/decorator/global-styles.jsx | 17 + LICENSE | 2 +- docs/guides/types.md | 1 + jest.config.js | 3 +- package.json | 3 +- src/{view => }/animation.js | 29 +- src/dev-warning.js | 7 +- src/state/action-creators.js | 26 +- src/state/can-start-drag.js | 4 +- src/state/create-store.js | 2 - .../dimension-marshal-types.js | 6 +- .../dimension-marshal/dimension-marshal.js | 7 + .../while-dragging-publisher.js | 17 +- src/state/droppable/should-use-placeholder.js | 16 +- src/state/droppable/with-placeholder.js | 17 +- .../get-client-border-box-center/index.js | 4 + .../get-page-border-box-center/index.js | 5 + .../when-combining.js | 19 +- .../when-reordering.js | 73 ++- .../move-relative-to.js | 1 - src/state/get-combined-item-displacement.js | 32 + src/state/get-displaced-by.js | 9 +- src/state/get-displacement.js | 45 +- .../get-drag-impact/get-combine-impact.js | 37 +- ...-foreign-list.js => get-reorder-impact.js} | 51 +- src/state/get-drag-impact/in-home-list.js | 180 ------ src/state/get-drag-impact/index.js | 53 +- src/state/get-home-impact.js | 18 - src/state/get-home-on-lift.js | 87 +++ src/state/middleware/drop-animation-finish.js | 4 +- src/state/middleware/drop/drop-middleware.js | 63 +- .../middleware/drop/get-drop-duration.js | 4 +- src/state/middleware/drop/get-drop-impact.js | 83 +++ .../drop/get-new-home-client-offset.js | 15 + src/state/middleware/lift.js | 2 +- .../responders/responders-middleware.js | 4 +- src/state/middleware/style.js | 2 +- ...ewport-max-scroll-on-destination-change.js | 73 --- src/state/move-in-direction/index.js | 16 +- .../move-cross-axis/get-closest-draggable.js | 32 +- .../move-cross-axis/index.js | 10 +- ...reign-list.js => move-to-new-droppable.js} | 99 +-- .../move-to-new-droppable/index.js | 79 --- .../move-to-new-droppable/to-home-list.js | 102 ---- .../without-starting-displacement.js | 31 + .../move-to-next-place/index.js | 6 + .../is-totally-visible-in-new-location.js | 13 +- .../move-to-next-combine/index.js | 7 +- .../move-to-next-index/from-combine.js | 68 +-- .../move-to-next-index/from-reorder.js | 17 +- .../move-to-next-index/index.js | 97 ++- src/state/no-impact.js | 2 - src/state/patch-dimension-map.js | 11 + src/state/patch-droppable-map.js | 13 +- .../post-reducer/when-moving/refresh-snap.js | 2 + src/state/post-reducer/when-moving/update.js | 21 +- src/state/publish-while-dragging/index.js | 101 ++- .../adjust-additions-for-collapsed-home.js | 51 ++ .../adjust-additions-for-scroll-changes.js | 36 +- ...st-existing-for-additions-and-removals.js} | 82 ++- .../update-draggables/index.js | 79 +++ .../update-draggables/offset-draggable.js | 35 ++ .../index.js} | 54 +- .../with-no-animated-displacement.js | 4 + ...aceholder.js => recompute-placeholders.js} | 61 +- src/state/reducer.js | 47 +- src/state/remove-draggable-from-list.js | 13 + .../starting-displaced/did-start-displaced.js | 5 + .../recompute.js | 7 + .../speculatively-increase.js | 43 +- src/state/will-displace-forward.js | 18 - src/types.js | 37 +- src/view/animate-in-out/animate-in-out.jsx | 91 +++ src/view/animate-in-out/index.js | 2 + src/view/context-keys.js | 5 +- .../drag-drop-context/drag-drop-context.jsx | 17 +- src/view/drag-handle/drag-handle.jsx | 10 +- src/view/draggable/connected-draggable.js | 36 +- src/view/draggable/draggable-types.js | 4 +- src/view/draggable/draggable.jsx | 55 +- .../droppable-dimension-publisher.jsx | 14 +- src/view/droppable/connected-droppable.js | 171 ++++-- src/view/droppable/droppable-types.js | 10 + src/view/droppable/droppable.jsx | 53 +- src/view/placeholder/placeholder-types.js | 1 + src/view/placeholder/placeholder.jsx | 150 ++++- src/view/style-marshal/get-styles.js | 2 +- stories/1-single-vertical-list.stories.js | 2 +- stories/assets/bmo.png | Bin 0 -> 20113 bytes stories/assets/finn.png | Bin 0 -> 18002 bytes stories/assets/jake.png | Bin 0 -> 9658 bytes stories/assets/princess.png | Bin 0 -> 21790 bytes stories/src/accessible/blur-context.js | 6 + stories/src/accessible/task-app.jsx | 8 +- stories/src/accessible/task-list.jsx | 33 +- stories/src/accessible/task.jsx | 53 +- stories/src/board/board.jsx | 13 +- stories/src/board/column.jsx | 12 +- stories/src/constants.js | 22 - stories/src/custom-drop/funny-drop.jsx | 5 +- stories/src/data.js | 42 +- stories/src/fixed-list/fixed-sidebar.jsx | 5 +- stories/src/horizontal/author-app.jsx | 5 +- .../interactive-elements-app.jsx | 11 +- stories/src/multi-drag/column.jsx | 10 +- stories/src/multi-drag/task.jsx | 25 +- stories/src/multiple-horizontal/quote-app.jsx | 5 +- stories/src/multiple-vertical/quote-app.jsx | 5 +- stories/src/portal/portal-app.jsx | 7 +- stories/src/primatives/author-item.jsx | 10 +- stories/src/primatives/author-list.jsx | 5 +- stories/src/primatives/quote-item.jsx | 55 +- stories/src/primatives/quote-list.jsx | 25 +- stories/src/primatives/title.jsx | 5 +- stories/src/table/with-dimension-locking.jsx | 7 +- stories/src/table/with-fixed-columns.jsx | 7 +- stories/src/table/with-portal.jsx | 7 +- stories/src/types.js | 6 + stories/src/vertical-grouped/quote-app.jsx | 7 +- stories/src/vertical-nested/quote-app.jsx | 5 +- stories/src/vertical-nested/quote-list.jsx | 7 +- stories/src/vertical/quote-app.jsx | 8 +- test/{setup.js => env-setup.js} | 0 test/test-setup.js | 6 + .../integration/reorder-render-sync.spec.js | 3 +- .../integration/responders-timing.spec.js | 1 + .../fluid-scroller/lifecycle.spec.js | 11 +- .../fluid-scroller/util/drag-to.js | 14 +- .../droppable/should-use-placeholder.spec.js | 91 --- .../state/droppable/with-placeholder.spec.js | 104 ++-- .../get-client-border-box-center.spec.js | 150 ++++- ...client-from-page-border-box-center.spec.js | 1 - .../get-page-border-box-center.spec.js | 459 -------------- .../combine/when-combining.spec.js | 193 ++++++ .../combine/with-droppable-scroll.spec.js | 100 +++ .../in-home-location.spec.js | 32 + .../over-nothing.spec.js | 32 + .../reorder/in-empty-list.spec.js | 37 ++ .../reorder/nothing-displaced.spec.js | 127 ++++ .../reorder/there-is-displacement.spec.js | 118 ++++ .../reorder/with-droppable-scroll.spec.js | 87 +++ test/unit/state/get-displacement.spec.js | 244 -------- .../did-not-start-displaced.spec.js | 186 ++++++ .../started-displaced.spec.js | 249 ++++++++ .../combine/did-not-start-displaced.spec.js | 422 +++++++++++++ .../combine/did-start-displaced.spec.js | 420 +++++++++++++ .../combine/is-combine-disabled.spec.js | 93 ++- .../combine/moving-backward.spec.js | 374 ------------ .../combine/moving-forward.spec.js | 376 ------------ ...ld-not-combine-with-home-draggable.spec.js | 43 ++ .../combine/with-droppable-scroll.spec.js | 13 +- .../state/get-drag-impact/is-disabled.spec.js | 23 +- .../get-drag-impact/over-nothing.spec.js | 17 +- .../did-not-start-displaced.spec.js | 182 ++++++ .../move-backward-from-last-item.spec.js | 84 +++ .../move-past-last-item.spec.js | 69 +++ .../over-foreign-list.spec.js | 576 ------------------ .../did-not-start-displaced.spec.js | 186 ++++++ .../displacement-visibility.spec.js | 67 +- .../is-behind-start-position.spec.js | 340 ----------- .../is-in-front-of-start-position.spec.js | 395 ------------ .../move-past-last-item.spec.js | 56 ++ .../over-home-list/started-displaced.spec.js | 182 ++++++ .../utils/get-displaced-with-map.js | 8 - .../with-droppable-scroll.spec.js | 83 +-- .../get-home-on-lift/get-home-on-lift.spec.js | 60 ++ .../unit/state/middleware/auto-scroll.spec.js | 5 +- .../dimension-marshal-stopper.spec.js | 27 +- .../middleware/drop-animation-finish.spec.js | 10 +- .../drop/conditionally-animate-drop.spec.js | 207 +++---- .../state/middleware/drop/drop-impact.spec.js | 171 ++++++ .../middleware/drop/drop-position.spec.js | 246 ++++---- .../drop/result-impact-mismatch.spec.js | 113 ++++ test/unit/state/middleware/lift.spec.js | 31 +- .../state/middleware/pending-drop.spec.js | 36 +- .../state/middleware/responders/abort.spec.js | 29 +- .../responders/announcements.spec.js | 13 +- .../state/middleware/responders/drop.spec.js | 68 ++- .../middleware/responders/flushing.spec.js | 15 +- .../responders/repeated-use.spec.js | 8 +- .../update-by-dynamic-change.spec.js | 15 +- .../util/get-completed-with-result.js | 21 + test/unit/state/middleware/style.spec.js | 21 +- ...t-max-scroll-on-destination-change.spec.js | 223 ------- .../get-closest-draggable.spec.js | 424 ------------- .../with-starting-displacement.spec.js | 200 ++++++ .../without-starting-displacement.spec.js | 419 +++++++++++++ ...ove-relative-to-not-in-destination.spec.js | 16 +- .../to-foreign-list.spec.js | 430 ++++--------- .../to-home-list.spec.js | 294 ++------- .../no-visible-targets-in-list.spec.js | 8 + .../move-in-direction.spec.js | 4 +- .../in-foreign-list.spec.js | 70 ++- .../move-to-next-combine/in-home-list.spec.js | 381 ++++++------ .../did-not-start-displaced.spec.js | 284 +++++++++ .../from-combine-with-displaced.spec.js | 238 -------- .../from-combine-with-non-displaced.spec.js | 229 ------- .../from-combine/started-displaced.spec.js | 233 +++++++ .../from-reorder/in-foreign-list.spec.js | 337 ++++------ .../from-reorder/in-home-list.spec.js | 289 +++++---- .../not-visible-in-droppable.spec.js | 392 ++++++------ .../not-visible-in-viewport.spec.js | 380 ++++++------ .../no-animated-displacement.spec.js | 114 ++-- .../nothing-changed.spec.js | 36 ++ .../recompute-on-lift.spec.js | 89 +++ ...djust-additions-for-collapsed-home.spec.js | 121 ++++ ...djust-additions-for-scroll-change.spec.js} | 16 +- .../removals.spec.js} | 12 +- ...ing-due-to-additions-and-removals.spec.js} | 42 +- .../droppable-subject-size-change.spec.js | 29 +- .../scroll-change.spec.js} | 17 +- .../unit/state/publish-while-dragging/util.js | 30 +- ...spec.js => recompute-placeholders.spec.js} | 99 ++- .../recompute.spec.js | 90 ++- .../speculative-displacement.spec.js | 79 ++- .../animate-in-out/animate-in-out.spec.js | 171 ++++++ .../animate-in-out/child-rendering.spec.js | 79 +++ .../combine-target-for.spec.js | 54 +- .../connected-draggable/combine-with.spec.js | 24 +- .../view/connected-draggable/dragging.spec.js | 15 +- .../dropping-something-else.spec.js | 49 +- .../dropping-with-result-mismatch.spec.js | 49 ++ .../view/connected-draggable/dropping.spec.js | 163 +++-- .../selector-isolation.spec.js | 14 +- .../something-else-is-dragging.spec.js | 259 ++------ .../child-render-behaviour.spec.js | 2 + .../view/connected-droppable/disabled.spec.js | 66 +- .../view/connected-droppable/dragging.spec.js | 89 +-- .../view/connected-droppable/dropping.spec.js | 159 +++-- .../connected-droppable/post-drop.spec.js | 199 ++++++ .../selector-isolation.spec.js | 2 +- .../{ => util}/get-own-props.js | 4 +- .../connected-droppable/util/resting-props.js | 12 + .../publish-while-dragging.spec.js | 67 ++ test/unit/view/drag-drop-context/app.jsx | 4 +- .../store-management.spec.js | 4 +- test/unit/view/drag-handle/attributes.spec.js | 4 +- ...start-when-something-else-dragging.spec.js | 4 +- .../disabled-while-capturing.spec.js | 1 + .../view/drag-handle/focus-management.spec.js | 13 +- .../view/drag-handle/keyboard-sensor.spec.js | 9 +- .../view/drag-handle/mouse-sensor.spec.js | 9 +- .../view/drag-handle/throw-if-svg.spec.js | 9 +- .../view/drag-handle/touch-sensor.spec.js | 9 +- .../view/drag-handle/util/basic-context.js | 9 +- test/unit/view/draggable/is-dragging.spec.js | 18 +- test/unit/view/draggable/is-dropping.spec.js | 72 ++- test/unit/view/draggable/secondary.spec.js | 2 +- .../recollection.spec.js | 47 +- .../droppable/dragging-but-not-over.spec.js | 46 ++ .../unit/view/droppable/dragging-over.spec.js | 47 +- .../home-list-placeholder-cleanup.spec.js | 59 ++ .../placeholder-for-foreign-list.spec.js | 28 - ...c.js => placeholder-setup-warning.spec.js} | 12 +- test/unit/view/droppable/placeholder.spec.js | 80 +++ .../throw-if-invalid-own-props.spec.js | 2 +- .../update-max-window-scroll.spec.js | 69 +++ test/unit/view/droppable/util/get-props.js | 42 +- test/unit/view/droppable/util/mount.js | 25 +- .../view/placeholder/animated-mount.spec.js | 90 +++ test/unit/view/placeholder/on-close.spec.js | 76 +++ .../placeholder/on-transition-end.spec.js | 58 ++ test/unit/view/placeholder/util/data.js | 6 + test/unit/view/placeholder/util/expect.js | 21 + .../placeholder/util/get-placeholder-style.js | 7 + test/utils/after-point.js | 7 + test/utils/before-point.js | 7 + test/utils/clone-impact.js | 5 + test/utils/dimension-marshal.js | 5 +- test/utils/dimension.js | 203 ++++-- test/utils/dragging-state.js | 15 +- test/utils/get-context-options.js | 24 +- .../get-not-animated-displacement.js | 8 + .../get-not-visible-displacement.js | 2 +- .../get-visible-displacement.js | 2 +- test/utils/get-simple-state-preset.js | 72 ++- test/utils/no-on-lift.js | 13 + test/utils/preset-action-args.js | 89 ++- yarn.lock | 11 +- 281 files changed, 10747 insertions(+), 8441 deletions(-) create mode 100644 .storybook/decorator/global-styles.jsx rename src/{view => }/animation.js (61%) create mode 100644 src/state/get-combined-item-displacement.js rename src/state/get-drag-impact/{in-foreign-list.js => get-reorder-impact.js} (60%) delete mode 100644 src/state/get-drag-impact/in-home-list.js delete mode 100644 src/state/get-home-impact.js create mode 100644 src/state/get-home-on-lift.js create mode 100644 src/state/middleware/drop/get-drop-impact.js delete mode 100644 src/state/middleware/update-viewport-max-scroll-on-destination-change.js rename src/state/move-in-direction/move-cross-axis/{move-to-new-droppable/to-foreign-list.js => move-to-new-droppable.js} (51%) delete mode 100644 src/state/move-in-direction/move-cross-axis/move-to-new-droppable/index.js delete mode 100644 src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-home-list.js create mode 100644 src/state/move-in-direction/move-cross-axis/without-starting-displacement.js create mode 100644 src/state/patch-dimension-map.js create mode 100644 src/state/publish-while-dragging/update-draggables/adjust-additions-for-collapsed-home.js rename src/state/publish-while-dragging/{ => update-draggables}/adjust-additions-for-scroll-changes.js (66%) rename src/state/publish-while-dragging/{get-draggable-map.js => update-draggables/adjust-existing-for-additions-and-removals.js} (74%) create mode 100644 src/state/publish-while-dragging/update-draggables/index.js create mode 100644 src/state/publish-while-dragging/update-draggables/offset-draggable.js rename src/state/publish-while-dragging/{adjust-modified-droppables.js => update-droppables/index.js} (74%) rename src/state/{get-dimension-map-with-placeholder.js => recompute-placeholders.js} (56%) create mode 100644 src/state/remove-draggable-from-list.js create mode 100644 src/state/starting-displaced/did-start-displaced.js delete mode 100644 src/state/will-displace-forward.js create mode 100644 src/view/animate-in-out/animate-in-out.jsx create mode 100644 src/view/animate-in-out/index.js create mode 100644 stories/assets/bmo.png create mode 100644 stories/assets/finn.png create mode 100644 stories/assets/jake.png create mode 100644 stories/assets/princess.png create mode 100644 stories/src/accessible/blur-context.js rename test/{setup.js => env-setup.js} (100%) create mode 100644 test/test-setup.js delete mode 100644 test/unit/state/droppable/should-use-placeholder.spec.js delete mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/combine/when-combining.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/combine/with-droppable-scroll.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/in-home-location.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/over-nothing.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/reorder/in-empty-list.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/reorder/nothing-displaced.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/reorder/there-is-displacement.spec.js create mode 100644 test/unit/state/get-center-from-impact/get-page-border-box-center/reorder/with-droppable-scroll.spec.js delete mode 100644 test/unit/state/get-displacement.spec.js create mode 100644 test/unit/state/get-displacement/did-not-start-displaced.spec.js create mode 100644 test/unit/state/get-displacement/started-displaced.spec.js create mode 100644 test/unit/state/get-drag-impact/combine/did-not-start-displaced.spec.js create mode 100644 test/unit/state/get-drag-impact/combine/did-start-displaced.spec.js delete mode 100644 test/unit/state/get-drag-impact/combine/moving-backward.spec.js delete mode 100644 test/unit/state/get-drag-impact/combine/moving-forward.spec.js create mode 100644 test/unit/state/get-drag-impact/combine/should-not-combine-with-home-draggable.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-foreign-list/did-not-start-displaced.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-foreign-list/move-backward-from-last-item.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-foreign-list/move-past-last-item.spec.js delete mode 100644 test/unit/state/get-drag-impact/reorder/over-foreign-list/over-foreign-list.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/did-not-start-displaced.spec.js delete mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/is-behind-start-position.spec.js delete mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/is-in-front-of-start-position.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/move-past-last-item.spec.js create mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/started-displaced.spec.js delete mode 100644 test/unit/state/get-drag-impact/reorder/over-home-list/utils/get-displaced-with-map.js create mode 100644 test/unit/state/get-home-on-lift/get-home-on-lift.spec.js create mode 100644 test/unit/state/middleware/drop/drop-impact.spec.js create mode 100644 test/unit/state/middleware/drop/result-impact-mismatch.spec.js create mode 100644 test/unit/state/middleware/responders/util/get-completed-with-result.js delete mode 100644 test/unit/state/middleware/update-viewport-max-scroll-on-destination-change.spec.js delete mode 100644 test/unit/state/move-in-direction/move-cross-axis/get-closest-draggable.spec.js create mode 100644 test/unit/state/move-in-direction/move-cross-axis/get-closest-draggable/with-starting-displacement.spec.js create mode 100644 test/unit/state/move-in-direction/move-cross-axis/get-closest-draggable/without-starting-displacement.spec.js create mode 100644 test/unit/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine/did-not-start-displaced.spec.js delete mode 100644 test/unit/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine/from-combine-with-displaced.spec.js delete mode 100644 test/unit/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine/from-combine-with-non-displaced.spec.js create mode 100644 test/unit/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine/started-displaced.spec.js create mode 100644 test/unit/state/publish-while-dragging/nothing-changed.spec.js create mode 100644 test/unit/state/publish-while-dragging/recompute-on-lift.spec.js create mode 100644 test/unit/state/publish-while-dragging/update-draggables/adjust-additions-for-collapsed-home.spec.js rename test/unit/state/publish-while-dragging/{shift-collected-draggables.spec.js => update-draggables/adjust-additions-for-scroll-change.spec.js} (88%) rename test/unit/state/publish-while-dragging/{removing-draggables.spec.js => update-draggables/removals.spec.js} (71%) rename test/unit/state/publish-while-dragging/{shift-existing-draggables.spec.js => update-draggables/shift-existing-due-to-additions-and-removals.spec.js} (88%) rename test/unit/state/publish-while-dragging/{ => update-droppables}/droppable-subject-size-change.spec.js (89%) rename test/unit/state/publish-while-dragging/{droppable-scroll-change.spec.js => update-droppables/scroll-change.spec.js} (86%) rename test/unit/state/{get-dimension-map-with-placeholder.spec.js => recompute-placeholders.spec.js} (65%) create mode 100644 test/unit/view/animate-in-out/animate-in-out.spec.js create mode 100644 test/unit/view/animate-in-out/child-rendering.spec.js create mode 100644 test/unit/view/connected-draggable/dropping-with-result-mismatch.spec.js create mode 100644 test/unit/view/connected-droppable/post-drop.spec.js rename test/unit/view/connected-droppable/{ => util}/get-own-props.js (66%) create mode 100644 test/unit/view/connected-droppable/util/resting-props.js create mode 100644 test/unit/view/droppable/dragging-but-not-over.spec.js create mode 100644 test/unit/view/droppable/home-list-placeholder-cleanup.spec.js delete mode 100644 test/unit/view/droppable/placeholder-for-foreign-list.spec.js rename test/unit/view/droppable/{placeholder-setup-issue.spec.js => placeholder-setup-warning.spec.js} (92%) create mode 100644 test/unit/view/droppable/placeholder.spec.js create mode 100644 test/unit/view/droppable/update-max-window-scroll.spec.js create mode 100644 test/unit/view/placeholder/animated-mount.spec.js create mode 100644 test/unit/view/placeholder/on-close.spec.js create mode 100644 test/unit/view/placeholder/on-transition-end.spec.js create mode 100644 test/unit/view/placeholder/util/data.js create mode 100644 test/unit/view/placeholder/util/expect.js create mode 100644 test/unit/view/placeholder/util/get-placeholder-style.js create mode 100644 test/utils/after-point.js create mode 100644 test/utils/before-point.js create mode 100644 test/utils/clone-impact.js create mode 100644 test/utils/get-displacement/get-not-animated-displacement.js rename test/utils/{ => get-displacement}/get-not-visible-displacement.js (68%) rename test/utils/{ => get-displacement}/get-visible-displacement.js (68%) create mode 100644 test/utils/no-on-lift.js diff --git a/.size-snapshot.json b/.size-snapshot.json index 9ff8c3b6fb..536269a9f8 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,25 +1,25 @@ { "dist/react-beautiful-dnd.js": { - "bundled": 347275, - "minified": 134225, - "gzipped": 39798 + "bundled": 355565, + "minified": 137929, + "gzipped": 40759 }, "dist/react-beautiful-dnd.min.js": { - "bundled": 295057, - "minified": 109593, - "gzipped": 31944 + "bundled": 303204, + "minified": 113136, + "gzipped": 32811 }, "dist/react-beautiful-dnd.esm.js": { - "bundled": 228983, - "minified": 120746, - "gzipped": 30051, + "bundled": 236995, + "minified": 125149, + "gzipped": 31186, "treeshaked": { "rollup": { - "code": 81922, - "import_statements": 846 + "code": 85449, + "import_statements": 832 }, "webpack": { - "code": 84599 + "code": 88137 } } } diff --git a/.storybook/config.js b/.storybook/config.js index ac51458216..8f57d3f85f 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -2,6 +2,7 @@ import React from 'react'; import { configure, addDecorator } from '@storybook/react'; import { withOptions } from '@storybook/addon-options'; import PopIframeDecorator from './decorator/pop-iframe'; +import GlobalStylesDecorator from './decorator/global-styles'; // adding css reset - storybook includes a css loader import '@atlaskit/css-reset'; import { version } from '../package.json'; @@ -10,10 +11,10 @@ addDecorator( withOptions({ name: 'react-beautiful-dnd', url: 'https://github.com/atlassian/react-beautiful-dnd', - showAddonPanel: false, }), ); +addDecorator(GlobalStylesDecorator); addDecorator(PopIframeDecorator); // automatically import all files ending in *.stories.js diff --git a/.storybook/decorator/global-styles.jsx b/.storybook/decorator/global-styles.jsx new file mode 100644 index 0000000000..9920a344f5 --- /dev/null +++ b/.storybook/decorator/global-styles.jsx @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '@atlaskit/theme'; +import { grid } from '../../stories/src/constants'; + +const GlobalStyles = styled.div` + background-color: ${colors.N0}; + min-height: 100vh; + color: ${colors.N900}; +`; + +const GlobalStylesDecorator = (storyFn: Function) => ( + {storyFn()} +); + +export default GlobalStylesDecorator; diff --git a/LICENSE b/LICENSE index b417933bca..742041f26e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2017 Atlassian Pty Ltd +Copyright 2019 Atlassian Pty Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/guides/types.md b/docs/guides/types.md index ede90444aa..fe3974ee41 100644 --- a/docs/guides/types.md +++ b/docs/guides/types.md @@ -115,6 +115,7 @@ type DraggableStateSnapshot = {| export type DraggableProps = {| style: ?DraggableStyle, 'data-react-beautiful-dnd-draggable': string, + onTransitionEnd: ?(event: TransitionEvent) => void, |}; type DraggableStyle = DraggingStyle | NotDraggingStyle; type DraggingStyle = {| diff --git a/jest.config.js b/jest.config.js index dc2afe93ee..0e4e05a299 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,8 @@ /* eslint-disable flowtype/require-valid-file-annotation */ module.exports = { - setupFiles: ['./test/setup.js'], + setupFiles: ['./test/env-setup.js'], + setupTestFrameworkScriptFile: './test/test-setup.js', // node_modules is default. testPathIgnorePatterns: ['/node_modules/'], watchPlugins: [ diff --git a/package.json b/package.json index 0e8738c936..3030bd7b30 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "devDependencies": { "@atlaskit/css-reset": "^3.0.5", + "@atlaskit/theme": "^7.0.4", "@babel/core": "^7.2.2", "@babel/plugin-proposal-class-properties": "^7.3.0", "@babel/plugin-transform-modules-commonjs": "^7.2.0", @@ -128,7 +129,7 @@ "peerDependencies": { "react": "^16.3.1" }, - "license": "Apache-2.0", + "license": "apache-2.0", "jest-junit": { "output": "test-reports/junit/js-test-results.xml" } diff --git a/src/view/animation.js b/src/animation.js similarity index 61% rename from src/view/animation.js rename to src/animation.js index ae14a72a2e..c734b418db 100644 --- a/src/view/animation.js +++ b/src/animation.js @@ -1,6 +1,6 @@ // @flow import type { Position } from 'css-box-model'; -import { isEqual, origin } from '../state/position'; +import { isEqual, origin } from './state/position'; export const curves = { outOfTheWay: 'cubic-bezier(0.2, 0, 0, 1)', @@ -19,8 +19,30 @@ export const combine = { }, }; -const outOfTheWayTime: number = 0.2; -const outOfTheWayTiming = `${outOfTheWayTime}s ${curves.outOfTheWay}`; +// 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: string = `${timings.outOfTheWay}s ${ + curves.outOfTheWay +}`; +export const placeholderTransitionDelayTime: number = 0.1; export const transitions = { fluid: `opacity ${outOfTheWayTiming}`, @@ -30,6 +52,7 @@ export const transitions = { return `transform ${timing}, opacity ${timing}`; }, outOfTheWay: `transform ${outOfTheWayTiming}`, + placeholder: `height ${outOfTheWayTiming}, width ${outOfTheWayTiming}, margin ${outOfTheWayTiming}`, }; const moveTo = (offset: Position): ?string => diff --git a/src/dev-warning.js b/src/dev-warning.js index 3d56743311..5f3a33a336 100644 --- a/src/dev-warning.js +++ b/src/dev-warning.js @@ -4,9 +4,14 @@ const isProduction: boolean = process.env.NODE_ENV === 'production'; // not replacing newlines (which \s does) const spacesAndTabs: RegExp = /[ \t]{2,}/g; +const lineStartWithSpaces: RegExp = /^[ \t]*/gm; // using .trim() to clear the any newlines before the first text and after last text -const clean = (value: string) => value.replace(spacesAndTabs, ' ').trim(); +const clean = (value: string) => + value + .replace(spacesAndTabs, ' ') + .replace(lineStartWithSpaces, '') + .trim(); const getDevMessage = (message: string) => clean(` diff --git a/src/state/action-creators.js b/src/state/action-creators.js index db0c3ad665..fcdb4d529b 100644 --- a/src/state/action-creators.js +++ b/src/state/action-creators.js @@ -4,12 +4,11 @@ import type { Critical, DraggableId, DroppableId, - DropResult, + CompletedDrag, MovementMode, Viewport, DimensionMap, DropReason, - PendingDrop, Published, } from '../types'; @@ -240,24 +239,35 @@ export const clean = (): CleanAction => ({ payload: null, }); +export type AnimateDropArgs = {| + completed: CompletedDrag, + newHomeClientOffset: Position, + dropDuration: number, +|}; + export type DropAnimateAction = { type: 'DROP_ANIMATE', - payload: PendingDrop, + payload: AnimateDropArgs, }; -export const animateDrop = (pending: PendingDrop): DropAnimateAction => ({ +export const animateDrop = (args: AnimateDropArgs): DropAnimateAction => ({ type: 'DROP_ANIMATE', - payload: pending, + payload: args, }); +export type DropCompleteArgs = {| + completed: CompletedDrag, + shouldFlush: boolean, +|}; + export type DropCompleteAction = { type: 'DROP_COMPLETE', - payload: DropResult, + payload: DropCompleteArgs, }; -export const completeDrop = (result: DropResult): DropCompleteAction => ({ +export const completeDrop = (args: DropCompleteArgs): DropCompleteAction => ({ type: 'DROP_COMPLETE', - payload: result, + payload: args, }); type DropArgs = {| diff --git a/src/state/can-start-drag.js b/src/state/can-start-drag.js index 592ae17d27..d03a3196ea 100644 --- a/src/state/can-start-drag.js +++ b/src/state/can-start-drag.js @@ -19,11 +19,11 @@ export default (state: State, id: DraggableId): boolean => { // 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.pending.result.draggableId === id) { + if (state.completed.result.draggableId === id) { return false; } // if dropping - allow lifting // if cancelling - disallow lifting - return state.pending.result.reason === 'DROP'; + return state.completed.result.reason === 'DROP'; }; diff --git a/src/state/create-store.js b/src/state/create-store.js index d8f582540f..6555251da6 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -10,7 +10,6 @@ import dropAnimationFinish from './middleware/drop-animation-finish'; import dimensionMarshalStopper from './middleware/dimension-marshal-stopper'; import autoScroll from './middleware/auto-scroll'; import pendingDrop from './middleware/pending-drop'; -import updateViewportMaxScrollOnDestinationChange from './middleware/update-viewport-max-scroll-on-destination-change'; import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types'; import type { StyleMarshal } from '../view/style-marshal/style-marshal-types'; import type { AutoScroller } from './auto-scroller/auto-scroller-types'; @@ -79,7 +78,6 @@ export default ({ // When a drop animation finishes - fire a drop complete dropAnimationFinish, pendingDrop, - updateViewportMaxScrollOnDestinationChange, autoScroll(getScroller), // Fire responders for consumers (after update to store) responders(getResponders, announce), diff --git a/src/state/dimension-marshal/dimension-marshal-types.js b/src/state/dimension-marshal/dimension-marshal-types.js index 8c89c5fdc3..4e08767d22 100644 --- a/src/state/dimension-marshal/dimension-marshal-types.js +++ b/src/state/dimension-marshal/dimension-marshal-types.js @@ -29,10 +29,14 @@ export type GetDroppableDimensionFn = ( options: ScrollOptions, ) => DroppableDimension; +export type RecollectDroppableOptions = {| + withoutPlaceholder: boolean, +|}; + export type DroppableCallbacks = {| // a drag is starting getDimensionAndWatchScroll: GetDroppableDimensionFn, - recollect: () => DroppableDimension, + recollect: (options: RecollectDroppableOptions) => DroppableDimension, // scroll a droppable scroll: (change: Position) => void, // If the Droppable is listening for scroll events - it needs to stop! diff --git a/src/state/dimension-marshal/dimension-marshal.js b/src/state/dimension-marshal/dimension-marshal.js index 32a6ee06ea..85e47d0ce7 100644 --- a/src/state/dimension-marshal/dimension-marshal.js +++ b/src/state/dimension-marshal/dimension-marshal.js @@ -56,6 +56,13 @@ export default (callbacks: Callbacks) => { callbacks: { publish: callbacks.publishWhileDragging, collectionStarting: callbacks.collectionStarting, + getCritical: (): Critical => { + invariant( + collection, + 'Cannot get critical when there is no collection', + ); + return collection.critical; + }, }, getEntries: (): Entries => entries, }); diff --git a/src/state/dimension-marshal/while-dragging-publisher.js b/src/state/dimension-marshal/while-dragging-publisher.js index bcf8806f2e..59607a33af 100644 --- a/src/state/dimension-marshal/while-dragging-publisher.js +++ b/src/state/dimension-marshal/while-dragging-publisher.js @@ -7,8 +7,13 @@ import type { DraggableDimension, DroppableDimension, DraggableDescriptor, + Critical, } from '../../types'; -import type { Entries, DroppableEntry } from './dimension-marshal-types'; +import type { + Entries, + DroppableEntry, + RecollectDroppableOptions, +} from './dimension-marshal-types'; import * as timings from '../../debug/timings'; import { origin } from '../position'; import { warning } from '../../dev-warning'; @@ -36,6 +41,7 @@ type Staging = {| type Callbacks = {| publish: (args: Published) => mixed, collectionStarting: () => mixed, + getCritical: () => Critical, |}; type Args = {| @@ -89,6 +95,7 @@ export default ({ getEntries, callbacks }: Args): WhileDraggingPublisher => { frameId = requestAnimationFrame(() => { frameId = null; callbacks.collectionStarting(); + const critical: Critical = callbacks.getCritical(); timings.start(timingKey); const entries: Entries = getEntries(); @@ -111,7 +118,13 @@ export default ({ getEntries, callbacks }: Args): WhileDraggingPublisher => { (id: DroppableId) => { const entry: ?DroppableEntry = entries.droppables[id]; invariant(entry, 'Cannot find dynamically added droppable in cache'); - return entry.callbacks.recollect(); + const isHome: boolean = entry.descriptor.id === critical.droppable.id; + + // need to keep the placeholder when in home list + const options: RecollectDroppableOptions = { + withoutPlaceholder: !isHome, + }; + return entry.callbacks.recollect(options); }, ); diff --git a/src/state/droppable/should-use-placeholder.js b/src/state/droppable/should-use-placeholder.js index 8922a432bf..d97d7275c1 100644 --- a/src/state/droppable/should-use-placeholder.js +++ b/src/state/droppable/should-use-placeholder.js @@ -1,15 +1,7 @@ // @flow -import type { DraggableDescriptor, DragImpact, DroppableId } from '../../types'; +import type { DraggableDescriptor, DragImpact } from '../../types'; import whatIsDraggedOver from './what-is-dragged-over'; -export default ( - descriptor: DraggableDescriptor, - impact: DragImpact, -): boolean => { - // use a placeholder when over a foreign list - const isOver: ?DroppableId = whatIsDraggedOver(impact); - if (!isOver) { - return false; - } - return isOver !== descriptor.droppableId; -}; +// use placeholder if dragged over +export default (descriptor: DraggableDescriptor, impact: DragImpact): boolean => + whatIsDraggedOver(impact) === descriptor.droppableId; diff --git a/src/state/droppable/with-placeholder.js b/src/state/droppable/with-placeholder.js index fe3d40ad4b..e5db0fbd20 100644 --- a/src/state/droppable/with-placeholder.js +++ b/src/state/droppable/with-placeholder.js @@ -13,6 +13,8 @@ import type { 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: DroppableDimension, @@ -53,20 +55,25 @@ const withMaxScroll = (frame: Scrollable, max: Position): Scrollable => ({ export const addPlaceholder = ( droppable: DroppableDimension, - displaceBy: Position, + draggable: DraggableDimension, draggables: DraggableDimensionMap, ): DroppableDimension => { const frame: ?Scrollable = 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: Position = patch( - droppable.axis.line, - displaceBy[droppable.axis.line], - ); + const placeholderSize: Position = getDisplacedBy( + droppable.axis, + draggable.displaceBy, + ).point; const requiredGrowth: ?Position = getRequiredGrowthForPlaceholder( droppable, diff --git a/src/state/get-center-from-impact/get-client-border-box-center/index.js b/src/state/get-center-from-impact/get-client-border-box-center/index.js index be2dea383c..e32c4adde3 100644 --- a/src/state/get-center-from-impact/get-client-border-box-center/index.js +++ b/src/state/get-center-from-impact/get-client-border-box-center/index.js @@ -6,6 +6,7 @@ import type { DragImpact, DraggableDimension, DraggableDimensionMap, + OnLift, } from '../../../types'; import getPageBorderBoxCenterFromImpact from '../get-page-border-box-center'; import getClientFromPageBorderBoxCenter from './get-client-from-page-border-box-center'; @@ -16,6 +17,7 @@ type Args = {| droppable: DroppableDimension, draggables: DraggableDimensionMap, viewport: Viewport, + onLift: OnLift, |}; export default ({ @@ -24,12 +26,14 @@ export default ({ droppable, draggables, viewport, + onLift, }: Args): Position => { const pageBorderBoxCenter: Position = getPageBorderBoxCenterFromImpact({ impact, draggable, draggables, droppable, + onLift, }); return getClientFromPageBorderBoxCenter({ diff --git a/src/state/get-center-from-impact/get-page-border-box-center/index.js b/src/state/get-center-from-impact/get-page-border-box-center/index.js index f4237170e5..5194268b56 100644 --- a/src/state/get-center-from-impact/get-page-border-box-center/index.js +++ b/src/state/get-center-from-impact/get-page-border-box-center/index.js @@ -7,6 +7,7 @@ import type { DraggableDimensionMap, CombineImpact, DraggableLocation, + OnLift, } from '../../../types'; import whenCombining from './when-combining'; import whenReordering from './when-reordering'; @@ -14,6 +15,7 @@ import withDroppableDisplacement from '../../with-scroll-change/with-droppable-d type Args = {| impact: DragImpact, + onLift: OnLift, draggable: DraggableDimension, droppable: ?DroppableDimension, draggables: DraggableDimensionMap, @@ -24,6 +26,7 @@ const getResultWithoutDroppableDisplacement = ({ draggable, droppable, draggables, + onLift, }: Args): Position => { const merge: ?CombineImpact = impact.merge; const destination: ?DraggableLocation = impact.destination; @@ -40,6 +43,7 @@ const getResultWithoutDroppableDisplacement = ({ draggable, draggables, droppable, + onLift, }); } @@ -48,6 +52,7 @@ const getResultWithoutDroppableDisplacement = ({ movement: impact.movement, combine: merge.combine, draggables, + onLift, }); } diff --git a/src/state/get-center-from-impact/get-page-border-box-center/when-combining.js b/src/state/get-center-from-impact/get-page-border-box-center/when-combining.js index b10ae88a75..9585c4c282 100644 --- a/src/state/get-center-from-impact/get-page-border-box-center/when-combining.js +++ b/src/state/get-center-from-impact/get-page-border-box-center/when-combining.js @@ -5,22 +5,31 @@ import type { DraggableId, Combine, DragMovement, + OnLift, } from '../../../types'; import { add } from '../../position'; +import getCombinedItemDisplacement from '../../get-combined-item-displacement'; type Args = {| movement: DragMovement, combine: Combine, // all draggables in the system draggables: DraggableDimensionMap, + onLift: OnLift, |}; // Returns the client offset required to move an item from its // original client position to its final resting position -export default ({ combine, movement, draggables }: Args): Position => { - const groupingWith: DraggableId = combine.draggableId; - const isDisplaced: boolean = Boolean(movement.map[groupingWith]); - const center: Position = draggables[groupingWith].page.borderBox.center; +export default ({ combine, onLift, movement, draggables }: Args): Position => { + const combineWith: DraggableId = combine.draggableId; + const center: Position = draggables[combineWith].page.borderBox.center; - return isDisplaced ? add(center, movement.displacedBy.point) : center; + const displaceBy: Position = getCombinedItemDisplacement({ + displaced: movement.map, + onLift, + combineWith, + displacedBy: movement.displacedBy, + }); + + return add(center, displaceBy); }; diff --git a/src/state/get-center-from-impact/get-page-border-box-center/when-reordering.js b/src/state/get-center-from-impact/get-page-border-box-center/when-reordering.js index 3ebb0ba529..cf8414d9d7 100644 --- a/src/state/get-center-from-impact/get-page-border-box-center/when-reordering.js +++ b/src/state/get-center-from-impact/get-page-border-box-center/when-reordering.js @@ -6,16 +6,19 @@ import type { DraggableDimensionMap, DragMovement, DroppableDimension, + OnLift, } from '../../../types'; import { goBefore, goAfter, goIntoStart } from '../move-relative-to'; import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable'; -import isHomeOf from '../../droppable/is-home-of'; +import { negate } from '../../position'; +import didStartDisplaced from '../../starting-displaced/did-start-displaced'; type NewHomeArgs = {| movement: DragMovement, draggable: DraggableDimension, draggables: DraggableDimensionMap, droppable: DroppableDimension, + onLift: OnLift, |}; // Returns the client offset required to move an item from its @@ -25,6 +28,7 @@ export default ({ draggable, draggables, droppable, + onLift, }: NewHomeArgs): Position => { const insideDestination: DraggableDimension[] = getDraggablesInsideDroppable( droppable.descriptor.id, @@ -43,48 +47,65 @@ export default ({ }); } - const { displaced, willDisplaceForward, displacedBy } = movement; + const { displaced, displacedBy } = movement; - const isOverHome: boolean = isHomeOf(draggable, droppable); + // go before the first displaced item + // items can only be displaced forwards + if (displaced.length) { + const closestAfter: DraggableDimension = + draggables[displaced[0].draggableId]; + // want to go before where it would be with the displacement - // there can be no displaced if: - // - you are in the home index or - // - in the last position of a foreign droppable - const closest: ?DraggableDimension = displaced.length - ? draggables[displaced[0].draggableId] - : null; - - if (!closest) { - // moving back into home index - if (isOverHome) { - return draggable.page.borderBox.center; + // target is displaced and is already in it's starting position + if (didStartDisplaced(closestAfter.descriptor.id, onLift)) { + return goBefore({ + axis, + moveRelativeTo: closestAfter.page, + isMoving: draggablePage, + }); } - // this can happen when moving into the last spot of a foreign list - const moveRelativeTo: DraggableDimension = - insideDestination[insideDestination.length - 1]; - return goAfter({ + // target has been displaced during the drag and it is not in its starting position + // we need to account for the displacement + const withDisplacement: BoxModel = offset( + closestAfter.page, + displacedBy.point, + ); + + return goBefore({ axis, - moveRelativeTo: moveRelativeTo.page, + moveRelativeTo: withDisplacement, isMoving: draggablePage, }); } - const displacedClosest: BoxModel = offset(closest.page, displacedBy.point); + // Nothing in list is displaced, we should go after the last item - // go before and item that is displaced forward - if (willDisplaceForward) { - return goBefore({ + const last: DraggableDimension = + 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 (didStartDisplaced(last.descriptor.id, onLift)) { + // 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: BoxModel = offset(last.page, negate(onLift.displacedBy.point)); + return goAfter({ axis, - moveRelativeTo: displacedClosest, + moveRelativeTo: page, isMoving: draggablePage, }); } - // go after an item that is displaced backwards + // item is in its resting spot. we can go straight after it return goAfter({ axis, - moveRelativeTo: displacedClosest, + moveRelativeTo: last.page, isMoving: draggablePage, }); }; diff --git a/src/state/get-center-from-impact/move-relative-to.js b/src/state/get-center-from-impact/move-relative-to.js index 35a4a66a70..6ac7d89102 100644 --- a/src/state/get-center-from-impact/move-relative-to.js +++ b/src/state/get-center-from-impact/move-relative-to.js @@ -46,7 +46,6 @@ export const goBefore = ({ axis, moveRelativeTo, isMoving }: Args): Position => distanceFromEndToBorderBoxCenter(axis, isMoving), getCrossAxisBorderBoxCenter(axis, moveRelativeTo.marginBox, isMoving), ); - type GoIntoArgs = {| axis: Axis, moveInto: BoxModel, diff --git a/src/state/get-combined-item-displacement.js b/src/state/get-combined-item-displacement.js new file mode 100644 index 0000000000..c7824cd209 --- /dev/null +++ b/src/state/get-combined-item-displacement.js @@ -0,0 +1,32 @@ +// @flow +import type { Position } from 'css-box-model'; +import type { + DisplacementMap, + OnLift, + DraggableId, + DisplacedBy, +} from '../types'; +import { origin, negate } from './position'; +import didStartDisplaced from './starting-displaced/did-start-displaced'; + +type Args = {| + displaced: DisplacementMap, + onLift: OnLift, + combineWith: DraggableId, + displacedBy: DisplacedBy, +|}; + +export default ({ + displaced, + onLift, + combineWith, + displacedBy, +}: Args): Position => { + const isDisplaced: boolean = Boolean(displaced[combineWith]); + + if (didStartDisplaced(combineWith, onLift)) { + return isDisplaced ? origin : negate(displacedBy.point); + } + + return isDisplaced ? displacedBy.point : origin; +}; diff --git a/src/state/get-displaced-by.js b/src/state/get-displaced-by.js index 81f43ffaba..9695edf9cb 100644 --- a/src/state/get-displaced-by.js +++ b/src/state/get-displaced-by.js @@ -6,13 +6,8 @@ import { patch } from './position'; // TODO: memoization needed? export default memoizeOne( - ( - axis: Axis, - displaceBy: Position, - willDisplaceForward: boolean, - ): DisplacedBy => { - const modifier: number = willDisplaceForward ? 1 : -1; - const displacement: number = displaceBy[axis.line] * modifier; + (axis: Axis, displaceBy: Position): DisplacedBy => { + const displacement: number = displaceBy[axis.line]; return { value: displacement, point: patch(axis.line, displacement), diff --git a/src/state/get-displacement.js b/src/state/get-displacement.js index eff47f2ea1..f77b7d133e 100644 --- a/src/state/get-displacement.js +++ b/src/state/get-displacement.js @@ -1,6 +1,5 @@ // @flow -import { type Rect } from 'css-box-model'; -import { isPartiallyVisible } from './visibility/is-visible'; +import { type Rect, getRect } from 'css-box-model'; import type { DraggableId, Displacement, @@ -8,16 +7,32 @@ import type { DroppableDimension, DragImpact, DisplacementMap, + OnLift, } from '../types'; +import { isPartiallyVisible } from './visibility/is-visible'; +import { offsetByPosition } from './spacing'; +import { negate } from './position'; +import didStartDisplaced from './starting-displaced/did-start-displaced'; type Args = {| draggable: DraggableDimension, destination: DroppableDimension, previousImpact: DragImpact, viewport: Rect, + onLift: OnLift, + forceShouldAnimate?: boolean, |}; -const getShouldAnimate = (isVisible: boolean, previous: ?Displacement) => { +const getShouldAnimate = ( + forceShouldAnimate: ?boolean, + isVisible: boolean, + previous: ?Displacement, +) => { + // Use a forced value if provided + if (typeof forceShouldAnimate === 'boolean') { + return forceShouldAnimate; + } + // if should be displaced and not visible if (!isVisible) { return false; @@ -38,25 +53,45 @@ const getShouldAnimate = (isVisible: boolean, previous: ?Displacement) => { // items when they are not longer visible. // This prevents a lot of .render() calls when leaving / entering a list +const getTarget = (draggable: DraggableDimension, onLift: OnLift): Rect => { + const marginBox: Rect = draggable.page.marginBox; + + if (!didStartDisplaced(draggable.descriptor.id, onLift)) { + return marginBox; + } + + return getRect(offsetByPosition(marginBox, negate(onLift.displacedBy.point))); +}; + export default ({ draggable, destination, previousImpact, viewport, + onLift, + forceShouldAnimate, }: Args): Displacement => { const id: DraggableId = draggable.descriptor.id; const map: DisplacementMap = previousImpact.movement.map; + const target: Rect = getTarget(draggable, onLift); + + // We need to account for items that are not in their resting + // position without original displacement // only displacing items that are visible in the droppable and the viewport const isVisible: boolean = isPartiallyVisible({ // TODO: borderBox? - target: draggable.page.marginBox, + target, destination, viewport, withDroppableDisplacement: true, }); - const shouldAnimate: boolean = getShouldAnimate(isVisible, map[id]); + const shouldAnimate: boolean = getShouldAnimate( + forceShouldAnimate, + isVisible, + map[id], + ); const displacement: Displacement = { draggableId: id, diff --git a/src/state/get-drag-impact/get-combine-impact.js b/src/state/get-drag-impact/get-combine-impact.js index 7e9504649a..b7a7e48e63 100644 --- a/src/state/get-drag-impact/get-combine-impact.js +++ b/src/state/get-drag-impact/get-combine-impact.js @@ -9,10 +9,13 @@ import type { CombineImpact, DragImpact, DisplacementMap, + OnLift, + DisplacedBy, } from '../../types'; import isWithin from '../is-within'; import { find } from '../../native-with-fallback'; import isUserMovingForward from '../user-direction/is-user-moving-forward'; +import getCombinedItemDisplacement from '../get-combined-item-displacement'; const getWhenEntered = ( id: DraggableId, @@ -33,7 +36,7 @@ type IsCombiningWithArgs = {| currentCenter: Position, axis: Axis, borderBox: Rect, - displacedBy: number, + displaceBy: Position, currentUserDirection: UserDirection, oldMerge: ?CombineImpact, |}; @@ -43,12 +46,12 @@ const isCombiningWith = ({ currentCenter, axis, borderBox, - displacedBy, + displaceBy, currentUserDirection, oldMerge, }: IsCombiningWithArgs): boolean => { - const start: number = borderBox[axis.start] + displacedBy; - const end: number = borderBox[axis.end] + displacedBy; + const start: number = borderBox[axis.start] + displaceBy[axis.line]; + const end: number = borderBox[axis.end] + displaceBy[axis.line]; const size: number = borderBox[axis.size]; const twoThirdsOfSize: number = size * 0.666; @@ -71,18 +74,18 @@ const isCombiningWith = ({ type Args = {| pageBorderBoxCenterWithDroppableScrollChange: Position, previousImpact: DragImpact, - draggable: DraggableDimension, destination: DroppableDimension, - insideDestination: DraggableDimension[], + insideDestinationWithoutDraggable: DraggableDimension[], userDirection: UserDirection, + onLift: OnLift, |}; export default ({ pageBorderBoxCenterWithDroppableScrollChange: currentCenter, previousImpact, - draggable, destination, - insideDestination, + insideDestinationWithoutDraggable, userDirection, + onLift, }: Args): ?DragImpact => { if (!destination.isCombineEnabled) { return null; @@ -90,27 +93,27 @@ export default ({ const axis: Axis = destination.axis; const map: DisplacementMap = previousImpact.movement.map; - const canBeDisplacedBy: number = previousImpact.movement.displacedBy.value; + const canBeDisplacedBy: DisplacedBy = previousImpact.movement.displacedBy; const oldMerge: ?CombineImpact = previousImpact.merge; const target: ?DraggableDimension = find( - insideDestination, + insideDestinationWithoutDraggable, (child: DraggableDimension): boolean => { - // Cannot group with yourself const id: DraggableId = child.descriptor.id; - if (id === draggable.descriptor.id) { - return false; - } - const isDisplaced: boolean = Boolean(map[id]); - const displacedBy: number = isDisplaced ? canBeDisplacedBy : 0; + const displaceBy: Position = getCombinedItemDisplacement({ + displaced: map, + onLift, + combineWith: id, + displacedBy: canBeDisplacedBy, + }); return isCombiningWith({ id, currentCenter, axis, borderBox: child.page.borderBox, - displacedBy, + displaceBy, currentUserDirection: userDirection, oldMerge, }); diff --git a/src/state/get-drag-impact/in-foreign-list.js b/src/state/get-drag-impact/get-reorder-impact.js similarity index 60% rename from src/state/get-drag-impact/in-foreign-list.js rename to src/state/get-drag-impact/get-reorder-impact.js index d101e5f988..d508fdfb4e 100644 --- a/src/state/get-drag-impact/in-foreign-list.js +++ b/src/state/get-drag-impact/get-reorder-impact.js @@ -10,72 +10,82 @@ import type { Viewport, UserDirection, DisplacedBy, + OnLift, } from '../../types'; import getDisplacement from '../get-displacement'; import getDisplacementMap from '../get-displacement-map'; import isUserMovingForward from '../user-direction/is-user-moving-forward'; import getDisplacedBy from '../get-displaced-by'; +import getDidStartDisplaced from '../starting-displaced/did-start-displaced'; type Args = {| pageBorderBoxCenterWithDroppableScrollChange: Position, draggable: DraggableDimension, destination: DroppableDimension, - insideDestination: DraggableDimension[], + insideDestinationWithoutDraggable: DraggableDimension[], previousImpact: DragImpact, viewport: Viewport, userDirection: UserDirection, + onLift: OnLift, |}; export default ({ pageBorderBoxCenterWithDroppableScrollChange: currentCenter, draggable, destination, - insideDestination, + insideDestinationWithoutDraggable, previousImpact, viewport, userDirection, + onLift, }: Args): DragImpact => { const axis: Axis = destination.axis; - const isMovingForward: boolean = isUserMovingForward( destination.axis, userDirection, ); - const displacedBy: DisplacedBy = getDisplacedBy( destination.axis, draggable.displaceBy, - // always displace forward in foreign list - true, ); - const targetCenter: number = currentCenter[axis.line]; const displacement: number = displacedBy.value; - const displaced: Displacement[] = insideDestination + const displaced: Displacement[] = insideDestinationWithoutDraggable .filter( (child: DraggableDimension): boolean => { const borderBox: Rect = child.page.borderBox; const start: number = borderBox[axis.start]; const end: number = borderBox[axis.end]; - // If entering list then assume everything is displaced for initial impact - // reminder: 'displacement' can be positive or negative + const didStartDisplaced: boolean = getDidStartDisplaced( + child.descriptor.id, + onLift, + ); - // When in foreign list, can only displace forwards // Moving forward will decrease the amount of things needed to be displaced if (isMovingForward) { - return targetCenter <= start + displacement; + if (didStartDisplaced) { + // if started displaced then its displaced position is its resting position + // continue to keep the item at rest until we go onto the start of the item + return targetCenter < start; + } + // if the item did not start displaced then we displace the item + // while we are still before the start edge + return targetCenter < start + displacement; } - // Moving backwards towards top of list // Moving backwards will increase the amount of things needed to be displaced + // The logic for this works by looking at assuming everything has been displaced + // backwards and then looking at how you would undo that - // this will be hit when: - // - move backwards in the first position - // - enter into a foreign list moving backwards + if (didStartDisplaced) { + // we continue to displace the item until we move back over the end of the item without displacement + return targetCenter <= end - displacement; + } - return targetCenter < end; + // a non-displaced item is at rest. when we hit the item from the bottom we move it out of the way + return targetCenter <= end; }, ) .map( @@ -85,21 +95,20 @@ export default ({ destination, previousImpact, viewport: viewport.frame, + onLift, }), ); - const newIndex: number = insideDestination.length - displaced.length; + const newIndex: number = + insideDestinationWithoutDraggable.length - displaced.length; const movement: DragMovement = { displacedBy, displaced, map: getDisplacementMap(displaced), - willDisplaceForward: true, }; - const impact: DragImpact = { movement, - direction: axis.direction, destination: { droppableId: destination.descriptor.id, index: newIndex, diff --git a/src/state/get-drag-impact/in-home-list.js b/src/state/get-drag-impact/in-home-list.js deleted file mode 100644 index bf782ec528..0000000000 --- a/src/state/get-drag-impact/in-home-list.js +++ /dev/null @@ -1,180 +0,0 @@ -// @flow -import { type Position, type Rect } from 'css-box-model'; -import type { - DragMovement, - DraggableDimension, - DroppableDimension, - DragImpact, - Axis, - Displacement, - Viewport, - UserDirection, - DisplacedBy, -} from '../../types'; -import getDisplacement from '../get-displacement'; -import getDisplacementMap from '../get-displacement-map'; -import isUserMovingForward from '../user-direction/is-user-moving-forward'; -import getDisplacedBy from '../get-displaced-by'; - -const getNewIndex = ( - startIndex: number, - amountOfDisplaced: number, - isInFrontOfStart: boolean, -): number => { - if (!amountOfDisplaced) { - return startIndex; - } - - if (isInFrontOfStart) { - return startIndex + amountOfDisplaced; - } - // is moving backwards - return startIndex - amountOfDisplaced; -}; - -type Args = {| - pageBorderBoxCenterWithDroppableScrollChange: Position, - draggable: DraggableDimension, - home: DroppableDimension, - insideHome: DraggableDimension[], - previousImpact: DragImpact, - viewport: Viewport, - userDirection: UserDirection, -|}; - -export default ({ - pageBorderBoxCenterWithDroppableScrollChange: currentCenter, - draggable, - home, - insideHome, - previousImpact, - viewport, - userDirection: currentUserDirection, -}: Args): DragImpact => { - const axis: Axis = home.axis; - // The starting center position - const originalCenter: Position = draggable.page.borderBox.center; - const targetCenter: number = currentCenter[axis.line]; - const isInFrontOfStart: boolean = targetCenter > originalCenter[axis.line]; - - // when behind where we started we push items forward - // when in front of where we started we push items backwards - const willDisplaceForward: boolean = !isInFrontOfStart; - - const isMovingForward: boolean = isUserMovingForward( - home.axis, - currentUserDirection, - ); - const isMovingTowardStart: boolean = isInFrontOfStart - ? !isMovingForward - : isMovingForward; - - const displacedBy: DisplacedBy = getDisplacedBy( - home.axis, - draggable.displaceBy, - willDisplaceForward, - ); - const displacement: number = displacedBy.value; - - const displaced: Displacement[] = insideHome - .filter( - (child: DraggableDimension): boolean => { - // do not want to displace the item that is dragging - if (child === draggable) { - return false; - } - - const borderBox: Rect = child.page.borderBox; - const start: number = borderBox[axis.start]; - const end: number = borderBox[axis.end]; - - if (isInFrontOfStart) { - // Nothing behind start can be displaced - if (child.descriptor.index < draggable.descriptor.index) { - return false; - } - - // Moving backwards towards the starting location - // Can reduce the amount of things that are displaced - // Need to check if the center is going over the - // end edge of a the target - // We apply the displacement to the calculation even if - // the item is not displaced so that it will have a consistent - // impact moving in a list as well as moving into it - if (isMovingTowardStart) { - const displacedEndEdge: number = end + displacement; - return targetCenter > displacedEndEdge; - } - - // Moving forwards away from the starting location - // Need to check if the center is going over the - // start edge of the target - // Can increase the amount of things that are displaced - return targetCenter >= start; - } - - // is behind where we started - - // Nothing in front of start can be displaced - if (child.descriptor.index > draggable.descriptor.index) { - return false; - } - - // Moving back towards the starting location - // Can reduce the amount of things displaced - // We apply the displacement to the calculation even if - // the item is not displaced so that it will have a consistent - // impact moving in a list as well as moving into it - // End displacement when we move onto the displaced start edge - if (isMovingTowardStart) { - const displacedStartEdge: number = start + displacement; - return targetCenter < displacedStartEdge; - } - - // Continuing to move further away backwards from the start - // Can increase the amount of things that are displaced - // Shift once the center goes onto the end of the thing before it - return targetCenter <= end; - }, - ) - .map( - (dimension: DraggableDimension): Displacement => - getDisplacement({ - draggable: dimension, - destination: home, - previousImpact, - viewport: viewport.frame, - }), - ); - - // Need to ensure that we always order by the closest impacted item - // when in front of start (displacing backwards) we need to reverse - // the natural order of the list so that it is ordered from last to first - const ordered: Displacement[] = isInFrontOfStart - ? displaced.reverse() - : displaced; - const index: number = getNewIndex( - draggable.descriptor.index, - ordered.length, - isInFrontOfStart, - ); - - const newMovement: DragMovement = { - displaced: ordered, - map: getDisplacementMap(ordered), - willDisplaceForward, - displacedBy, - }; - - const impact: DragImpact = { - movement: newMovement, - direction: axis.direction, - destination: { - droppableId: home.descriptor.id, - index, - }, - merge: null, - }; - - return impact; -}; diff --git a/src/state/get-drag-impact/index.js b/src/state/get-drag-impact/index.js index 0f9564ef8b..2da6679db9 100644 --- a/src/state/get-drag-impact/index.js +++ b/src/state/get-drag-impact/index.js @@ -9,15 +9,15 @@ import type { UserDirection, DragImpact, Viewport, + OnLift, } from '../../types'; import getDroppableOver from '../get-droppable-over'; import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; -import inHomeList from './in-home-list'; -import inForeignList from './in-foreign-list'; -import noImpact from '../no-impact'; import withDroppableScroll from '../with-scroll-change/with-droppable-scroll'; -import isHomeOf from '../droppable/is-home-of'; import getCombineImpact from './get-combine-impact'; +import getReorderImpact from './get-reorder-impact'; +import noImpact from '../no-impact'; +import removeDraggableFromList from '../remove-draggable-from-list'; type Args = {| pageBorderBoxCenter: Position, @@ -28,6 +28,7 @@ type Args = {| previousImpact: DragImpact, viewport: Viewport, userDirection: UserDirection, + onLift: OnLift, |}; export default ({ @@ -38,6 +39,7 @@ export default ({ previousImpact, viewport, userDirection, + onLift, }: Args): DragImpact => { const destinationId: ?DroppableId = getDroppableOver({ target: pageBorderBoxCenter, @@ -45,17 +47,22 @@ export default ({ }); // 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: DroppableDimension = droppables[destinationId]; - - const isWithinHomeDroppable: boolean = isHomeOf(draggable, destination); const insideDestination: DraggableDimension[] = getDraggablesInsideDroppable( destination.descriptor.id, draggables, ); + const insideDestinationWithoutDraggable: DraggableDimension[] = removeDraggableFromList( + draggable, + insideDestination, + ); // Where the element actually is now. // Need to take into account the change of scroll in the droppable const pageBorderBoxCenterWithDroppableScrollChange: Position = withDroppableScroll( @@ -63,36 +70,28 @@ export default ({ pageBorderBoxCenter, ); + // checking combine first so we combine before any reordering const withMerge: ?DragImpact = getCombineImpact({ pageBorderBoxCenterWithDroppableScrollChange, previousImpact, - draggable, destination, - insideDestination, + insideDestinationWithoutDraggable, userDirection, + onLift, }); if (withMerge) { return withMerge; } - return isWithinHomeDroppable - ? inHomeList({ - pageBorderBoxCenterWithDroppableScrollChange, - draggable, - home: destination, - insideHome: insideDestination, - previousImpact, - viewport, - userDirection, - }) - : inForeignList({ - pageBorderBoxCenterWithDroppableScrollChange, - draggable, - destination, - insideDestination, - previousImpact, - viewport, - userDirection, - }); + return getReorderImpact({ + pageBorderBoxCenterWithDroppableScrollChange, + destination, + draggable, + insideDestinationWithoutDraggable, + previousImpact, + viewport, + userDirection, + onLift, + }); }; diff --git a/src/state/get-home-impact.js b/src/state/get-home-impact.js deleted file mode 100644 index d3743677cf..0000000000 --- a/src/state/get-home-impact.js +++ /dev/null @@ -1,18 +0,0 @@ -// @flow -import getHomeLocation from './get-home-location'; -import { noMovement } from './no-impact'; -import type { - DraggableDimension, - DroppableDimension, - DragImpact, -} from '../types'; - -export default ( - draggable: DraggableDimension, - home: DroppableDimension, -): DragImpact => ({ - movement: noMovement, - direction: home.axis.direction, - destination: getHomeLocation(draggable.descriptor), - merge: null, -}); diff --git a/src/state/get-home-on-lift.js b/src/state/get-home-on-lift.js new file mode 100644 index 0000000000..e7626578b3 --- /dev/null +++ b/src/state/get-home-on-lift.js @@ -0,0 +1,87 @@ +// @flow +import getHomeLocation from './get-home-location'; +import type { + DraggableDimension, + DroppableDimension, + DraggableDimensionMap, + DragImpact, + Displacement, + DisplacedBy, + Viewport, + DragMovement, + DraggableIdMap, + OnLift, +} from '../types'; +import noImpact from './no-impact'; +import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; +import getDisplacedBy from './get-displaced-by'; +import getDisplacementMap from './get-displacement-map'; +import getDisplacement from './get-displacement'; + +type Args = {| + draggable: DraggableDimension, + home: DroppableDimension, + draggables: DraggableDimensionMap, + viewport: Viewport, +|}; + +type Result = {| + onLift: OnLift, + impact: DragImpact, +|}; + +export default ({ draggable, home, draggables, viewport }: Args): Result => { + const displacedBy: DisplacedBy = getDisplacedBy( + home.axis, + draggable.displaceBy, + ); + + const insideHome: DraggableDimension[] = getDraggablesInsideDroppable( + home.descriptor.id, + draggables, + ); + + const originallyDisplaced: DraggableDimension[] = insideHome.slice( + draggable.descriptor.index + 1, + ); + const wasDisplaced: DraggableIdMap = originallyDisplaced.reduce( + (previous: DraggableIdMap, item: DraggableDimension): DraggableIdMap => { + previous[item.descriptor.id] = true; + return previous; + }, + {}, + ); + const onLift: OnLift = { + displacedBy, + wasDisplaced, + }; + + const displaced: Displacement[] = originallyDisplaced.map( + (dimension: DraggableDimension): Displacement => + getDisplacement({ + draggable: dimension, + destination: home, + previousImpact: noImpact, + 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, + onLift, + }), + ); + + const movement: DragMovement = { + displaced, + map: getDisplacementMap(displaced), + displacedBy, + }; + + const impact: DragImpact = { + movement, + destination: getHomeLocation(draggable.descriptor), + merge: null, + }; + + return { impact, onLift }; +}; diff --git a/src/state/middleware/drop-animation-finish.js b/src/state/middleware/drop-animation-finish.js index 2c6e31c58e..c0e23b8dd8 100644 --- a/src/state/middleware/drop-animation-finish.js +++ b/src/state/middleware/drop-animation-finish.js @@ -17,5 +17,7 @@ export default (store: MiddlewareStore) => (next: Dispatch) => ( state.phase === 'DROP_ANIMATING', 'Cannot finish a drop animating when no drop is occurring', ); - store.dispatch(completeDrop(state.pending.result)); + store.dispatch( + completeDrop({ completed: state.completed, shouldFlush: false }), + ); }; diff --git a/src/state/middleware/drop/drop-middleware.js b/src/state/middleware/drop/drop-middleware.js index 7f39dce1c9..d0079e3945 100644 --- a/src/state/middleware/drop/drop-middleware.js +++ b/src/state/middleware/drop/drop-middleware.js @@ -1,24 +1,28 @@ // @flow import invariant from 'tiny-invariant'; import type { Position } from 'css-box-model'; -import { animateDrop, completeDrop, dropPending } from '../../action-creators'; -import noImpact from '../../no-impact'; -import { isEqual } from '../../position'; -import getDropDuration from './get-drop-duration'; -import getNewHomeClientOffset from './get-new-home-client-offset'; import type { State, DropReason, Critical, DraggableLocation, - DragImpact, DropResult, - PendingDrop, + CompletedDrag, Combine, DimensionMap, DraggableDimension, } from '../../../types'; import type { MiddlewareStore, Dispatch, Action } from '../../store-types'; +import { + animateDrop, + completeDrop, + dropPending, + type AnimateDropArgs, +} from '../../action-creators'; +import { isEqual } from '../../position'; +import getDropDuration from './get-drop-duration'; +import getNewHomeClientOffset from './get-new-home-client-offset'; +import getDropImpact, { type Result } from './get-drop-impact'; export default ({ getState, dispatch }: MiddlewareStore) => ( next: Dispatch, @@ -62,12 +66,25 @@ export default ({ getState, dispatch }: MiddlewareStore) => ( const dimensions: DimensionMap = state.dimensions; // Only keeping impact when doing a user drop - otherwise we are cancelling - const impact: DragImpact = reason === 'DROP' ? state.impact : noImpact; + const { impact, didDropInsideDroppable }: Result = getDropImpact({ + reason, + lastImpact: state.impact, + onLift: state.onLift, + onLiftImpact: state.onLiftImpact, + home: state.dimensions.droppables[state.critical.droppable.id], + viewport: state.viewport, + draggables: state.dimensions.draggables, + }); + const draggable: DraggableDimension = dimensions.draggables[state.critical.draggable.id]; - const destination: ?DraggableLocation = impact ? impact.destination : null; + + // only populating destination / combine if 'didDropInsideDroppable' is true + const destination: ?DraggableLocation = didDropInsideDroppable + ? impact.destination + : null; const combine: ?Combine = - impact && impact.merge ? impact.merge.combine : null; + didDropInsideDroppable && impact.merge ? impact.merge.combine : null; const source: DraggableLocation = { index: critical.draggable.index, @@ -78,10 +95,11 @@ export default ({ getState, dispatch }: MiddlewareStore) => ( draggableId: draggable.descriptor.id, type: draggable.descriptor.type, source, + reason, mode: state.movementMode, + // destination / combine will be null if didDropInsideDroppable is true destination, combine, - reason, }; const newHomeClientOffset: Position = getNewHomeClientOffset({ @@ -89,18 +107,24 @@ export default ({ getState, dispatch }: MiddlewareStore) => ( draggable, dimensions, viewport: state.viewport, + onLift: state.onLift, }); - // Do not animate if you do not need to. - // Animate the drop if: - // - not already in the right spot OR - // - doing a combine (we still want to animate the scale and opacity fade) + const completed: CompletedDrag = { + critical: state.critical, + result, + impact, + }; + const isAnimationRequired: boolean = + // 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(result)); + dispatch(completeDrop({ completed, shouldFlush: false })); return; } @@ -110,12 +134,11 @@ export default ({ getState, dispatch }: MiddlewareStore) => ( reason, }); - const pending: PendingDrop = { + const args: AnimateDropArgs = { newHomeClientOffset, dropDuration, - result, - impact, + completed, }; - dispatch(animateDrop(pending)); + dispatch(animateDrop(args)); }; diff --git a/src/state/middleware/drop/get-drop-duration.js b/src/state/middleware/drop/get-drop-duration.js index d292e7f066..ab193ffd81 100644 --- a/src/state/middleware/drop/get-drop-duration.js +++ b/src/state/middleware/drop/get-drop-duration.js @@ -1,6 +1,7 @@ // @flow import type { Position } from 'css-box-model'; import { distance as getDistance } from '../../position'; +import { timings } from '../../../animation'; import type { DropReason } from '../../../types'; type GetDropDurationArgs = {| @@ -9,8 +10,7 @@ type GetDropDurationArgs = {| reason: DropReason, |}; -const minDropTime: number = 0.33; -const maxDropTime: number = 0.55; +const { minDropTime, maxDropTime } = timings; const dropTimeRange: number = maxDropTime - minDropTime; const maxDropTimeAtDistance: number = 1500; // will bring a time lower - which makes it faster diff --git a/src/state/middleware/drop/get-drop-impact.js b/src/state/middleware/drop/get-drop-impact.js new file mode 100644 index 0000000000..caed464cbf --- /dev/null +++ b/src/state/middleware/drop/get-drop-impact.js @@ -0,0 +1,83 @@ +// @flow +import type { + DropReason, + DragImpact, + Viewport, + DroppableDimension, + DraggableDimensionMap, + OnLift, +} from '../../../types'; +import whatIsDraggedOver from '../../droppable/what-is-dragged-over'; +import recompute from '../../update-displacement-visibility/recompute'; +import { noMovement } from '../../no-impact'; + +type Args = {| + reason: DropReason, + lastImpact: DragImpact, + onLiftImpact: DragImpact, + viewport: Viewport, + home: DroppableDimension, + draggables: DraggableDimensionMap, + onLift: OnLift, +|}; + +export type Result = {| + impact: DragImpact, + didDropInsideDroppable: boolean, +|}; + +export default ({ + reason, + lastImpact, + home, + viewport, + draggables, + onLiftImpact, + onLift, +}: Args): Result => { + const didDropInsideDroppable: boolean = + reason === 'DROP' && Boolean(whatIsDraggedOver(lastImpact)); + + if (!didDropInsideDroppable) { + // 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 impact: DragImpact = recompute({ + impact: onLiftImpact, + destination: home, + viewport, + draggables, + onLift, + // We need the draggables to animate back to their positions + forceShouldAnimate: true, + }); + + return { + impact, + didDropInsideDroppable, + }; + } + + // use the existing impact + if (lastImpact.destination) { + return { + impact: lastImpact, + didDropInsideDroppable, + }; + } + + // When merging we remove the movement so that everything + // will animate closed + const withoutMovement: DragImpact = { + ...lastImpact, + movement: noMovement, + }; + + return { + impact: withoutMovement, + didDropInsideDroppable, + }; +}; diff --git a/src/state/middleware/drop/get-new-home-client-offset.js b/src/state/middleware/drop/get-new-home-client-offset.js index e148b4cd3d..9b36d0fe36 100644 --- a/src/state/middleware/drop/get-new-home-client-offset.js +++ b/src/state/middleware/drop/get-new-home-client-offset.js @@ -7,16 +7,20 @@ import type { DimensionMap, DraggableDimension, DroppableId, + OnLift, + CombineImpact, } from '../../../types'; import whatIsDraggedOver from '../../droppable/what-is-dragged-over'; import { subtract } from '../../position'; import getClientBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center'; +import didStartDisplaced from '../../starting-displaced/did-start-displaced'; type Args = {| impact: DragImpact, draggable: DraggableDimension, dimensions: DimensionMap, viewport: Viewport, + onLift: OnLift, |}; export default ({ @@ -24,6 +28,7 @@ export default ({ draggable, dimensions, viewport, + onLift, }: Args): Position => { const { draggables, droppables } = dimensions; const droppableId: ?DroppableId = whatIsDraggedOver(impact); @@ -37,6 +42,7 @@ export default ({ draggable, draggables, // if there is no destination, then we will be dropping back into the home + onLift, droppable: destination || home, viewport, }); @@ -46,5 +52,14 @@ export default ({ draggable.client.borderBox.center, ); + const merge: ?CombineImpact = impact.merge; + + // When dropping with a merge we want to drop the dragging item + // into the new home location of the target. + // The target will move as a result of a drop if it started displaced + if (merge && didStartDisplaced(merge.combine.draggableId, onLift)) { + return subtract(offset, onLift.displacedBy.point); + } + return offset; }; diff --git a/src/state/middleware/lift.js b/src/state/middleware/lift.js index 15e3953431..8db6baff3f 100644 --- a/src/state/middleware/lift.js +++ b/src/state/middleware/lift.js @@ -22,7 +22,7 @@ export default (getMarshal: () => DimensionMarshal) => ({ // this can change the descriptor of the dragging item // Will call the onDragEnd responders if (initial.phase === 'DROP_ANIMATING') { - dispatch(completeDrop(initial.pending.result)); + dispatch(completeDrop({ completed: initial.completed, shouldFlush: true })); } invariant(getState().phase === 'IDLE', 'Incorrect phase to start a drag'); diff --git a/src/state/middleware/responders/responders-middleware.js b/src/state/middleware/responders/responders-middleware.js index 4837c7a70c..074fd026f2 100644 --- a/src/state/middleware/responders/responders-middleware.js +++ b/src/state/middleware/responders/responders-middleware.js @@ -36,7 +36,9 @@ export default ( // Drag end if (action.type === 'DROP_COMPLETE') { - const result: DropResult = action.payload; + // 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: DropResult = action.payload.completed.result; // flushing all pending responders before snapshots are updated publisher.flush(); next(action); diff --git a/src/state/middleware/style.js b/src/state/middleware/style.js index 2982ebf521..97c82aa3cf 100644 --- a/src/state/middleware/style.js +++ b/src/state/middleware/style.js @@ -10,7 +10,7 @@ export default (marshal: StyleMarshal) => () => (next: Dispatch) => ( } if (action.type === 'DROP_ANIMATE') { - marshal.dropping(action.payload.result.reason); + marshal.dropping(action.payload.completed.result.reason); } // this will clear any styles immediately before a reorder diff --git a/src/state/middleware/update-viewport-max-scroll-on-destination-change.js b/src/state/middleware/update-viewport-max-scroll-on-destination-change.js deleted file mode 100644 index 2a41730587..0000000000 --- a/src/state/middleware/update-viewport-max-scroll-on-destination-change.js +++ /dev/null @@ -1,73 +0,0 @@ -// @flow -import type { Position } from 'css-box-model'; -import type { State, Viewport } from '../../types'; -import type { Action, MiddlewareStore, Dispatch } from '../store-types'; -import { isEqual } from '../position'; -import { updateViewportMaxScroll } from '../action-creators'; -import isMovementAllowed from '../is-movement-allowed'; -import whatIsDraggedOver from '../droppable/what-is-dragged-over'; -import getMaxWindowScroll from '../../view/window/get-max-window-scroll'; - -const shouldCheckOnAction = (action: Action): boolean => - action.type === 'MOVE' || - action.type === 'MOVE_UP' || - action.type === 'MOVE_RIGHT' || - action.type === 'MOVE_DOWN' || - action.type === 'MOVE_LEFT' || - action.type === 'MOVE_BY_WINDOW_SCROLL'; - -const wasDestinationChange = ( - previous: State, - current: State, - action: Action, -): boolean => { - if (!shouldCheckOnAction(action)) { - return false; - } - - if (!isMovementAllowed(previous) || !isMovementAllowed(current)) { - return false; - } - - if ( - whatIsDraggedOver(previous.impact) === whatIsDraggedOver(current.impact) - ) { - return false; - } - - return true; -}; - -// check to see if the viewport max scroll has changed -const getUpdatedViewportMax = (viewport: Viewport): ?Position => { - const maxScroll: Position = getMaxWindowScroll(); - - // No change in current or max scroll - if (isEqual(viewport.scroll.max, maxScroll)) { - return null; - } - - return maxScroll; -}; - -export default (store: MiddlewareStore) => (next: Dispatch) => ( - action: Action, -): any => { - const previous: State = store.getState(); - next(action); - const current: State = store.getState(); - - if (!current.isDragging) { - return; - } - - if (!wasDestinationChange(previous, current, action)) { - return; - } - - const maxScroll: ?Position = getUpdatedViewportMax(current.viewport); - - if (maxScroll) { - next(updateViewportMaxScroll({ maxScroll })); - } -}; diff --git a/src/state/move-in-direction/index.js b/src/state/move-in-direction/index.js index ffb5f89398..ced7fc4a4a 100644 --- a/src/state/move-in-direction/index.js +++ b/src/state/move-in-direction/index.js @@ -1,8 +1,5 @@ // @flow import type { Position } from 'css-box-model'; -import moveCrossAxis from './move-cross-axis'; -import moveToNextPlace from './move-to-next-place'; -import whatIsDraggedOver from '../droppable/what-is-dragged-over'; import type { PublicResult } from './move-in-direction-types'; import type { DroppableId, @@ -12,8 +9,10 @@ import type { DraggableDimension, DroppableDimensionMap, DragImpact, - Viewport, } from '../../types'; +import moveToNextPlace from './move-to-next-place'; +import moveCrossAxis from './move-cross-axis'; +import whatIsDraggedOver from '../droppable/what-is-dragged-over'; type Args = {| state: DraggingState, @@ -59,18 +58,18 @@ export default ({ state, type }: Args): ?PublicResult => { const previousPageBorderBoxCenter: Position = state.current.page.borderBoxCenter; const { draggables, droppables } = state.dimensions; - const viewport: Viewport = state.viewport; return isMovingOnMainAxis ? moveToNextPlace({ isMovingForward, + previousPageBorderBoxCenter, draggable, destination: isOver, draggables, - viewport, - previousPageBorderBoxCenter, + viewport: state.viewport, previousClientSelection: state.current.client.selection, previousImpact: state.impact, + onLift: state.onLift, }) : moveCrossAxis({ isMovingForward, @@ -80,6 +79,7 @@ export default ({ state, type }: Args): ?PublicResult => { draggables, droppables, previousImpact: state.impact, - viewport, + viewport: state.viewport, + onLift: state.onLift, }); }; diff --git a/src/state/move-in-direction/move-cross-axis/get-closest-draggable.js b/src/state/move-in-direction/move-cross-axis/get-closest-draggable.js index 37160bafb7..c2e1c16241 100644 --- a/src/state/move-in-direction/move-cross-axis/get-closest-draggable.js +++ b/src/state/move-in-direction/move-cross-axis/get-closest-draggable.js @@ -1,31 +1,35 @@ // @flow import { type Position } from 'css-box-model'; -import { distance } from '../../position'; -import { isTotallyVisible } from '../../visibility/is-visible'; -import withDroppableDisplacement from '../../with-scroll-change/with-droppable-displacement'; import type { Viewport, - Axis, DraggableDimension, DroppableDimension, + OnLift, } from '../../../types'; +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'; type Args = {| - axis: Axis, pageBorderBoxCenter: Position, viewport: Viewport, // the droppable that is being moved to destination: DroppableDimension, // the droppables inside the destination insideDestination: DraggableDimension[], + onLift: OnLift, |}; export default ({ - axis, pageBorderBoxCenter, viewport, destination, insideDestination, + onLift, }: Args): ?DraggableDimension => { const sorted: DraggableDimension[] = insideDestination .filter( @@ -34,7 +38,7 @@ export default ({ // but must be visible in the droppable // We can improve this, but this limitation is easier for now isTotallyVisible({ - target: draggable.page.borderBox, + target: getCurrentPageBorderBox(draggable, onLift), destination, viewport: viewport.frame, withDroppableDisplacement: true, @@ -45,11 +49,17 @@ export default ({ // Need to consider the change in scroll in the destination const distanceToA = distance( pageBorderBoxCenter, - withDroppableDisplacement(destination, a.page.borderBox.center), + withDroppableDisplacement( + destination, + getCurrentPageBorderBoxCenter(a, onLift), + ), ); const distanceToB = distance( pageBorderBoxCenter, - withDroppableDisplacement(destination, b.page.borderBox.center), + withDroppableDisplacement( + destination, + getCurrentPageBorderBoxCenter(b, onLift), + ), ); // if a is closer - return a @@ -63,8 +73,8 @@ export default ({ } // if the distance to a and b are the same: - // return the one that appears first on the main axis - return a.page.borderBox[axis.start] - b.page.borderBox[axis.start]; + // return the one with the lower index (it will be higher on the main axis) + return a.descriptor.index - b.descriptor.index; }, ); diff --git a/src/state/move-in-direction/move-cross-axis/index.js b/src/state/move-in-direction/move-cross-axis/index.js index d6fee6552e..f436630005 100644 --- a/src/state/move-in-direction/move-cross-axis/index.js +++ b/src/state/move-in-direction/move-cross-axis/index.js @@ -8,13 +8,15 @@ import type { DroppableDimensionMap, DragImpact, Viewport, + OnLift, } from '../../../types'; import getBestCrossAxisDroppable from './get-best-cross-axis-droppable'; import getClosestDraggable from './get-closest-draggable'; -import moveToNewDroppable from './move-to-new-droppable'; +// 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'; type Args = {| isMovingForward: boolean, @@ -31,6 +33,7 @@ type Args = {| previousImpact: DragImpact, // the current viewport viewport: Viewport, + onLift: OnLift, |}; export default ({ @@ -42,6 +45,7 @@ export default ({ droppables, previousImpact, viewport, + onLift, }: Args): ?PublicResult => { // not considering the container scroll changes as container scrolling cancels a keyboard drag @@ -64,11 +68,11 @@ export default ({ ); const moveRelativeTo: ?DraggableDimension = getClosestDraggable({ - axis: destination.axis, pageBorderBoxCenter: previousPageBorderBoxCenter, viewport, destination, insideDestination, + onLift, }); const impact: ?DragImpact = moveToNewDroppable({ @@ -80,6 +84,7 @@ export default ({ insideDestination, previousImpact, viewport, + onLift, }); if (!impact) { @@ -91,6 +96,7 @@ export default ({ draggable, droppable: destination, draggables, + onLift, }); const clientSelection: Position = getClientFromPageBorderBoxCenter({ diff --git a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-foreign-list.js b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js similarity index 51% rename from src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-foreign-list.js rename to src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js index da1b976576..04fe12564c 100644 --- a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-foreign-list.js +++ b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable.js @@ -2,7 +2,6 @@ import type { Position } from 'css-box-model'; import invariant from 'tiny-invariant'; import type { - Axis, DragImpact, DraggableDimension, DraggableDimensionMap, @@ -10,14 +9,17 @@ import type { Displacement, Viewport, DisplacedBy, -} from '../../../../types'; -import getDisplacedBy from '../../../get-displaced-by'; -import getDisplacement from '../../../get-displacement'; -import getDisplacementMap from '../../../get-displacement-map'; -import { noMovement } 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'; + OnLift, +} from '../../../types'; +import getDisplacedBy from '../../get-displaced-by'; +import getDisplacement from '../../get-displacement'; +import getDisplacementMap from '../../get-displacement-map'; +import { noMovement } 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 removeDraggableFromList from '../../remove-draggable-from-list'; +import isHomeOf from '../../droppable/is-home-of'; type Args = {| previousPageBorderBoxCenter: Position, @@ -28,6 +30,7 @@ type Args = {| destination: DroppableDimension, previousImpact: DragImpact, viewport: Viewport, + onLift: OnLift, |}; export default ({ @@ -39,39 +42,40 @@ export default ({ destination, previousImpact, viewport, + onLift, }: Args): ?DragImpact => { - const axis: Axis = destination.axis; + if (!moveRelativeTo) { + // Draggables available, but none are candidates for movement + if (insideDestination.length) { + return null; + } - // Moving to an empty list - // Could be invisible location - so need to check - if (!moveRelativeTo || !insideDestination.length) { + // Try move to top of empty list if it is visible const proposed: DragImpact = { movement: noMovement, - direction: axis.direction, destination: { droppableId: destination.descriptor.id, index: 0, }, merge: null, }; - const pageBorderBoxCenter: Position = getPageBorderBoxCenter({ + const proposedPageBorderBoxCenter: Position = getPageBorderBoxCenter({ impact: proposed, draggable, droppable: destination, draggables, + onLift, }); - // need to check as if room was already added - const withPlaceholder: DroppableDimension = addPlaceholder( - destination, - draggable.displaceBy, - draggables, - ); + // need to add room for a placeholder in a foreign list + const withPlaceholder: DroppableDimension = isHomeOf(draggable, destination) + ? destination + : addPlaceholder(destination, draggable, draggables); const isVisibleInNewLocation: boolean = isTotallyVisibleInNewLocation({ draggable, destination: withPlaceholder, - newPageBorderBoxCenter: pageBorderBoxCenter, + newPageBorderBoxCenter: proposedPageBorderBoxCenter, viewport: viewport.frame, // already taken into account by getPageBorderBoxCenter withDroppableDisplacement: false, @@ -81,34 +85,47 @@ export default ({ return isVisibleInNewLocation ? proposed : null; } - // Moving to a populated list - const targetIndex: number = insideDestination.indexOf(moveRelativeTo); - invariant(targetIndex !== -1, 'Cannot find draggable in foreign list'); - const isGoingBeforeTarget: boolean = Boolean( previousPageBorderBoxCenter[destination.axis.line] < moveRelativeTo.page.borderBox.center[destination.axis.line], ); - const proposedIndex: number = isGoingBeforeTarget - ? targetIndex - : targetIndex + 1; + // Moving to a populated list + const targetIndex: number = insideDestination.indexOf(moveRelativeTo); + invariant(targetIndex !== -1, 'Cannot find target in list'); + + const proposedIndex: number = (() => { + // TODO: is this logic correct? + if (moveRelativeTo.descriptor.id === draggable.descriptor.id) { + return targetIndex; + } - const displaced: Displacement[] = insideDestination.slice(proposedIndex).map( - (dimension: DraggableDimension): Displacement => - getDisplacement({ - draggable: dimension, - destination, - viewport: viewport.frame, - previousImpact, - }), - ); + if (isGoingBeforeTarget) { + return targetIndex; + } + + return targetIndex + 1; + })(); + + const displaced: Displacement[] = removeDraggableFromList( + draggable, + insideDestination, + ) + .slice(proposedIndex) + .map( + (dimension: DraggableDimension): Displacement => + getDisplacement({ + draggable: dimension, + destination, + viewport: viewport.frame, + previousImpact, + onLift, + }), + ); - const willDisplaceForward: boolean = true; const displacedBy: DisplacedBy = getDisplacedBy( destination.axis, draggable.displaceBy, - willDisplaceForward, ); const impact: DragImpact = { @@ -116,9 +133,7 @@ export default ({ displacedBy, displaced, map: getDisplacementMap(displaced), - willDisplaceForward, }, - direction: axis.direction, destination: { droppableId: destination.descriptor.id, index: proposedIndex, diff --git a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/index.js b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/index.js deleted file mode 100644 index ac57f6200a..0000000000 --- a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/index.js +++ /dev/null @@ -1,79 +0,0 @@ -// @flow -import { type Position } from 'css-box-model'; -import invariant from 'tiny-invariant'; -import type { - DraggableDimension, - DroppableDimension, - DragImpact, - Viewport, - DraggableDimensionMap, -} from '../../../../types'; -import toHomeList from './to-home-list'; -import toForeignList from './to-foreign-list'; -import isHomeOf from '../../../droppable/is-home-of'; - -type Args = {| - // the current center position of the draggable - previousPageBorderBoxCenter: Position, - // the draggable that is dragging and needs to move - draggable: DraggableDimension, - // what the draggable is moving towards - // can be null if the destination is empty - moveRelativeTo: ?DraggableDimension, - // the droppable the draggable is moving to - destination: DroppableDimension, - // all the draggables inside the destination - insideDestination: DraggableDimension[], - // the impact of a previous drag, - previousImpact: DragImpact, - // the viewport - viewport: Viewport, - draggables: DraggableDimensionMap, -|}; - -export default ({ - previousPageBorderBoxCenter, - destination, - insideDestination, - draggable, - draggables, - moveRelativeTo, - previousImpact, - viewport, -}: Args): ?DragImpact => { - // Draggables available, but none are candidates for movement - // Cannot move into the list - // Note: can move to empty list and then !moveRelativeTo && !insideDestination.length - if (insideDestination.length && !moveRelativeTo) { - return null; - } - - if (moveRelativeTo) { - invariant( - moveRelativeTo.descriptor.droppableId === destination.descriptor.id, - 'Unable to find target in destination droppable', - ); - } - - const isMovingToHome: boolean = isHomeOf(draggable, destination); - - return isMovingToHome - ? toHomeList({ - moveIntoIndexOf: moveRelativeTo, - insideDestination, - draggable, - destination, - previousImpact, - viewport, - }) - : toForeignList({ - previousPageBorderBoxCenter, - moveRelativeTo, - insideDestination, - draggable, - draggables, - destination, - previousImpact, - viewport, - }); -}; diff --git a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-home-list.js b/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-home-list.js deleted file mode 100644 index a0b43b294d..0000000000 --- a/src/state/move-in-direction/move-cross-axis/move-to-new-droppable/to-home-list.js +++ /dev/null @@ -1,102 +0,0 @@ -// @flow -import invariant from 'tiny-invariant'; -import getDisplacement from '../../../get-displacement'; -import getDisplacementMap from '../../../get-displacement-map'; -import getDisplacedBy from '../../../get-displaced-by'; -import getWillDisplaceForward from '../../../will-displace-forward'; -import getHomeImpact from '../../../get-home-impact'; -import type { - Axis, - Viewport, - Displacement, - DragImpact, - DraggableDimension, - DroppableDimension, - DisplacedBy, -} from '../../../../types'; - -type Args = {| - moveIntoIndexOf: ?DraggableDimension, - insideDestination: DraggableDimension[], - draggable: DraggableDimension, - destination: DroppableDimension, - previousImpact: DragImpact, - viewport: Viewport, -|}; - -export default ({ - moveIntoIndexOf, - insideDestination, - draggable, - destination, - previousImpact, - viewport, -}: Args): ?DragImpact => { - // this can happen when the position is not visible - if (!moveIntoIndexOf) { - return null; - } - - const axis: Axis = destination.axis; - const homeIndex: number = draggable.descriptor.index; - const targetIndex: number = moveIntoIndexOf.descriptor.index; - - // Moving back home - if (homeIndex === targetIndex) { - return getHomeImpact(draggable, destination); - } - - const willDisplaceForward: boolean = getWillDisplaceForward({ - isInHomeList: true, - proposedIndex: targetIndex, - startIndexInHome: homeIndex, - }); - - const isMovingAfterStart: boolean = !willDisplaceForward; - // Which draggables will need to move? - // Everything between the target index and the start index - const modified: DraggableDimension[] = isMovingAfterStart - ? // we will be displacing these items backwards - // homeIndex + 1 so we don't include the home - // .reverse() so the closest displaced will be first - insideDestination.slice(homeIndex + 1, targetIndex + 1).reverse() - : insideDestination.slice(targetIndex, homeIndex); - - const displaced: Displacement[] = modified.map( - (dimension: DraggableDimension): Displacement => - getDisplacement({ - draggable: dimension, - destination, - previousImpact, - viewport: viewport.frame, - }), - ); - - invariant( - displaced.length, - 'Must displace as least one thing if not moving into the home index', - ); - - const displacedBy: DisplacedBy = getDisplacedBy( - destination.axis, - draggable.displaceBy, - willDisplaceForward, - ); - - const impact: DragImpact = { - movement: { - displacedBy, - displaced, - map: getDisplacementMap(displaced), - willDisplaceForward, - }, - direction: axis.direction, - destination: { - droppableId: destination.descriptor.id, - index: targetIndex, - }, - merge: null, - }; - - return impact; -}; diff --git a/src/state/move-in-direction/move-cross-axis/without-starting-displacement.js b/src/state/move-in-direction/move-cross-axis/without-starting-displacement.js new file mode 100644 index 0000000000..627e8fb707 --- /dev/null +++ b/src/state/move-in-direction/move-cross-axis/without-starting-displacement.js @@ -0,0 +1,31 @@ +// @flow +import type { Position, Rect, Spacing } from 'css-box-model'; +import type { DraggableDimension, OnLift } from '../../../types'; +import { negate, subtract } from '../../position'; +import { offsetByPosition } from '../../spacing'; +import didStartDisplaced from '../../starting-displaced/did-start-displaced'; + +export const getCurrentPageBorderBoxCenter = ( + draggable: DraggableDimension, + onLift: OnLift, +): Position => { + // If an item started displaced it is now resting + // in a non-displaced location + const original: Position = draggable.page.borderBox.center; + return didStartDisplaced(draggable.descriptor.id, onLift) + ? subtract(original, onLift.displacedBy.point) + : original; +}; + +export const getCurrentPageBorderBox = ( + draggable: DraggableDimension, + onLift: OnLift, +): Spacing => { + // If an item started displaced it is now resting + // in a non-displaced location + const original: Rect = draggable.page.borderBox; + + return didStartDisplaced(draggable.descriptor.id, onLift) + ? offsetByPosition(original, negate(onLift.displacedBy.point)) + : original; +}; diff --git a/src/state/move-in-direction/move-to-next-place/index.js b/src/state/move-in-direction/move-to-next-place/index.js index 8fd1783f91..44681e7bbe 100644 --- a/src/state/move-in-direction/move-to-next-place/index.js +++ b/src/state/move-in-direction/move-to-next-place/index.js @@ -6,6 +6,7 @@ import type { DraggableDimensionMap, DragImpact, Viewport, + OnLift, } from '../../../types'; import type { PublicResult } from '../move-in-direction-types'; import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable'; @@ -27,6 +28,7 @@ type Args = {| viewport: Viewport, previousClientSelection: Position, previousPageBorderBoxCenter: Position, + onLift: OnLift, |}; export default ({ @@ -38,6 +40,7 @@ export default ({ viewport, previousPageBorderBoxCenter, previousClientSelection, + onLift, }: Args): ?PublicResult => { if (!destination.isEnabled) { return null; @@ -66,6 +69,7 @@ export default ({ destination, insideDestination, previousImpact, + onLift, }); if (!impact) { @@ -77,6 +81,7 @@ export default ({ draggable, droppable: destination, draggables, + onLift, }); const isVisibleInNewLocation: boolean = isTotallyVisibleInNewLocation({ @@ -117,6 +122,7 @@ export default ({ destination, draggables, maxScrollChange: distance, + onLift, }); return { diff --git a/src/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js b/src/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js index ae2a95aa6b..0e401b0ddb 100644 --- a/src/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js +++ b/src/state/move-in-direction/move-to-next-place/is-totally-visible-in-new-location.js @@ -31,11 +31,14 @@ export default ({ // 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 diff: Position = subtract( + const changeNeeded: Position = subtract( newPageBorderBoxCenter, draggable.page.borderBox.center, ); - const shifted: Spacing = offsetByPosition(draggable.page.borderBox, diff); + const shifted: Spacing = offsetByPosition( + draggable.page.borderBox, + changeNeeded, + ); // Must be totally visible, not just partially visible. const args: IsVisibleArgs = { @@ -45,9 +48,5 @@ export default ({ viewport, }; - if (onlyOnMainAxis) { - return isTotallyVisibleOnAxis(args); - } - - return isTotallyVisible(args); + return onlyOnMainAxis ? isTotallyVisibleOnAxis(args) : isTotallyVisible(args); }; diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js b/src/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js index f4c0bd13ad..0a0faed8f0 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-combine/index.js @@ -46,8 +46,8 @@ export default ({ const currentIndex: number = location.index; - // update the insideDestination list to reflect the current - // list order + // update the insideDestination list to reflect the current list order + // TODO: cleanup const currentInsideDestination: DraggableDimension[] = (() => { const shallow = originalInsideDestination.slice(); @@ -75,7 +75,9 @@ export default ({ return null; } + // TODO: what if target is original!? const target: DraggableDimension = currentInsideDestination[targetIndex]; + invariant(target !== draggable, 'Cannot combine with self'); const merge: CombineImpact = { whenEntered: isMovingForward ? forward : backward, @@ -90,7 +92,6 @@ export default ({ movement: previousImpact.movement, // grouping removes the destination destination: null, - direction: destination.axis.direction, merge, }; diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js index bdf54c6548..0d77b9609f 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-combine.js @@ -1,5 +1,4 @@ // @flow -import getWillDisplaceForward from '../../../will-displace-forward'; import type { DroppableDimension, DragImpact, @@ -8,27 +7,27 @@ import type { DraggableDimensionMap, DraggableId, DragMovement, + OnLift, } from '../../../../types'; import type { Instruction } from './move-to-next-index-types'; +import didStartDisplaced from '../../../starting-displaced/did-start-displaced'; type Args = {| - isInHomeList: boolean, isMovingForward: boolean, - draggable: DraggableDimension, destination: DroppableDimension, previousImpact: DragImpact, draggables: DraggableDimensionMap, merge: CombineImpact, + onLift: OnLift, |}; export default ({ - isInHomeList, isMovingForward, - draggable, destination, previousImpact, draggables, merge, + onLift, }: Args): ?Instruction => { if (!destination.isCombineEnabled) { return null; @@ -38,92 +37,63 @@ export default ({ const combineId: DraggableId = merge.combine.draggableId; const combine: DraggableDimension = draggables[combineId]; const combineIndex: number = combine.descriptor.index; - const isCombineDisplaced: boolean = Boolean(movement.map[combineId]); + const wasDisplacedAtStart: boolean = didStartDisplaced(combineId, onLift); - // moving from an item that is not displaced - if (!isCombineDisplaced) { - // Need to know if targeting the combined item would normally displace forward - const willDisplaceForward: boolean = getWillDisplaceForward({ - isInHomeList, - proposedIndex: combineIndex, - startIndexInHome: draggable.descriptor.index, - }); - - if (willDisplaceForward) { - // will displace forwards (eg home list moving backward from start) - // moving forward will decrease displacement - // moving backward will increase displacement + if (wasDisplacedAtStart) { + const hasDisplacedFromStart: boolean = !movement.map[combineId]; + if (hasDisplacedFromStart) { if (isMovingForward) { - // we skip displacement when we move past a displaced item return { - proposedIndex: combineIndex + 1, + proposedIndex: combineIndex, modifyDisplacement: false, }; } + return { - proposedIndex: combineIndex, + proposedIndex: combineIndex - 1, modifyDisplacement: true, }; } - // will displace backwards (eg home list moving forward from start) - // moving forward will increase displacement - // moving backward will decrease displacement - + // move into position of combine if (isMovingForward) { - // we are moving into the visual spot of the combine item - // and pushing it backwards return { proposedIndex: combineIndex, modifyDisplacement: true, }; } - // we are moving behind the displaced item and leaving it in place + return { proposedIndex: combineIndex - 1, modifyDisplacement: false, }; } - // moving from an item that is already displaced - const isDisplacedForward: boolean = movement.willDisplaceForward; - const visualIndex: number = isDisplacedForward - ? combineIndex + 1 - : combineIndex - 1; + const isDisplaced: boolean = Boolean(movement.map[combineId]); - if (isDisplacedForward) { - // if displaced forward, then moving forward will undo the displacement + if (isDisplaced) { if (isMovingForward) { return { - proposedIndex: visualIndex, + proposedIndex: combineIndex + 1, modifyDisplacement: true, }; } - // if moving backwards, will move in front of the displaced item - // want to leave the displaced item in place return { - proposedIndex: visualIndex - 1, + proposedIndex: combineIndex, modifyDisplacement: false, }; } - // is displaced backwards - // moving forward will increase the displacement - // moving backward will decrease the displacement - if (isMovingForward) { - // we are moving forwards off the backwards displaced item, leaving it displaced return { - proposedIndex: visualIndex + 1, + proposedIndex: combineIndex + 1, modifyDisplacement: false, }; } - // we are moving backwards into the visual spot that the displaced item is occupying - // this will undo the displacement of the item return { - proposedIndex: visualIndex, + proposedIndex: combineIndex, modifyDisplacement: true, }; }; diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js index b005db013c..cc343ee51b 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/from-reorder.js @@ -1,33 +1,22 @@ // @flow -import invariant from 'tiny-invariant'; -import type { - DraggableDimension, - DragImpact, - DraggableLocation, -} from '../../../../types'; +import type { DraggableDimension, DraggableLocation } from '../../../../types'; import type { Instruction } from './move-to-next-index-types'; type Args = {| isMovingForward: boolean, isInHomeList: boolean, + location: DraggableLocation, draggable: DraggableDimension, insideDestination: DraggableDimension[], - previousImpact: DragImpact, |}; export default ({ isMovingForward, isInHomeList, - previousImpact, draggable, insideDestination: initialInside, + location, }: Args): ?Instruction => { - if (previousImpact.merge) { - return null; - } - const location: ?DraggableLocation = previousImpact.destination; - invariant(location, 'Cannot move to next index without previous destination'); - const insideDestination: DraggableDimension[] = initialInside.slice(); const currentIndex: number = location.index; const isInForeignList: boolean = !isInHomeList; diff --git a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js index 0daa485f17..91db020a78 100644 --- a/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js +++ b/src/state/move-in-direction/move-to-next-place/move-to-next-index/index.js @@ -7,40 +7,15 @@ import type { DragImpact, DisplacedBy, Displacement, + OnLift, } from '../../../../types'; import type { Instruction } from './move-to-next-index-types'; import getDisplacementMap from '../../../get-displacement-map'; import { addClosest, removeClosest } from '../update-displacement'; -import getWillDisplaceForward from '../../../will-displace-forward'; import getDisplacedBy from '../../../get-displaced-by'; import fromReorder from './from-reorder'; import fromCombine from './from-combine'; - -type IsIncreasingDisplacementArgs = {| - isInHomeList: boolean, - isMovingForward: boolean, - proposedIndex: number, - startIndexInHome: number, -|}; - -const getIsIncreasingDisplacement = ({ - isInHomeList, - isMovingForward, - proposedIndex, - startIndexInHome, -}: IsIncreasingDisplacementArgs): boolean => { - // in foreign list moving forward will reduce the amount displaced - if (!isInHomeList) { - return !isMovingForward; - } - - // increase displacement if moving forward past start - if (isMovingForward) { - return proposedIndex > startIndexInHome; - } - // increase displacement if moving backwards away from start - return proposedIndex < startIndexInHome; -}; +import removeDraggableFromList from '../../../remove-draggable-from-list'; export type Args = {| isMovingForward: boolean, @@ -50,6 +25,7 @@ export type Args = {| destination: DroppableDimension, insideDestination: DraggableDimension[], previousImpact: DragImpact, + onLift: OnLift, |}; export default ({ @@ -60,32 +36,34 @@ export default ({ destination, insideDestination, previousImpact, + onLift, }: Args): ?DragImpact => { const instruction: ?Instruction = (() => { + // moving from reorder if (previousImpact.destination) { return fromReorder({ isMovingForward, isInHomeList, draggable, - previousImpact, + location: previousImpact.destination, insideDestination, }); } - invariant( - previousImpact.merge, - 'Cannot move to next spot without a destination or merge', - ); + // moving from merge + if (previousImpact.merge) { + return fromCombine({ + isMovingForward, + destination, + previousImpact, + draggables, + merge: previousImpact.merge, + onLift, + }); + } - return fromCombine({ - isInHomeList, - isMovingForward, - draggable, - destination, - previousImpact, - draggables, - merge: previousImpact.merge, - }); + invariant('Cannot move to next spot without a destination or merge'); + return null; })(); if (instruction == null) { @@ -93,46 +71,39 @@ export default ({ } const { proposedIndex, modifyDisplacement } = instruction; - const startIndexInHome: number = draggable.descriptor.index; - const willDisplaceForward: boolean = getWillDisplaceForward({ - isInHomeList, - proposedIndex, - startIndexInHome, - }); const displacedBy: DisplacedBy = getDisplacedBy( destination.axis, draggable.displaceBy, - willDisplaceForward, ); - const atProposedIndex: DraggableDimension = insideDestination[proposedIndex]; - const displaced: Displacement[] = (() => { + const lastDisplaced: Displacement[] = previousImpact.movement.displaced; + if (!modifyDisplacement) { - return previousImpact.movement.displaced; + return lastDisplaced; + } + + if (isMovingForward) { + return removeClosest(lastDisplaced); } - const isIncreasingDisplacement: boolean = getIsIncreasingDisplacement({ - isInHomeList, - isMovingForward, - proposedIndex, - startIndexInHome, - }); + // moving backwards - will increase the amount of displaced items - const lastDisplaced: Displacement[] = previousImpact.movement.displaced; - return isIncreasingDisplacement - ? addClosest(atProposedIndex, lastDisplaced) - : removeClosest(lastDisplaced); + const withoutDraggable: DraggableDimension[] = removeDraggableFromList( + draggable, + insideDestination, + ); + + const atProposedIndex: DraggableDimension = withoutDraggable[proposedIndex]; + return addClosest(atProposedIndex, lastDisplaced); })(); return { movement: { displacedBy, - willDisplaceForward, displaced, map: getDisplacementMap(displaced), }, - direction: destination.axis.direction, destination: { droppableId: destination.descriptor.id, index: proposedIndex, diff --git a/src/state/no-impact.js b/src/state/no-impact.js index 8a1fb63452..57e6075c58 100644 --- a/src/state/no-impact.js +++ b/src/state/no-impact.js @@ -11,12 +11,10 @@ export const noMovement: DragMovement = { displaced: [], map: {}, displacedBy: noDisplacedBy, - willDisplaceForward: false, }; const noImpact: DragImpact = { movement: noMovement, - direction: null, destination: null, merge: null, }; diff --git a/src/state/patch-dimension-map.js b/src/state/patch-dimension-map.js new file mode 100644 index 0000000000..400be1b60c --- /dev/null +++ b/src/state/patch-dimension-map.js @@ -0,0 +1,11 @@ +// @flow +import type { DimensionMap, DroppableDimension } from '../types'; +import patchDroppableMap from './patch-droppable-map'; + +export default ( + dimensions: DimensionMap, + updated: DroppableDimension, +): DimensionMap => ({ + draggables: dimensions.draggables, + droppables: patchDroppableMap(dimensions.droppables, updated), +}); diff --git a/src/state/patch-droppable-map.js b/src/state/patch-droppable-map.js index 5324dce9eb..2b8412a3b4 100644 --- a/src/state/patch-droppable-map.js +++ b/src/state/patch-droppable-map.js @@ -1,13 +1,10 @@ // @flow -import type { DroppableDimension, DimensionMap } from '../types'; +import type { DroppableDimension, DroppableDimensionMap } from '../types'; export default ( - dimensions: DimensionMap, + droppables: DroppableDimensionMap, updated: DroppableDimension, -): DimensionMap => ({ - ...dimensions, - droppables: { - ...dimensions.droppables, - [updated.descriptor.id]: updated, - }, +): DroppableDimensionMap => ({ + ...droppables, + [updated.descriptor.id]: updated, }); diff --git a/src/state/post-reducer/when-moving/refresh-snap.js b/src/state/post-reducer/when-moving/refresh-snap.js index 4d74654697..f4d7325db4 100644 --- a/src/state/post-reducer/when-moving/refresh-snap.js +++ b/src/state/post-reducer/when-moving/refresh-snap.js @@ -44,6 +44,7 @@ Args): StateWhenUpdatesAllowed => { viewport, destination, draggables, + onLift: state.onLift, }); const clientSelection: Position = getClientBorderBoxCenter({ @@ -52,6 +53,7 @@ Args): StateWhenUpdatesAllowed => { droppable: destination, draggables, viewport, + onLift: state.onLift, }); return update({ diff --git a/src/state/post-reducer/when-moving/update.js b/src/state/post-reducer/when-moving/update.js index 68b336d14f..62d8bd742c 100644 --- a/src/state/post-reducer/when-moving/update.js +++ b/src/state/post-reducer/when-moving/update.js @@ -1,9 +1,5 @@ // @flow import type { Position } from 'css-box-model'; -import getDragImpact from '../../get-drag-impact'; -import { add, subtract } from '../../position'; -import getDimensionMapWithPlaceholder from '../../get-dimension-map-with-placeholder'; -import getUserDirection from '../../user-direction/get-user-direction'; import type { DraggableDimension, DraggingState, @@ -15,7 +11,12 @@ import type { DimensionMap, UserDirection, StateWhenUpdatesAllowed, + DroppableDimensionMap, } from '../../../types'; +import getDragImpact from '../../get-drag-impact'; +import { add, subtract } from '../../position'; +import getUserDirection from '../../user-direction/get-user-direction'; +import recomputePlaceholders from '../../recompute-placeholders'; type Args = {| state: StateWhenUpdatesAllowed, @@ -98,21 +99,25 @@ export default ({ previousImpact: state.impact, viewport, userDirection, + onLift: state.onLift, }); - const withUpdatedPlaceholders: DimensionMap = getDimensionMapWithPlaceholder({ + const withUpdatedPlaceholders: DroppableDimensionMap = recomputePlaceholders({ draggable, impact: newImpact, previousImpact: state.impact, - dimensions, + draggables: dimensions.draggables, + droppables: dimensions.droppables, }); - // dragging! const result: DraggingState = { ...state, current, userDirection, - dimensions: withUpdatedPlaceholders, + dimensions: { + draggables: dimensions.draggables, + droppables: withUpdatedPlaceholders, + }, impact: newImpact, viewport, scrollJumpRequest: scrollJumpRequest || null, diff --git a/src/state/publish-while-dragging/index.js b/src/state/publish-while-dragging/index.js index 1a9031f227..5e182ec9dd 100644 --- a/src/state/publish-while-dragging/index.js +++ b/src/state/publish-while-dragging/index.js @@ -1,28 +1,26 @@ // @flow import invariant from 'tiny-invariant'; import type { - DragImpact, DimensionMap, DraggingState, CollectingState, DropPendingState, Published, Critical, - DraggableId, DraggableDimension, - DroppableDimension, DraggableDimensionMap, + DroppableDimensionMap, + DragImpact, } from '../../types'; import * as timings from '../../debug/timings'; +import whatIsDraggedOver from '../droppable/what-is-dragged-over'; import getDragImpact from '../get-drag-impact'; +import getHomeOnLift from '../get-home-on-lift'; import getDragPositions from './get-drag-positions'; -import adjustModifiedDroppables from './adjust-modified-droppables'; -import adjustAdditionsForScrollChanges from './adjust-additions-for-scroll-changes'; -import getDraggableMap from './get-draggable-map'; +import updateDraggables from './update-draggables'; +import updateDroppables from './update-droppables'; import withNoAnimatedDisplacement from './with-no-animated-displacement'; -import { toDroppableMap } from '../dimension-structures'; -import noImpact from '../no-impact'; -import getDimensionMapWithPlaceholder from '../get-dimension-map-with-placeholder'; +import recomputePlaceholders from '../recompute-placeholders'; type Args = {| state: CollectingState | DropPendingState, @@ -39,61 +37,42 @@ export default ({ // Change the subject size and scroll of droppables // will remove any subject.withPlaceholder - const adjusted: DroppableDimension[] = adjustModifiedDroppables({ + const updatedDroppables: DroppableDimensionMap = updateDroppables({ modified: published.modified, - existingDroppables: state.dimensions.droppables, - initialWindowScroll: state.viewport.scroll.initial, + existing: state.dimensions.droppables, + viewport: state.viewport, }); - const shifted: DraggableDimension[] = adjustAdditionsForScrollChanges({ + const draggables: DraggableDimensionMap = updateDraggables({ + updatedDroppables, + // will not change during a drag + criticalId: state.critical.draggable.id, + existing: state.dimensions.draggables, additions: published.additions, - // using our already adjusted droppables as they have the correct scroll changes - modified: adjusted, + removals: published.removals, viewport: state.viewport, }); - const patched: DimensionMap = { - draggables: state.dimensions.draggables, - droppables: { - ...state.dimensions.droppables, - ...toDroppableMap(adjusted), - }, + const critical: Critical = { + draggable: draggables[state.critical.draggable.id].descriptor, + droppable: updatedDroppables[state.critical.droppable.id].descriptor, }; + const original: DraggableDimension = + state.dimensions.draggables[critical.draggable.id]; + const updated: DraggableDimension = draggables[critical.draggable.id]; - // Add, remove and shift draggables - const draggables: DraggableDimensionMap = getDraggableMap({ - existing: patched, - additions: shifted, - removals: published.removals, - initialWindowScroll: state.viewport.scroll.initial, - }); - - // const droppables: DroppableDimensionMap = reapplyPlaceholder({ - // wasOver: whatIsDraggedOver(state.impact), - // previous: state.dimensions.droppables, - // proposed: patched.droppables, - // draggables, - // }); - - const dragging: DraggableId = state.critical.draggable.id; - const original: DraggableDimension = state.dimensions.draggables[dragging]; - const updated: DraggableDimension = draggables[dragging]; - - const dimensions: DimensionMap = getDimensionMapWithPlaceholder({ + // TODO: this is a bit of a chicken and egg problem, but it will use the old impact for placeholders + const droppables: DroppableDimensionMap = recomputePlaceholders({ + draggable: updated, + draggables, + droppables: updatedDroppables, previousImpact: state.impact, impact: state.impact, - draggable: updated, - dimensions: { - draggables, - droppables: patched.droppables, - }, }); - const critical: Critical = { - // droppable cannot change during a drag - droppable: state.critical.droppable, - // draggable index can change during a drag - draggable: updated.descriptor, + const dimensions: DimensionMap = { + draggables, + droppables, }; // Get the updated drag positions to account for any @@ -108,29 +87,35 @@ export default ({ // Get the impact of all of our changes // this could result in a strange snap placement (will be fixed on next move) + const { impact: homeImpact, onLift } = getHomeOnLift({ + draggable: updated, + home: dimensions.droppables[critical.droppable.id], + draggables: dimensions.draggables, + viewport: state.viewport, + }); + // now need to calculate the impact for the current pageBorderBoxCenter const impact: DragImpact = withNoAnimatedDisplacement( getDragImpact({ pageBorderBoxCenter: current.page.borderBoxCenter, - draggable: dimensions.draggables[state.critical.draggable.id], + draggable: updated, draggables: dimensions.draggables, droppables: dimensions.droppables, // starting from a fresh slate - previousImpact: noImpact, + previousImpact: homeImpact, viewport: state.viewport, userDirection: state.userDirection, + onLift, }), ); const isOrphaned: boolean = Boolean( - state.movementMode === 'SNAP' && - state.impact.destination && - !impact.destination, + state.movementMode === 'SNAP' && !whatIsDraggedOver(impact), ); // TODO: try and recover? invariant( !isOrphaned, - 'Dragging item no longer has a valid destination after a dynamic update. This is not supported', + 'Dragging item no longer has a valid merge/destination after a dynamic update. This is not supported', ); // TODO: move into move visually pleasing position if using JUMP auto scrolling @@ -148,6 +133,8 @@ export default ({ initial, impact, dimensions, + onLift, + onLiftImpact: homeImpact, // not animating this movement forceShouldAnimate: false, }; diff --git a/src/state/publish-while-dragging/update-draggables/adjust-additions-for-collapsed-home.js b/src/state/publish-while-dragging/update-draggables/adjust-additions-for-collapsed-home.js new file mode 100644 index 0000000000..a4951b9686 --- /dev/null +++ b/src/state/publish-while-dragging/update-draggables/adjust-additions-for-collapsed-home.js @@ -0,0 +1,51 @@ +// @flow +import type { + Viewport, + DraggableDimension, + DroppableDimension, + DisplacedBy, +} from '../../../types'; +import getDisplacedBy from '../../get-displaced-by'; +import offsetDraggable from './offset-draggable'; + +type Args = {| + additions: DraggableDimension[], + dragging: DraggableDimension, + home: DroppableDimension, + viewport: Viewport, +|}; + +export default ({ + additions, + dragging, + home, + viewport, +}: Args): DraggableDimension[] => { + const displacedBy: DisplacedBy = getDisplacedBy( + home.axis, + dragging.displaceBy, + ); + + return additions.map( + (draggable: DraggableDimension): DraggableDimension => { + // not in the home list, nothing to worry about there + if (draggable.descriptor.droppableId !== home.descriptor.id) { + return draggable; + } + + // appears before the draggable - no need to shift + if (draggable.descriptor.index < dragging.descriptor.index) { + return draggable; + } + + // item occurs after dragging item + // need to shift it to account for collapsed home item + + return offsetDraggable({ + draggable, + offset: displacedBy.point, + initialWindowScroll: viewport.scroll.initial, + }); + }, + ); +}; diff --git a/src/state/publish-while-dragging/adjust-additions-for-scroll-changes.js b/src/state/publish-while-dragging/update-draggables/adjust-additions-for-scroll-changes.js similarity index 66% rename from src/state/publish-while-dragging/adjust-additions-for-scroll-changes.js rename to src/state/publish-while-dragging/update-draggables/adjust-additions-for-scroll-changes.js index 751efacfaa..819219e8bb 100644 --- a/src/state/publish-while-dragging/adjust-additions-for-scroll-changes.js +++ b/src/state/publish-while-dragging/update-draggables/adjust-additions-for-scroll-changes.js @@ -1,13 +1,6 @@ // @flow import invariant from 'tiny-invariant'; -import { - offset, - withScroll, - type Position, - type BoxModel, -} from 'css-box-model'; -import { add } from '../position'; -import { toDroppableMap } from '../dimension-structures'; +import { type Position } from 'css-box-model'; import type { Viewport, DraggableDimension, @@ -15,17 +8,19 @@ import type { Scrollable, DroppableDimensionMap, DroppableId, -} from '../../types'; +} from '../../../types'; +import { add, isEqual, origin } from '../../position'; +import offsetDraggable from './offset-draggable'; type Args = {| additions: DraggableDimension[], - modified: DroppableDimension[], + updatedDroppables: DroppableDimensionMap, viewport: Viewport, |}; export default ({ additions, - modified: modifiedDroppables, + updatedDroppables, viewport, }: Args): DraggableDimension[] => { // We need to adjust collected draggables so that they @@ -40,12 +35,11 @@ export default ({ // Need to undo the displacement caused by window scroll changes const windowScrollChange: Position = viewport.scroll.diff.value; // These modified droppables have already had their scroll changes correctly updated - const modifiedMap: DroppableDimensionMap = toDroppableMap(modifiedDroppables); return additions.map( (draggable: DraggableDimension): DraggableDimension => { const droppableId: DroppableId = draggable.descriptor.droppableId; - const modified: DroppableDimension = modifiedMap[droppableId]; + const modified: DroppableDimension = updatedDroppables[droppableId]; const frame: ?Scrollable = modified.frame; invariant(frame); @@ -56,18 +50,12 @@ export default ({ windowScrollChange, droppableScrollChange, ); - const client: BoxModel = offset(draggable.client, totalChange); - const page: BoxModel = withScroll(client, viewport.scroll.initial); - const moved: DraggableDimension = { - ...draggable, - placeholder: { - ...draggable.placeholder, - client, - }, - client, - page, - }; + const moved: DraggableDimension = offsetDraggable({ + draggable, + offset: totalChange, + initialWindowScroll: viewport.scroll.initial, + }); return moved; }, diff --git a/src/state/publish-while-dragging/get-draggable-map.js b/src/state/publish-while-dragging/update-draggables/adjust-existing-for-additions-and-removals.js similarity index 74% rename from src/state/publish-while-dragging/get-draggable-map.js rename to src/state/publish-while-dragging/update-draggables/adjust-existing-for-additions-and-removals.js index 5947f3834b..e698ecb8d1 100644 --- a/src/state/publish-while-dragging/get-draggable-map.js +++ b/src/state/publish-while-dragging/update-draggables/adjust-existing-for-additions-and-removals.js @@ -1,27 +1,26 @@ // @flow -import { - offset as offsetBox, - withScroll, - type BoxModel, - type Position, -} from 'css-box-model'; +import invariant from 'tiny-invariant'; +import { type Position } from 'css-box-model'; import type { Axis, - DimensionMap, + DroppableDimensionMap, DraggableId, DroppableDimension, DraggableDimension, DraggableDimensionMap, -} from '../../types'; -import { toDraggableMap, toDroppableList } from '../dimension-structures'; -import { patch, add, negate } from '../position'; -import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; + Viewport, +} from '../../../types'; +import { toDraggableMap, toDroppableList } from '../../dimension-structures'; +import { patch, add, negate } from '../../position'; +import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable'; +import offsetDraggable from './offset-draggable'; type Args = {| - existing: DimensionMap, + droppables: DroppableDimensionMap, + existing: DraggableDimensionMap, additions: DraggableDimension[], removals: DraggableId[], - initialWindowScroll: Position, + viewport: Viewport, |}; type Shift = {| @@ -35,20 +34,19 @@ type ShiftMap = { export default ({ existing, + droppables, additions: addedDraggables, removals: removedDraggables, - initialWindowScroll, + viewport, }: Args): DraggableDimensionMap => { - const droppables: DroppableDimension[] = toDroppableList(existing.droppables); - const shifted: DraggableDimensionMap = {}; - droppables.forEach((droppable: DroppableDimension) => { + toDroppableList(droppables).forEach((droppable: DroppableDimension) => { const axis: Axis = droppable.axis; const original: DraggableDimension[] = getDraggablesInsideDroppable( droppable.descriptor.id, - existing.draggables, + existing, ); const toShift: ShiftMap = {}; @@ -70,7 +68,13 @@ export default ({ // phase 1: removals const removals: DraggableDimensionMap = toDraggableMap( removedDraggables - .map((id: DraggableId): DraggableDimension => existing.draggables[id]) + .map( + (id: DraggableId): DraggableDimension => { + const item: ?DraggableDimension = existing[id]; + invariant(item, `Could not find removed draggable "${id}"`); + return item; + }, + ) // only care about the ones inside of this droppable .filter( (draggable: DraggableDimension): boolean => @@ -87,9 +91,9 @@ export default ({ return true; } - // moving backwards by size + // moving backwards by displacement const offset: Position = negate( - patch(axis.line, item.client.marginBox[axis.size]), + patch(axis.line, item.displaceBy[axis.line]), ); original.slice(index).forEach((sibling: DraggableDimension) => { @@ -110,7 +114,7 @@ export default ({ }, ); - // phase 2: additions + // Phase 2: additions // We do this on the withRemovals array as the new index coming in already account for removals const additions: DraggableDimension[] = addedDraggables.filter( @@ -167,41 +171,31 @@ export default ({ return; } - const client: BoxModel = offsetBox(item.client, shift.offset); - const page: BoxModel = withScroll(client, initialWindowScroll); - const index: number = item.descriptor.index + shift.indexChange; + const moved: DraggableDimension = offsetDraggable({ + draggable: item, + offset: shift.offset, + initialWindowScroll: viewport.scroll.initial, + }); - const moved: DraggableDimension = { - ...item, + const index: number = item.descriptor.index + shift.indexChange; + const updated: DraggableDimension = { + ...moved, descriptor: { ...item.descriptor, index, }, - placeholder: { - ...item.placeholder, - client, - }, - client, - page, }; // Add to big cache - shifted[moved.descriptor.id] = moved; + shifted[moved.descriptor.id] = updated; }); }); - const draggableMap: DraggableDimensionMap = { - ...existing.draggables, + const map: DraggableDimensionMap = { + ...existing, // will overwrite existing draggables with shifted values if required ...shifted, - // add the additions without modification - they are already in the right spot - ...toDraggableMap(addedDraggables), }; - // delete draggables that have been removed - removedDraggables.forEach((id: DraggableId) => { - delete draggableMap[id]; - }); - - return draggableMap; + return map; }; diff --git a/src/state/publish-while-dragging/update-draggables/index.js b/src/state/publish-while-dragging/update-draggables/index.js new file mode 100644 index 0000000000..e2ac2b6a52 --- /dev/null +++ b/src/state/publish-while-dragging/update-draggables/index.js @@ -0,0 +1,79 @@ +// @flow +import type { + DraggableId, + DroppableDimension, + DroppableDimensionMap, + DraggableDimensionMap, + DraggableDimension, + Viewport, +} from '../../../types'; +import adjustExistingForAdditionsAndRemovals from './adjust-existing-for-additions-and-removals'; +import adjustAdditionsForScrollChanges from './adjust-additions-for-scroll-changes'; +import adjustAdditionsForCollapsedHome from './adjust-additions-for-collapsed-home'; +import { toDraggableMap } from '../../dimension-structures'; + +type Args = {| + updatedDroppables: DroppableDimensionMap, + criticalId: DraggableId, + existing: DraggableDimensionMap, + additions: DraggableDimension[], + removals: DraggableId[], + viewport: Viewport, +|}; + +export default ({ + updatedDroppables, + criticalId, + existing: unmodifiedExisting, + additions: unmodifiedAdditions, + removals, + viewport, +}: Args): DraggableDimensionMap => { + // Phase 1: update existing draggables + const existing: DraggableDimensionMap = adjustExistingForAdditionsAndRemovals( + { + droppables: updatedDroppables, + existing: unmodifiedExisting, + additions: unmodifiedAdditions, + removals, + viewport, + }, + ); + + // Phase 2: update added draggables + + const dragging: DraggableDimension = existing[criticalId]; + const home: DroppableDimension = + updatedDroppables[dragging.descriptor.droppableId]; + + const scrolledAdditions: DraggableDimension[] = adjustAdditionsForScrollChanges( + { + additions: unmodifiedAdditions, + // using our already adjusted droppables as they have the correct scroll changes + updatedDroppables, + viewport, + }, + ); + + const additions: DraggableDimension[] = adjustAdditionsForCollapsedHome({ + additions: scrolledAdditions, + dragging, + home, + viewport, + }); + + const map: DraggableDimensionMap = { + ...existing, + ...toDraggableMap(additions), + }; + + // Phase 3: clear removed draggables + + removals.forEach((id: DraggableId) => { + delete map[id]; + }); + + return map; +}; + +// Adjust the added draggables diff --git a/src/state/publish-while-dragging/update-draggables/offset-draggable.js b/src/state/publish-while-dragging/update-draggables/offset-draggable.js new file mode 100644 index 0000000000..aebe0abde8 --- /dev/null +++ b/src/state/publish-while-dragging/update-draggables/offset-draggable.js @@ -0,0 +1,35 @@ +// @flow +import { + withScroll, + offset as offsetBox, + type Position, + type BoxModel, +} from 'css-box-model'; +import type { DraggableDimension } from '../../../types'; + +type Args = {| + draggable: DraggableDimension, + offset: Position, + initialWindowScroll: Position, +|}; + +export default ({ + draggable, + offset, + initialWindowScroll, +}: Args): DraggableDimension => { + const client: BoxModel = offsetBox(draggable.client, offset); + const page: BoxModel = withScroll(client, initialWindowScroll); + + const moved: DraggableDimension = { + ...draggable, + placeholder: { + ...draggable.placeholder, + client, + }, + client, + page, + }; + + return moved; +}; diff --git a/src/state/publish-while-dragging/adjust-modified-droppables.js b/src/state/publish-while-dragging/update-droppables/index.js similarity index 74% rename from src/state/publish-while-dragging/adjust-modified-droppables.js rename to src/state/publish-while-dragging/update-droppables/index.js index 3e21664cbd..02d27f8f0b 100644 --- a/src/state/publish-while-dragging/adjust-modified-droppables.js +++ b/src/state/publish-while-dragging/update-droppables/index.js @@ -3,24 +3,25 @@ import invariant from 'tiny-invariant'; import { withScroll, createBox, - type Position, type BoxModel, type Spacing, type Rect, } from 'css-box-model'; import getDroppableDimension, { type Closest, -} from '../droppable/get-droppable'; +} from '../../droppable/get-droppable'; import type { + Viewport, DroppableDimension, DroppableDimensionMap, Scrollable, Axis, -} from '../../types'; -import { isEqual } from '../spacing'; -import scrollDroppable from '../droppable/scroll-droppable'; -import { removePlaceholder } from '../droppable/with-placeholder'; -import getFrame from '../get-frame'; +} from '../../../types'; +import { isEqual } from '../../spacing'; +import scrollDroppable from '../../droppable/scroll-droppable'; +import { removePlaceholder } from '../../droppable/with-placeholder'; +import getFrame from '../../get-frame'; +import { toDroppableMap } from '../../dimension-structures'; const throwIfSpacingChange = (old: BoxModel, fresh: BoxModel) => { if (process.env.NODE_ENV !== 'production') { @@ -42,42 +43,43 @@ const adjustBorderBoxSize = (axis: Axis, old: Rect, fresh: Rect): Spacing => ({ }); type Args = {| + existing: DroppableDimensionMap, modified: DroppableDimension[], - existingDroppables: DroppableDimensionMap, - initialWindowScroll: Position, + viewport: Viewport, |}; export default ({ modified, - existingDroppables, - initialWindowScroll, -}: Args): DroppableDimension[] => { + existing, + viewport, +}: Args): DroppableDimensionMap => { // dynamically adjusting the client subject and page subject // of a droppable in response to dynamic additions and removals // No existing droppables modified if (!modified.length) { - return modified; + return existing; } const adjusted: DroppableDimension[] = modified.map( (provided: DroppableDimension): DroppableDimension => { - const raw: ?DroppableDimension = - existingDroppables[provided.descriptor.id]; + const raw: ?DroppableDimension = existing[provided.descriptor.id]; invariant(raw, 'Could not locate droppable in existing droppables'); - const existing: DroppableDimension = raw.subject.withPlaceholder + const hasPlaceholder: boolean = Boolean(raw.subject.withPlaceholder); + + const dimension: DroppableDimension = hasPlaceholder ? removePlaceholder(raw) : raw; - const oldClient: BoxModel = existing.client; + const oldClient: BoxModel = dimension.client; const newClient: BoxModel = provided.client; - const oldScrollable: Scrollable = getFrame(existing); + const oldScrollable: Scrollable = getFrame(dimension); const newScrollable: Scrollable = getFrame(provided); // Extra checks to help with development if (process.env.NODE_ENV !== 'production') { - throwIfSpacingChange(existing.client, provided.client); + throwIfSpacingChange(dimension.client, provided.client); throwIfSpacingChange( oldScrollable.frameClient, newScrollable.frameClient, @@ -97,7 +99,7 @@ export default ({ const client: BoxModel = createBox({ borderBox: adjustBorderBoxSize( - existing.axis, + dimension.axis, oldClient.borderBox, newClient.borderBox, ), @@ -109,7 +111,7 @@ export default ({ const closest: Closest = { // not allowing a change to the scrollable frame size during a drag client: oldScrollable.frameClient, - page: withScroll(oldScrollable.frameClient, initialWindowScroll), + page: withScroll(oldScrollable.frameClient, viewport.scroll.initial), shouldClipSubject: oldScrollable.shouldClipSubject, // the scroll size can change during a drag scrollSize: newScrollable.scrollSize, @@ -124,7 +126,7 @@ export default ({ isFixedOnPage: provided.isFixedOnPage, direction: provided.axis.direction, client, - page: withScroll(client, initialWindowScroll), + page: withScroll(client, viewport.scroll.initial), closest, }); @@ -138,5 +140,11 @@ export default ({ }, ); - return adjusted; + const result: DroppableDimensionMap = { + ...existing, + // will override any conflicts with existing + ...toDroppableMap(adjusted), + }; + + return result; }; diff --git a/src/state/publish-while-dragging/with-no-animated-displacement.js b/src/state/publish-while-dragging/with-no-animated-displacement.js index b573abf864..4f1471a6c4 100644 --- a/src/state/publish-while-dragging/with-no-animated-displacement.js +++ b/src/state/publish-while-dragging/with-no-animated-displacement.js @@ -11,6 +11,10 @@ export default (impact: DragImpact): DragImpact => { const withoutAnimation: Displacement[] = displaced.map( (displacement: Displacement): Displacement => { + if (!displacement.isVisible) { + return displacement; + } + // Already do not need to animate it - can return as is if (!displacement.shouldAnimate) { return displacement; diff --git a/src/state/get-dimension-map-with-placeholder.js b/src/state/recompute-placeholders.js similarity index 56% rename from src/state/get-dimension-map-with-placeholder.js rename to src/state/recompute-placeholders.js index a8e8d0b90f..2a597291b6 100644 --- a/src/state/get-dimension-map-with-placeholder.js +++ b/src/state/recompute-placeholders.js @@ -3,96 +3,97 @@ import { addPlaceholder, removePlaceholder, } from './droppable/with-placeholder'; -import shouldUsePlaceholder from './droppable/should-use-placeholder'; import whatIsDraggedOver from './droppable/what-is-dragged-over'; import type { DroppableDimension, - DimensionMap, + DraggableDimensionMap, + DroppableDimensionMap, DraggableDimension, DragImpact, DroppableId, } from '../types'; import patchDroppableMap from './patch-droppable-map'; +import isHomeOf from './droppable/is-home-of'; type ClearArgs = {| previousImpact: DragImpact, impact: DragImpact, - dimensions: DimensionMap, + droppables: DroppableDimensionMap, |}; const clearUnusedPlaceholder = ({ previousImpact, impact, - dimensions, -}: ClearArgs): DimensionMap => { + droppables, +}: ClearArgs): DroppableDimensionMap => { const last: ?DroppableId = whatIsDraggedOver(previousImpact); const now: ?DroppableId = whatIsDraggedOver(impact); if (!last) { - return dimensions; + return droppables; } // no change - can keep the last state if (last === now) { - return dimensions; + return droppables; } - const lastDroppable: DroppableDimension = dimensions.droppables[last]; + const lastDroppable: DroppableDimension = droppables[last]; // nothing to clear if (!lastDroppable.subject.withPlaceholder) { - return dimensions; + return droppables; } const updated: DroppableDimension = removePlaceholder(lastDroppable); - return patchDroppableMap(dimensions, updated); + return patchDroppableMap(droppables, updated); }; type Args = {| - dimensions: DimensionMap, draggable: DraggableDimension, + draggables: DraggableDimensionMap, + droppables: DroppableDimensionMap, impact: DragImpact, previousImpact: DragImpact, |}; export default ({ - dimensions, - previousImpact, draggable, + draggables, + droppables, + previousImpact, impact, -}: Args): DimensionMap => { - const base: DimensionMap = clearUnusedPlaceholder({ +}: Args): DroppableDimensionMap => { + const cleaned: DroppableDimensionMap = clearUnusedPlaceholder({ previousImpact, impact, - dimensions, + droppables, }); - const usePlaceholder: boolean = shouldUsePlaceholder( - draggable.descriptor, - impact, - ); + const isOver: ?DroppableId = whatIsDraggedOver(impact); - if (!usePlaceholder) { - return base; + if (!isOver) { + return cleaned; } - const droppableId: ?DroppableId = whatIsDraggedOver(impact); - if (!droppableId) { - return base; + const droppable: DroppableDimension = droppables[isOver]; + + // no need to add additional space to home droppable + if (isHomeOf(draggable, droppable)) { + return cleaned; } - const droppable: DroppableDimension = base.droppables[droppableId]; // already have a placeholder - nothing to do here! if (droppable.subject.withPlaceholder) { - return base; + return cleaned; } // Need to patch the existing droppable const patched: DroppableDimension = addPlaceholder( droppable, - draggable.displaceBy, - base.draggables, + draggable, + draggables, ); - return patchDroppableMap(base, patched); + return patchDroppableMap(cleaned, patched); }; diff --git a/src/state/reducer.js b/src/state/reducer.js index 1f84ff9e7b..0efb37e637 100644 --- a/src/state/reducer.js +++ b/src/state/reducer.js @@ -7,7 +7,6 @@ import type { StateWhenUpdatesAllowed, DraggableDimension, DroppableDimension, - PendingDrop, IdleState, DraggingState, DragPositions, @@ -25,13 +24,13 @@ import publishWhileDragging from './publish-while-dragging'; import moveInDirection from './move-in-direction'; import { add, isEqual, origin } from './position'; import scrollViewport from './scroll-viewport'; -import getHomeImpact from './get-home-impact'; import isMovementAllowed from './is-movement-allowed'; import { toDroppableList } from './dimension-structures'; import { forward } from './user-direction/user-direction-preset'; import update from './post-reducer/when-moving/update'; import refreshSnap from './post-reducer/when-moving/refresh-snap'; -import patchDroppableMap from './patch-droppable-map'; +import getHomeOnLift from './get-home-on-lift'; +import patchDimensionMap from './patch-dimension-map'; const isSnapping = (state: StateWhenUpdatesAllowed): boolean => state.movementMode === 'SNAP'; @@ -41,7 +40,7 @@ const postDroppableChange = ( updated: DroppableDimension, isEnabledChanging: boolean, ): StateWhenUpdatesAllowed => { - const dimensions: DimensionMap = patchDroppableMap(state.dimensions, updated); + const dimensions: DimensionMap = patchDimensionMap(state.dimensions, updated); // if the enabled state is changing, we need to force a update if (!isSnapping(state) || isEnabledChanging) { @@ -57,7 +56,7 @@ const postDroppableChange = ( }); }; -const idle: IdleState = { phase: 'IDLE' }; +const idle: IdleState = { phase: 'IDLE', completed: null, shouldFlush: false }; export default (state: State = idle, action: Action): State => { if (action.type === 'CLEAN') { @@ -101,6 +100,13 @@ export default (state: State = idle, action: Action): State => { dimensions.droppables, ).every((item: DroppableDimension) => !item.isFixedOnPage); + const { impact, onLift } = getHomeOnLift({ + draggable, + home, + draggables: dimensions.draggables, + viewport, + }); + const result: DraggingState = { phase: 'DRAGGING', isDragging: true, @@ -110,7 +116,9 @@ export default (state: State = idle, action: Action): State => { initial, current: initial, isWindowScrollAllowed, - impact: getHomeImpact(draggable, home), + impact, + onLift, + onLiftImpact: impact, viewport, userDirection: forward, scrollJumpRequest: null, @@ -319,12 +327,17 @@ export default (state: State = idle, action: Action): State => { } if (action.type === 'UPDATE_VIEWPORT_MAX_SCROLL') { - invariant( - isMovementAllowed(state), - `Cannot update viewport scroll in phase ${state.phase}`, - ); + // Could occur if a transitionEnd occurs after a drag ends + if (!isMovementAllowed(state)) { + return state; + } const maxScroll: Position = action.payload.maxScroll; + + if (isEqual(maxScroll, state.viewport.scroll.max)) { + return state; + } + const withMaxScroll: Viewport = { ...state.viewport, scroll: { @@ -395,7 +408,7 @@ export default (state: State = idle, action: Action): State => { } if (action.type === 'DROP_ANIMATE') { - const pending: PendingDrop = action.payload; + const { completed, dropDuration, newHomeClientOffset } = action.payload; invariant( state.phase === 'DRAGGING' || state.phase === 'DROP_PENDING', `Cannot animate drop from phase ${state.phase}`, @@ -404,8 +417,10 @@ export default (state: State = idle, action: Action): State => { // Moving into a new phase const result: DropAnimatingState = { phase: 'DROP_ANIMATING', - pending, dimensions: state.dimensions, + completed, + dropDuration, + newHomeClientOffset, }; return result; @@ -414,7 +429,13 @@ export default (state: State = idle, action: Action): State => { // Action will be used by responders to call consumers // We can simply return to the idle state if (action.type === 'DROP_COMPLETE') { - return idle; + const { completed, shouldFlush } = action.payload; + + return { + phase: 'IDLE', + completed, + shouldFlush, + }; } return state; diff --git a/src/state/remove-draggable-from-list.js b/src/state/remove-draggable-from-list.js new file mode 100644 index 0000000000..48ec0cda6d --- /dev/null +++ b/src/state/remove-draggable-from-list.js @@ -0,0 +1,13 @@ +// @flow +import memoizeOne from 'memoize-one'; +import type { DraggableDimension } from '../types'; + +export default memoizeOne( + ( + remove: DraggableDimension, + list: DraggableDimension[], + ): DraggableDimension[] => + list.filter( + (item: DraggableDimension) => item.descriptor.id !== remove.descriptor.id, + ), +); diff --git a/src/state/starting-displaced/did-start-displaced.js b/src/state/starting-displaced/did-start-displaced.js new file mode 100644 index 0000000000..c2cc9a8e4e --- /dev/null +++ b/src/state/starting-displaced/did-start-displaced.js @@ -0,0 +1,5 @@ +// @flow +import type { DraggableId, OnLift } from '../../types'; + +export default (draggableId: DraggableId, onLift: OnLift): boolean => + Boolean(onLift.wasDisplaced[draggableId]); diff --git a/src/state/update-displacement-visibility/recompute.js b/src/state/update-displacement-visibility/recompute.js index 2006ca2571..f15029ab8a 100644 --- a/src/state/update-displacement-visibility/recompute.js +++ b/src/state/update-displacement-visibility/recompute.js @@ -5,6 +5,7 @@ import type { DragImpact, Displacement, Viewport, + OnLift, } from '../../types'; import getDisplacement from '../get-displacement'; import withNewDisplacement from './with-new-displacement'; @@ -13,7 +14,9 @@ type RecomputeArgs = {| impact: DragImpact, destination: DroppableDimension, viewport: Viewport, + onLift: OnLift, draggables: DraggableDimensionMap, + forceShouldAnimate?: boolean, |}; export default ({ @@ -21,6 +24,8 @@ export default ({ viewport, destination, draggables, + onLift, + forceShouldAnimate, }: RecomputeArgs): DragImpact => { const updated: Displacement[] = impact.movement.displaced.map( (entry: Displacement) => @@ -29,6 +34,8 @@ export default ({ destination, previousImpact: impact, viewport: viewport.frame, + onLift, + forceShouldAnimate, }), ); diff --git a/src/state/update-displacement-visibility/speculatively-increase.js b/src/state/update-displacement-visibility/speculatively-increase.js index 9151e7d88f..33772e9884 100644 --- a/src/state/update-displacement-visibility/speculatively-increase.js +++ b/src/state/update-displacement-visibility/speculatively-increase.js @@ -2,10 +2,12 @@ import type { Position } from 'css-box-model'; import type { DroppableDimension, + DraggableDimension, DraggableDimensionMap, DragImpact, Displacement, Viewport, + OnLift, } from '../../types'; import scrollViewport from '../scroll-viewport'; import scrollDroppable from '../droppable/scroll-droppable'; @@ -19,6 +21,7 @@ type SpeculativeArgs = {| viewport: Viewport, draggables: DraggableDimensionMap, maxScrollChange: Position, + onLift: OnLift, |}; export default ({ @@ -27,6 +30,7 @@ export default ({ destination, draggables, maxScrollChange, + onLift, }: SpeculativeArgs): DragImpact => { const displaced: Displacement[] = impact.movement.displaced; @@ -42,28 +46,43 @@ export default ({ : destination; const updated: Displacement[] = displaced.map((entry: Displacement) => { + // already visible: do not need to speculatively increase if (entry.isVisible) { return entry; } - const result: Displacement = getDisplacement({ - draggable: draggables[entry.draggableId], - destination: scrolledDroppable, + const draggable: DraggableDimension = draggables[entry.draggableId]; + + // check if would be visibly displaced in a scrolled droppable or viewport + + const withScrolledViewport: Displacement = getDisplacement({ + draggable, + destination, previousImpact: impact, viewport: scrolledViewport.frame, + onLift, + forceShouldAnimate: false, }); - if (!result.isVisible) { - return entry; + if (withScrolledViewport.isVisible) { + return withScrolledViewport; + } + + const withScrolledDroppable: Displacement = getDisplacement({ + draggable, + destination: scrolledDroppable, + previousImpact: impact, + viewport: viewport.frame, + onLift, + forceShouldAnimate: false, + }); + + if (withScrolledDroppable.isVisible) { + return withScrolledDroppable; } - // speculatively visible! - return { - draggableId: entry.draggableId, - isVisible: true, - // force skipping animation - shouldAnimate: false, - }; + // still not visible + return entry; }); return withNewDisplacement(impact, updated); diff --git a/src/state/will-displace-forward.js b/src/state/will-displace-forward.js deleted file mode 100644 index ffa89e8fb0..0000000000 --- a/src/state/will-displace-forward.js +++ /dev/null @@ -1,18 +0,0 @@ -// @flow - -type Args = {| - isInHomeList: boolean, - proposedIndex: number, - startIndexInHome: number, -|}; - -export default ({ - isInHomeList, - proposedIndex, - startIndexInHome, -}: Args): boolean => - isInHomeList - ? // items will be displaced forward when moving backwards in a home list - proposedIndex < startIndexInHome - : // always displacing forward when in a foreign list - true; diff --git a/src/types.js b/src/types.js index 75d42a4037..e6f09d82bb 100644 --- a/src/types.js +++ b/src/types.js @@ -166,7 +166,6 @@ export type DragMovement = {| displaced: Displacement[], // displaced as a map map: DisplacementMap, - willDisplaceForward: boolean, displacedBy: DisplacedBy, |}; @@ -191,9 +190,9 @@ export type CombineImpact = {| export type DragImpact = {| movement: DragMovement, - // the direction of the Droppable you are over - direction: ?Direction, + // a reorder location destination: ?DraggableLocation, + // a merge location merge: ?CombineImpact, |}; @@ -247,13 +246,6 @@ export type DropResult = {| reason: DropReason, |}; -export type PendingDrop = {| - newHomeClientOffset: Position, - dropDuration: number, - impact: DragImpact, - result: DropResult, -|}; - export type ScrollOptions = {| shouldPublishImmediately: boolean, |}; @@ -291,8 +283,25 @@ export type Published = {| modified: DroppableDimension[], |}; +export type CompletedDrag = {| + critical: Critical, + result: DropResult, + impact: DragImpact, +|}; + export type IdleState = {| phase: 'IDLE', + completed: ?CompletedDrag, + shouldFlush: boolean, +|}; + +export type DraggableIdMap = { + [id: DraggableId]: true, +}; + +export type OnLift = {| + wasDisplaced: DraggableIdMap, + displacedBy: DisplacedBy, |}; export type DraggingState = {| @@ -306,6 +315,8 @@ export type DraggingState = {| userDirection: UserDirection, impact: DragImpact, viewport: Viewport, + onLift: OnLift, + onLiftImpact: DragImpact, // when there is a fixed list we want to opt out of this behaviour isWindowScrollAllowed: boolean, // if we need to jump the scroll (keyboard dragging) @@ -338,7 +349,9 @@ export type DropPendingState = {| // An optional phase for animating the drop / cancel if it is needed export type DropAnimatingState = {| phase: 'DROP_ANIMATING', - pending: PendingDrop, + completed: CompletedDrag, + newHomeClientOffset: Position, + dropDuration: number, // We still need to render placeholders and fix the dimensions of the dragging item dimensions: DimensionMap, |}; @@ -354,6 +367,8 @@ export type StateWhenUpdatesAllowed = DraggingState | CollectingState; export type Announce = (message: string) => void; +export type InOutAnimationMode = 'none' | 'open' | 'close'; + export type ResponderProvided = {| announce: Announce, |}; diff --git a/src/view/animate-in-out/animate-in-out.jsx b/src/view/animate-in-out/animate-in-out.jsx new file mode 100644 index 0000000000..8c60a4a218 --- /dev/null +++ b/src/view/animate-in-out/animate-in-out.jsx @@ -0,0 +1,91 @@ +// @flow +import React, { type Node } from 'react'; +import type { InOutAnimationMode } from '../../types'; + +export type AnimateProvided = {| + onClose: () => void, + animate: InOutAnimationMode, + data: mixed, +|}; + +type Props = {| + on: mixed, + shouldAnimate: boolean, + children: (provided: AnimateProvided) => Node, +|}; + +type State = {| + data: mixed, + isVisible: boolean, + animate: InOutAnimationMode, +|}; + +export default class AnimateInOut extends React.PureComponent { + state: 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: Props, state: State): 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: AnimateProvided = { + onClose: this.onClose, + data: this.state.data, + animate: this.state.animate, + }; + return this.props.children(provided); + } +} diff --git a/src/view/animate-in-out/index.js b/src/view/animate-in-out/index.js new file mode 100644 index 0000000000..5869168b0f --- /dev/null +++ b/src/view/animate-in-out/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './animate-in-out'; diff --git a/src/view/context-keys.js b/src/view/context-keys.js index dfd6d627ed..e5091d964f 100644 --- a/src/view/context-keys.js +++ b/src/view/context-keys.js @@ -6,5 +6,6 @@ export const storeKey: string = prefix('store'); export const droppableIdKey: string = prefix('droppable-id'); export const droppableTypeKey: string = prefix('droppable-type'); export const dimensionMarshalKey: string = prefix('dimension-marshal'); -export const styleContextKey: string = prefix('style-context'); -export const canLiftContextKey: string = prefix('can-lift'); +export const styleKey: string = prefix('style'); +export const canLiftKey: string = prefix('can-lift'); +export const isMovementAllowedKey: string = prefix('is-movement-allowed'); diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index 5bab429d8c..651d4489db 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -24,8 +24,9 @@ import type { Store } from '../../state/store-types'; import { storeKey, dimensionMarshalKey, - styleContextKey, - canLiftContextKey, + styleKey, + canLiftKey, + isMovementAllowedKey, } from '../context-keys'; import { clean, @@ -40,6 +41,7 @@ import { getFormattedMessage } from '../../dev-warning'; import { peerDependencies } from '../../../package.json'; import checkReactVersion from './check-react-version'; import checkDoctype from './check-doctype'; +import isMovementAllowed from '../../state/is-movement-allowed'; type Props = {| ...Responders, @@ -147,16 +149,18 @@ export default class DragDropContext extends React.Component { getState: PropTypes.func.isRequired, }).isRequired, [dimensionMarshalKey]: PropTypes.object.isRequired, - [styleContextKey]: PropTypes.string.isRequired, - [canLiftContextKey]: PropTypes.func.isRequired, + [styleKey]: PropTypes.string.isRequired, + [canLiftKey]: PropTypes.func.isRequired, + [isMovementAllowedKey]: PropTypes.func.isRequired, }; getChildContext(): Context { return { [storeKey]: this.store, [dimensionMarshalKey]: this.dimensionMarshal, - [styleContextKey]: this.styleMarshal.styleContext, - [canLiftContextKey]: this.canLift, + [styleKey]: this.styleMarshal.styleContext, + [canLiftKey]: this.canLift, + [isMovementAllowedKey]: this.getIsMovementAllowed, }; } @@ -168,6 +172,7 @@ export default class DragDropContext extends React.Component { // on drag start which is too expensive. // This is useful when the user canLift = (id: DraggableId) => canStartDrag(this.store.getState(), id); + getIsMovementAllowed = () => isMovementAllowed(this.store.getState()); componentDidMount() { window.addEventListener('error', this.onWindowError); diff --git a/src/view/drag-handle/drag-handle.jsx b/src/view/drag-handle/drag-handle.jsx index 0856cd8866..edaf996c6d 100644 --- a/src/view/drag-handle/drag-handle.jsx +++ b/src/view/drag-handle/drag-handle.jsx @@ -13,7 +13,7 @@ import type { CreateSensorArgs, } from './sensor/sensor-types'; import type { DraggableId } from '../../types'; -import { styleContextKey, canLiftContextKey } from '../context-keys'; +import { styleKey, canLiftKey } from '../context-keys'; import focusRetainer from './util/focus-retainer'; import shouldAllowDraggingFromTarget from './util/should-allow-dragging-from-target'; import createMouseSensor from './sensor/create-mouse-sensor'; @@ -41,8 +41,8 @@ export default class DragHandle extends Component { // Need to declare contextTypes without flow // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 static contextTypes = { - [styleContextKey]: PropTypes.string.isRequired, - [canLiftContextKey]: PropTypes.func.isRequired, + [styleKey]: PropTypes.string.isRequired, + [canLiftKey]: PropTypes.func.isRequired, }; constructor(props: Props, context: Object) { @@ -62,7 +62,7 @@ export default class DragHandle extends Component { this.keyboardSensor = createKeyboardSensor(args); this.touchSensor = createTouchSensor(args); this.sensors = [this.mouseSensor, this.keyboardSensor, this.touchSensor]; - this.styleContext = context[styleContextKey]; + this.styleContext = context[styleKey]; // The canLift function is read directly off the context // and will communicate with the store. This is done to avoid @@ -70,7 +70,7 @@ export default class DragHandle extends Component { // with that value. By putting it as a function on the context we are able // to avoid re-rendering to pass this information while still allowing // drag-handles to obtain this state if they need it. - this.canLift = context[canLiftContextKey]; + this.canLift = context[canLiftKey]; } componentDidMount() { diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index fd9be6c9de..07dc0d8fc7 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -8,7 +8,7 @@ import Draggable from './draggable'; import { storeKey } from '../context-keys'; import { origin } from '../../state/position'; import isStrictEqual from '../is-strict-equal'; -import { curves, combine } from '../animation'; +import { curves, combine } from '../../animation'; import { lift as liftAction, move as moveAction, @@ -28,7 +28,7 @@ import type { DraggableDimension, CombineImpact, Displacement, - PendingDrop, + CompletedDrag, DragImpact, DisplacementMap, MovementMode, @@ -157,7 +157,6 @@ export const makeMapStateToProps = (): Selector => { const mode: MovementMode = state.movementMode; const draggingOver: ?DroppableId = whatIsDraggedOver(state.impact); const combineWith: ?DraggableId = getCombineWith(state.impact); - const forceShouldAnimate: ?boolean = state.forceShouldAnimate; return getDraggingProps( @@ -172,22 +171,23 @@ export const makeMapStateToProps = (): Selector => { // Dropping if (state.phase === 'DROP_ANIMATING') { - const pending: PendingDrop = state.pending; - if (pending.result.draggableId !== ownProps.draggableId) { + const completed: CompletedDrag = state.completed; + if (completed.result.draggableId !== ownProps.draggableId) { return null; } - const draggingOver: ?DroppableId = whatIsDraggedOver(pending.impact); - const combineWith: ?DraggableId = getCombineWith(pending.impact); - const duration: number = pending.dropDuration; - const mode: MovementMode = pending.result.mode; + const dimension: DraggableDimension = + state.dimensions.draggables[ownProps.draggableId]; + const draggingOver: ?DroppableId = whatIsDraggedOver(completed.impact); + const combineWith: ?DraggableId = getCombineWith(completed.impact); + const duration: number = state.dropDuration; + const mode: MovementMode = completed.result.mode; // not memoized as it is the only execution return { dragging: { - offset: pending.newHomeClientOffset, - // still need to provide the dimension for the placeholder - dimension: state.dimensions.draggables[ownProps.draggableId], + offset: state.newHomeClientOffset, + dimension, draggingOver, combineWith, mode, @@ -195,7 +195,7 @@ export const makeMapStateToProps = (): Selector => { dropping: { duration, curve: curves.drop, - moveTo: pending.newHomeClientOffset, + moveTo: state.newHomeClientOffset, opacity: combineWith ? combine.opacity.drop : null, scale: combineWith ? combine.scale.drop : null, }, @@ -224,14 +224,15 @@ export const makeMapStateToProps = (): Selector => { // Dropping if (state.phase === 'DROP_ANIMATING') { + const completed: CompletedDrag = state.completed; // do nothing if this was the dragging item - if (state.pending.result.draggableId === ownProps.draggableId) { + if (completed.result.draggableId === ownProps.draggableId) { return null; } return getSecondaryMovement( ownProps.draggableId, - state.pending.result.draggableId, - state.pending.impact, + completed.result.draggableId, + completed.impact, ); } @@ -280,7 +281,7 @@ class DraggableType extends Component { const ConnectedDraggable: typeof DraggableType = (connect( // returning a function so each component can do its own memoization makeMapStateToProps, - (mapDispatchToProps: any), + mapDispatchToProps, // mergeProps: use default null, // options @@ -294,7 +295,6 @@ const ConnectedDraggable: typeof DraggableType = (connect( // 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 - // $FlowFixMe - incorrect type signature areStatePropsEqual: isStrictEqual, }, ): any)(Draggable); diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index 0477b944c1..9d716b1dec 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -64,7 +64,7 @@ export type DraggableProps = {| // used for shared global styles 'data-react-beautiful-dnd-draggable': string, // used to know when a transition ends - onTransitionEnd: ?() => mixed, + onTransitionEnd: ?(event: TransitionEvent) => void, |}; export type Provided = {| @@ -129,7 +129,7 @@ export type MapProps = {| secondary: ?SecondaryMapProps, |}; -export type ChildrenFn = (Provided, StateSnapshot) => Node; +export type ChildrenFn = (Provided, StateSnapshot) => Node | null; export type DefaultProps = {| isDragDisabled: boolean, diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 1b283c0af4..09139b9fed 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -1,10 +1,10 @@ // @flow -import React, { Component, Fragment, type Node } from 'react'; +import React, { type Node } from 'react'; import { type Position, type BoxModel } from 'css-box-model'; import PropTypes from 'prop-types'; import memoizeOne from 'memoize-one'; import invariant from 'tiny-invariant'; -import { transitions, transforms, combine } from '../animation'; +import { transitions, transforms, combine } from '../../animation'; import type { DraggableDimension, DroppableId, @@ -17,12 +17,7 @@ import type { DragHandleProps, Callbacks as DragHandleCallbacks, } from '../drag-handle/drag-handle-types'; -import Placeholder from '../placeholder'; -import { - droppableIdKey, - styleContextKey, - droppableTypeKey, -} from '../context-keys'; +import { droppableIdKey, styleKey, droppableTypeKey } from '../context-keys'; import * as timings from '../../debug/timings'; import type { Props, @@ -77,7 +72,7 @@ const getShouldDraggingAnimate = (dragging: DraggingMapProps): boolean => { return dragging.mode === 'SNAP'; }; -export default class Draggable extends Component { +export default class Draggable extends React.Component { /* eslint-disable react/sort-comp */ callbacks: DragHandleCallbacks; styleContext: string; @@ -88,7 +83,7 @@ export default class Draggable extends Component { static contextTypes = { [droppableIdKey]: PropTypes.string.isRequired, [droppableTypeKey]: PropTypes.string.isRequired, - [styleContextKey]: PropTypes.string.isRequired, + [styleKey]: PropTypes.string.isRequired, }; constructor(props: Props, context: Object) { @@ -111,7 +106,7 @@ export default class Draggable extends Component { }; this.callbacks = callbacks; - this.styleContext = context[styleContextKey]; + this.styleContext = context[styleKey]; // Only running this check on creation. // Could run it on updates, but I don't think that would be needed @@ -126,10 +121,22 @@ export default class Draggable extends Component { this.ref = null; } - onMoveEnd = () => { - if (this.props.dragging && this.props.dragging.dropping) { - this.props.dropAnimationFinished(); + onMoveEnd = (event: TransitionEvent) => { + const isDropping: boolean = Boolean( + this.props.dragging && this.props.dragging.dropping, + ); + + if (!isDropping) { + 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; } + + this.props.dropAnimationFinished(); }; onLift = (options: { @@ -290,27 +297,16 @@ export default class Draggable extends Component { }), ); - renderChildren = (dragHandleProps: ?DragHandleProps): Node => { + renderChildren = (dragHandleProps: ?DragHandleProps): Node | null => { const dragging: ?DraggingMapProps = this.props.dragging; const secondary: ?SecondaryMapProps = this.props.secondary; const children: ChildrenFn = this.props.children; if (dragging) { - const child: ?Node = children( + return children( this.getDraggingProvided(dragging, dragHandleProps), this.getDraggingSnapshot(dragging), ); - - const placeholder: Node = ( - - ); - - return ( - - {child} - {placeholder} - - ); } invariant( @@ -318,13 +314,10 @@ export default class Draggable extends Component { 'If no DraggingMapProps are provided, then SecondaryMapProps are required', ); - const child: ?Node = children( + return children( this.getSecondaryProvided(secondary, dragHandleProps), this.getSecondarySnapshot(secondary), ); - - // still wrapping in fragment to avoid reparenting - return {child}; }; render() { diff --git a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx index d1dbdac86a..36b61f2b2d 100644 --- a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx +++ b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx @@ -12,6 +12,7 @@ import getScroll from './get-scroll'; import type { DimensionMarshal, DroppableCallbacks, + RecollectDroppableOptions, } from '../../state/dimension-marshal/dimension-marshal-types'; import getEnv, { type Env } from './get-env'; import type { @@ -268,7 +269,7 @@ export default class DroppableDimensionPublisher extends React.Component }; // Used when Draggables are added or removed from a Droppable during a drag - recollect = (): DroppableDimension => { + recollect = (options: RecollectDroppableOptions): DroppableDimension => { const dragging: ?WhileDragging = this.dragging; const closest: ?Element = getClosestScrollable(dragging); invariant( @@ -276,7 +277,7 @@ export default class DroppableDimensionPublisher extends React.Component 'Can only recollect Droppable client for Droppables that have a scroll container', ); - return withoutPlaceholder(this.props.getPlaceholderRef(), () => + const execute = (): DroppableDimension => getDimension({ ref: dragging.ref, descriptor: dragging.descriptor, @@ -286,8 +287,13 @@ export default class DroppableDimensionPublisher extends React.Component isDropDisabled: this.props.isDropDisabled, isCombineEnabled: this.props.isCombineEnabled, shouldClipSubject: !this.props.ignoreContainerClipping, - }), - ); + }); + + if (!options.withoutPlaceholder) { + return execute(); + } + + return withoutPlaceholder(this.props.getPlaceholderRef(), execute); }; getDimensionAndWatchScroll = ( diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 1630b2e8e7..7b3531ee2c 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -3,93 +3,195 @@ import { Component } from 'react'; import { connect } from 'react-redux'; import memoizeOne from 'memoize-one'; -import { storeKey } from '../context-keys'; -import Droppable from './droppable'; -import isStrictEqual from '../is-strict-equal'; -import shouldUsePlaceholder from '../../state/droppable/should-use-placeholder'; -import whatIsDraggedOver from '../../state/droppable/what-is-dragged-over'; import type { State, DroppableId, DraggableId, DragImpact, + CompletedDrag, DraggableDimension, + DimensionMap, Placeholder, + TypeId, + Critical, } from '../../types'; import type { MapProps, OwnProps, DefaultProps, Selector, + DispatchProps, } from './droppable-types'; +import { storeKey } from '../context-keys'; +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'; -const defaultMapProps: MapProps = { +const idle: MapProps = { isDraggingOver: false, draggingOverWith: null, + draggingFromThisWith: null, placeholder: null, + shouldAnimatePlaceholder: true, +}; + +const idleWithoutAnimation: MapProps = { + ...idle, + shouldAnimatePlaceholder: false, }; +const isMatchingType = (type: TypeId, critical: Critical): boolean => + type === critical.droppable.type; + +const getDraggable = ( + critical: Critical, + dimensions: DimensionMap, +): DraggableDimension => dimensions.draggables[critical.draggable.id]; + // Returning a function to ensure each // Droppable gets its own selector export const makeMapStateToProps = (): Selector => { - const getMapProps = memoizeOne( + const getDraggingOverMapProps = memoizeOne( ( - isDraggingOver: boolean, - draggingOverWith: ?DraggableId, - placeholder: ?Placeholder, + draggingOverWith: DraggableId, + draggingFromThisWith: ?DraggableId, + placeholder: Placeholder, + shouldAnimatePlaceholder: boolean, ): MapProps => ({ - isDraggingOver, + isDraggingOver: true, + draggingFromThisWith, draggingOverWith, placeholder, + shouldAnimatePlaceholder, + }), + ); + + const getHomeNotDraggedOverMapProps = memoizeOne( + ( + draggingFromThisWith: DraggableId, + placeholder: Placeholder, + ): MapProps => ({ + isDraggingOver: false, + // this is the home list so we need to provide the dragging id + draggingFromThisWith, + draggingOverWith: null, + placeholder, + // placeholder can only animated after drag finish + shouldAnimatePlaceholder: false, }), ); - const getDraggingOverProps = ( + const getMapProps = ( id: DroppableId, draggable: DraggableDimension, impact: DragImpact, - ) => { + ): MapProps => { const isOver: boolean = whatIsDraggedOver(impact) === id; - if (!isOver) { - return defaultMapProps; + const isHome: boolean = draggable.descriptor.droppableId === id; + + if (isOver) { + const draggingFromThisWith: ?DraggableId = isHome + ? draggable.descriptor.id + : null; + const shouldAnimatePlaceholder: boolean = !isHome; + + return getDraggingOverMapProps( + draggable.descriptor.id, + draggingFromThisWith, + draggable.placeholder, + shouldAnimatePlaceholder, + ); } - const usePlaceholder: boolean = shouldUsePlaceholder( - draggable.descriptor, - impact, - ); - const placeholder: ?Placeholder = usePlaceholder - ? draggable.placeholder - : null; + // not over the list + + if (!isHome) { + return idle; + } - return getMapProps(true, draggable.descriptor.id, placeholder); + // showing a placeholder in the home list during a drag to prevent + // other lists from being shifted on the page. + // we animate the placeholder closed during a drop animation + // if (isDropAnimating) { + // return withoutAnimation; + // } + return getHomeNotDraggedOverMapProps( + // this is the home list so we can use the draggable + draggable.descriptor.id, + draggable.placeholder, + ); }; const selector = (state: State, ownProps: OwnProps): MapProps => { - if (ownProps.isDropDisabled) { - return defaultMapProps; - } + // not checking if item is disabled as we need the home list to display a placeholder const id: DroppableId = ownProps.droppableId; + const type: TypeId = ownProps.type; if (state.isDragging) { - const draggable: DraggableDimension = - state.dimensions.draggables[state.critical.draggable.id]; - return getDraggingOverProps(id, draggable, state.impact); + const critical: Critical = state.critical; + if (!isMatchingType(type, critical)) { + return idle; + } + + return getMapProps( + id, + getDraggable(critical, state.dimensions), + state.impact, + ); } if (state.phase === 'DROP_ANIMATING') { - const draggable: DraggableDimension = - state.dimensions.draggables[state.pending.result.draggableId]; - return getDraggingOverProps(id, draggable, state.pending.impact); + const completed: CompletedDrag = state.completed; + if (!isMatchingType(type, completed.critical)) { + return idle; + } + + return getMapProps( + id, + getDraggable(completed.critical, state.dimensions), + completed.impact, + ); + } + + if (state.phase === 'IDLE' && state.completed) { + const completed: CompletedDrag = state.completed; + if (!isMatchingType(type, completed.critical)) { + return idle; + } + + const wasOverId: ?DroppableId = whatIsDraggedOver(completed.impact); + const wasOver: boolean = Boolean(wasOverId) && wasOverId === id; + const wasCombining: boolean = Boolean(completed.result.combine); + + // need to cut any animations: sadly a memoization fail + // we need to do this for all lists as there might be + // lists that are still animating a placeholder closed + if (state.shouldFlush) { + return idleWithoutAnimation; + } + + if (wasOver) { + // if reordering we need to cut an animation immediately + // if merging: animate placeholder closed after drop + return wasCombining ? idle : idleWithoutAnimation; + } + + // keep default value + return idle; } - return defaultMapProps; + return idle; }; return selector; }; +const mapDispatchToProps: DispatchProps = { + updateViewportMaxScroll: updateViewportMaxScrollAction, +}; + const defaultProps = ({ type: 'DEFAULT', direction: 'vertical', @@ -114,7 +216,7 @@ const ConnectedDroppable: typeof DroppableType = (connect( // returning a function so each component can do its own memoization makeMapStateToProps, // no dispatch props for droppable - null, + mapDispatchToProps, // mergeProps - using default null, { @@ -127,7 +229,6 @@ const ConnectedDroppable: typeof DroppableType = (connect( // 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 - // $FlowFixMe - incorrect type signature areStatePropsEqual: isStrictEqual, }, ): any)(Droppable); diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index 68bc35ac5b..e3d4623302 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -8,6 +8,7 @@ import type { Placeholder, State, } from '../../types'; +import { updateViewportMaxScroll } from '../../state/action-creators'; export type DroppableProps = {| // used for shared global styles @@ -23,6 +24,7 @@ export type Provided = {| export type StateSnapshot = {| isDraggingOver: boolean, draggingOverWith: ?DraggableId, + draggingFromThisWith: ?DraggableId, |}; export type MapProps = {| @@ -33,6 +35,9 @@ export type MapProps = {| // not the user is dragging over a list that // is not the source list placeholder: ?Placeholder, + shouldAnimatePlaceholder: boolean, + // when dragging from a home list this will be populated even when not over the list + draggingFromThisWith: ?DraggableId, |}; export type DefaultProps = {| @@ -43,6 +48,10 @@ export type DefaultProps = {| ignoreContainerClipping: boolean, |}; +export type DispatchProps = {| + updateViewportMaxScroll: typeof updateViewportMaxScroll, +|}; + export type OwnProps = {| ...DefaultProps, children: (Provided, StateSnapshot) => Node, @@ -51,6 +60,7 @@ export type OwnProps = {| export type Props = {| ...MapProps, + ...DispatchProps, ...OwnProps, |}; diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 942e78f772..645a12c0a1 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -1,5 +1,5 @@ // @flow -import React, { Component } from 'react'; +import React, { type Node } from 'react'; import PropTypes from 'prop-types'; import DroppableDimensionPublisher from '../droppable-dimension-publisher'; import type { Props, Provided, StateSnapshot } from './droppable-types'; @@ -9,16 +9,21 @@ import throwIfRefIsInvalid from '../throw-if-invalid-inner-ref'; import { droppableIdKey, droppableTypeKey, - styleContextKey, + styleKey, + isMovementAllowedKey, } from '../context-keys'; import { warning } from '../../dev-warning'; import checkOwnProps from './check-own-props'; +import AnimateInOut, { + type AnimateProvided, +} from '../animate-in-out/animate-in-out'; +import getMaxWindowScroll from '../window/get-max-window-scroll'; type Context = { [string]: DroppableId | TypeId, }; -export default class Droppable extends Component { +export default class Droppable extends React.Component { /* eslint-disable react/sort-comp */ styleContext: string; ref: ?HTMLElement = null; @@ -26,13 +31,14 @@ export default class Droppable extends Component { // Need to declare childContextTypes without flow static contextTypes = { - [styleContextKey]: PropTypes.string.isRequired, + [styleKey]: PropTypes.string.isRequired, + [isMovementAllowedKey]: PropTypes.func.isRequired, }; constructor(props: Props, context: Object) { super(props, context); - this.styleContext = context[styleContextKey]; + this.styleContext = context[styleKey]; // a little run time check to avoid an easy to catch setup issues if (process.env.NODE_ENV !== 'production') { @@ -84,9 +90,10 @@ export default class Droppable extends Component { } warning(` - Droppable setup issue: DroppableProvided > placeholder could not be found. - Please be sure to add the {provided.placeholder} Node as a child of your Droppable + Droppable setup issue [droppableId: "${this.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#1-provided-droppableprovided `); } @@ -116,16 +123,32 @@ export default class Droppable extends Component { getDroppableRef = (): ?HTMLElement => this.ref; - getPlaceholder() { - if (!this.props.placeholder) { - return null; + onPlaceholderTransitionEnd = () => { + const isMovementAllowed: boolean = this.context[isMovementAllowedKey](); + // A placeholder change can impact the window's max scroll + if (isMovementAllowed) { + this.props.updateViewportMaxScroll({ maxScroll: getMaxWindowScroll() }); } + }; + getPlaceholder(): Node { + // Placeholder > onClose / onTransitionEnd + // might not fire in the case of very fast toggling return ( - + + {({ onClose, data, animate }: AnimateProvided) => ( + + )} + ); } @@ -142,6 +165,7 @@ export default class Droppable extends Component { ignoreContainerClipping, isDraggingOver, draggingOverWith, + draggingFromThisWith, } = this.props; const provided: Provided = { innerRef: this.setRef, @@ -153,6 +177,7 @@ export default class Droppable extends Component { const snapshot: StateSnapshot = { isDraggingOver, draggingOverWith, + draggingFromThisWith, }; return ( diff --git a/src/view/placeholder/placeholder-types.js b/src/view/placeholder/placeholder-types.js index 022548562c..28d05af690 100644 --- a/src/view/placeholder/placeholder-types.js +++ b/src/view/placeholder/placeholder-types.js @@ -12,4 +12,5 @@ export type PlaceholderStyle = {| flexShrink: '0', flexGrow: '0', pointerEvents: 'none', + transition: string, |}; diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index 373cae942f..7f0ca6cc80 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -1,17 +1,137 @@ // @flow import React, { PureComponent } from 'react'; -import type { Placeholder as PlaceholderType } from '../../types'; -import type { PlaceholderStyle } from './placeholder-types'; +import type { Spacing } from 'css-box-model'; +import type { + Placeholder as PlaceholderType, + InOutAnimationMode, +} from '../../types'; +import { transitions } from '../../animation'; +import { noSpacing } from '../../state/spacing'; +export type PlaceholderStyle = {| + display: string, + boxSizing: 'border-box', + width: number, + height: number, + marginTop: number, + marginRight: number, + marginBottom: number, + marginLeft: number, + flexShrink: '0', + flexGrow: '0', + pointerEvents: 'none', + transition: string, +|}; type Props = {| placeholder: PlaceholderType, + animate: InOutAnimationMode, + onClose: () => void, innerRef?: () => ?HTMLElement, + onTransitionEnd: () => void, +|}; + +type Size = {| + width: number, + height: number, + // Need to animate in/out animation as well as size + margin: Spacing, |}; -export default class Placeholder extends PureComponent { +type State = {| + isAnimatingOpenOnMount: boolean, + // useEmpty: boolean, +|}; + +const empty: Size = { + width: 0, + height: 0, + margin: noSpacing, +}; + +export default class Placeholder extends PureComponent { + mountTimerId: ?TimeoutID = null; + + state: State = { + isAnimatingOpenOnMount: this.props.animate === 'open', + }; + + // called before render() on initial mount and updates + static getDerivedStateFromProps(props: Props, state: State): State { + // An animated open is no longer relevant. + if (state.isAnimatingOpenOnMount && props.animate !== 'open') { + return { + isAnimatingOpenOnMount: false, + }; + } + + return state; + } + + componentDidMount() { + if (!this.state.isAnimatingOpenOnMount) { + return; + } + + // Ensuring there is one browser update with an empty size + // .setState in componentDidMount will cause two react renders + // but only a single browser update + // https://reactjs.org/docs/react-component.html#componentdidmount + this.mountTimerId = setTimeout(() => { + this.mountTimerId = null; + + if (this.state.isAnimatingOpenOnMount) { + this.setState({ + isAnimatingOpenOnMount: false, + }); + } + }); + } + + componentWillUnmount() { + if (!this.mountTimerId) { + return; + } + clearTimeout(this.mountTimerId); + this.mountTimerId = null; + } + + onTransitionEnd = (event: TransitionEvent) => { + // 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; + } + + this.props.onTransitionEnd(); + + if (this.props.animate === 'close') { + this.props.onClose(); + } + }; + + getSize(): Size { + if (this.state.isAnimatingOpenOnMount) { + return empty; + } + + if (this.props.animate === 'close') { + return empty; + } + + const placeholder: PlaceholderType = this.props.placeholder; + return { + height: placeholder.client.borderBox.height, + width: placeholder.client.borderBox.width, + margin: placeholder.client.margin, + }; + } + render() { const placeholder: PlaceholderType = this.props.placeholder; - const { client, display, tagName } = placeholder; + const size: Size = this.getSize(); + const { display, tagName } = placeholder; // The goal of the placeholder is to take up the same amount of space // as the original draggable @@ -22,14 +142,15 @@ export default class Placeholder extends PureComponent { // this is to maintain any margin collapsing behaviour // creating borderBox + // background: 'green', boxSizing: 'border-box', - width: client.borderBox.width, - height: client.borderBox.height, + width: size.width, + height: size.height, // creating marginBox - marginTop: client.margin.top, - marginRight: client.margin.right, - marginBottom: client.margin.bottom, - marginLeft: client.margin.left, + 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. @@ -41,8 +162,15 @@ export default class Placeholder extends PureComponent { // 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: transitions.placeholder, }; - return React.createElement(tagName, { style, ref: this.props.innerRef }); + return React.createElement(tagName, { + style, + onTransitionEnd: this.onTransitionEnd, + ref: this.props.innerRef, + }); } } diff --git a/src/view/style-marshal/get-styles.js b/src/view/style-marshal/get-styles.js index b73ac7beaa..a61b12697a 100644 --- a/src/view/style-marshal/get-styles.js +++ b/src/view/style-marshal/get-styles.js @@ -1,5 +1,5 @@ // @flow -import { transitions } from '../animation'; +import { transitions } from '../../animation'; import * as attributes from '../data-attributes'; export type Styles = {| diff --git a/stories/1-single-vertical-list.stories.js b/stories/1-single-vertical-list.stories.js index 9e59368f14..294695697f 100644 --- a/stories/1-single-vertical-list.stories.js +++ b/stories/1-single-vertical-list.stories.js @@ -7,7 +7,7 @@ import { quotes, getQuotes } from './src/data'; import { grid } from './src/constants'; const data = { - small: quotes, + small: getQuotes(4), medium: getQuotes(40), large: getQuotes(500), }; diff --git a/stories/assets/bmo.png b/stories/assets/bmo.png new file mode 100644 index 0000000000000000000000000000000000000000..2e528f94c71d7794f7f8b8eb065b2dc4e9489511 GIT binary patch literal 20113 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 literal 0 HcmV?d00001 diff --git a/stories/assets/finn.png b/stories/assets/finn.png new file mode 100644 index 0000000000000000000000000000000000000000..6bceb6d06528b33d06172efe4d9506c3aca7744c GIT binary patch literal 18002 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 Zcrh$7crodf{BQsO002ovPDHLkV1ilM9Pj`D literal 0 HcmV?d00001 diff --git a/stories/assets/jake.png b/stories/assets/jake.png new file mode 100644 index 0000000000000000000000000000000000000000..6d342283ce2c96384d623dec0dcc304b6243e3b7 GIT binary patch literal 9658 zcmb7qWlSAF)9%5&xD+Vv6n7{N#i6)E;ox?FgFBSs?heJ>-CNv?yIawNADoZ({xiw$`@qRdL&rtU#GS&?8DwE?XHMbb>1a-2?qO{K0C=p}M@^#yu%k=8aY=vm zkh87a#Vg-JjDg57YutzjOm$10)|=mF62u9G3jvDYj)^~i0ntJ zKu-0uK$W&2xQE;RUlIwTyW$S`JTwm)l_!e;0F9KKl(>e+%9*ccR9AqC$l>l$VliZt zV7p*3L9?SmXh$b#Uf_CY^%D+l7tJT{1L$qy>;58?u4P00RwG+bf4=Dch>@tjHxNlL z;BAvbwif`53xFAKn{-uo`b!TW??R%4>CDnFQ9M=JnZ;_`;;kR2=SKmYBW%K}hnNpXEorgjQq*HYGw=FZzP&&xTol{L(y?0kXX&^Y3eP>mX?z6UJUrFH z@A4_*tUs{wqzVhQ#FbICqLn^oDhH~mQa05^XRH=}yZNRQ8NpdTYEz6}D=qb@I&T9Z zL^C}pDcQ|=^o9f`Un1vE6ouyUipJ}V1oVc41=M~0I@;7WQKibtw&4lGXn2n?~X zR2)9wTso099MA+csXj6nU+FZJ2Nk^rWy#C>yngC;^U zxxa(Lo?o-UL)!5~G8!t}cCe0_fj_hU1Esul$|B~ARsYA_>ZiSK1k{8dG{MIlia=n0t6DSy%Oj zWfs^Op`l;Z0+3I2u!l@^mxUmUMm}ewqHpH-c_vt1;Cx>}aA^$%dp*CwP^2Bl2R=KXKx!M3`0WDg5nIrB6!mrs+ zeVWiOZBgM8B!e5CmnGS5E3tkz1suS^QYHXIScFp;ml!{QgU-|crM*e?al2>T@!G$2 zSc$|KJchq}mrEe66pungo=z$>5NP$UjK`37=Z^jd*#AmiUELdt$P^d9VBL-6DKYnX zZ!b|reh(WP`=_8 zrTTk?v&DJ0HnIEVjzmEOodr&OFCtkpnQznOT8=)uu$-#iJgEf<8(Y)Dbm#r(H{%Uc zRyx-vuDVB^5G9dwcDziXC=MafX9=u{68TVLVdEaxGuWUOx}vxxY5H8Bt7yIr9Ye$H zs1A;h<{r4Rau1l_M6)X^UfT$S&x@U&fVbIS)S#D*e>k)Mfc4>@7H!j*CreL&?ol2X zGwpV>|E8YDPCzW1arKO);XY2|Y16S^o-a&7KOJOIQvt{ClBQ&Qa5x&u1<6&i_>Wqr zNZiU>G94ByPjwtV}k8m_+ISCgULdD@P!fP;$?51!^@dp(%o z+sslf>4J{0z5D~kr=)d(%h-9K{cSL-^tpb{Bi<~a&yj~@f}Gr*HRHh>LfpP|y^$-u zAf_n6OZ`Yf7JYJ^UHnNI$rhFChsk+hOhA6N4a z;>l5{e=2>Cn@*3kJ0*x4aF+N9KY5?lT^~%S&zq@1!dj)I_PRt*Yx`1abn2|8Gtso0 zNLU2yl(kUpN3CI<*m(Cugfsg)G&~>My(8U4JJ<9`yg5Ro*Lq%0%wm08M2vqnrd3Hn86rEZ3OuPr^J{J;l9pnW*)!la8;(1v z?qE&765@T-=a&`nJ7cR!=XWCA^5+KK;<7#Dbt-aOv~CQqJq;z6hAorly(K;D%TDXE^ht+HK(;`rqozdElr z4?sGd&OoawT+1+kf!ne`)FHy7u zWbs2A66sZ7ccd$>di;I3EY)_F8shFqd(;BDs482JaxnE*zzY)iQi#1rCH`gPC1`*E z9O~oBhDl|kx8v&Di3w6-rM2vAdNBaDW!&WR`F8Q0!K5AzMi_oC#}KKyuI_#pr011l zc}-qnELPOdfivgL)n8qtW}#dbb(;m5s!RtbC_I%o(ck(XigKqt!;;rZ^Q~ekdVHX zNdkfgyYG~*!Jvo{hH&+~A=j#W21f6HMQb4w_6uS>%Pi*atfgL}thBb`mz0>U;o2Xz z{dC%pDhAX25+3cCzM(j3bOw_j;c~oBJ6%6N5fW9(x#g2Jk~E&i@ky*Flr0e^gV*<~ ztEV*Q@>Q0b( zqKlPK>x|bfaZ}pd++0r1+MT+-fPzXBqp!BS92GG9`62J(_uvw-2ir!U=dGb=c2A^N z4b`#q_aD*~5w^TdT#|wXK-BMh!|_Wzv$WO?)zt)mEf`N%>knq$Yt>#rRb-Oc1T?KG z`@ZJ0pC(H^qb?^Ubkj+_dVrLP1A-P4pvf8$E`cx!;;Qn%Q1~=v=^y6K$Kl^gMUX=n zO9mGvcsWj%h4U4v;DiKpKp4Ufeg#^X_aw`$1O;vVQs;=h%_Q8C*Tcaz!eM9Ai$wq| zKuRhbIk7H}KLTb+3Hn5W(bSuKpu_tbls43^-kM#S3z< zvA3_Pu0AF_5~5y$@1vuj%jY5Q?D3k=1E}Jm++s zhybnb2K4^~4+f&gQ-x1ssca`+lBP9A?arI4G9Y9MhGC}C3ImjHnzmKo>64ls5#Zp> zrzU1@C+hI;QJ1XU&_L0b9YVg&i&L(?`iBD1_^#7Y6K|D|LX`wbRJBN^9wFLR=;V@e zNhoAA@)VA~_|bf1AtttYg%97swk9J?U#`DC0ohu*ZnUn~p&M^!W2`+FC&nbacCFu~X?v#wcb7)}zR3>V-Lc#b&!Bx%sK9*AA_ewVlOTzPb}+!+sU2r=w{D2c z*2;=~@WvH8EDSZB!sGQPp~bmxZ@Tf9v*GZQl(nu!Lq5f~Ia1pq#toe?z0OD&Dq9A2 zvvl;nZci1)q_%H?&HfLhm*bswZ_v}e zL&rqx-O5RNGRBipKYBX^EkN}3GWOG_s=y{WvW{j!{9;|H4*F(7cz|+gxzyJ=q<`fp zQY8eaeD*faXCVK`>Tz$j?M~pq(!imi%XA?4Kykd%=PBj|^nBUsB)RLM4xBp&j@I4| z{!>0iqGC_v3FKoSXlpH_o`^OUE;3zy$!)zF!{6+@cz&@UuXEXV23CSU2o=iW+OJJd zJDpU$p@>O`5%n%@!`Q|8=|Ci~jlQcntlZ0g&%~BX>p5>fS*U*btG6mdXq@f7u!PT? zawc@ryXtVi?<<<)Hs!CF&R*a2cW#G@NErzouI2_>B(86VGS900%NB#?t+VS}6MH|7 z?wC>Qk^%Bgu|l_A)9Hpv(xFi{uWPR8^R}{W)ph>LA%isMwL{x}3avtl4oL(JZ^=1F z9p0YzoSkq94L*FJ+^;4U@YL){n8!)Dsr}!XnYH(j^Z7E@Y0|X=mjN$67M4WhWlkN* z_iDIo-ZuB~cH?Qh&=g$Z@nH$e(_9rxyk(*+0fz`yN2R=+svN6SqF=JpP1zU`S)Sd0 zNZQhs9i|`t7k_F^+2zsN|t;c2NLWq%1?+)IRB2&+e!n_d`)p_?|jdry_yi|PDM*^~L(Ct}l3m4G# zCmWTN&xYq=G^xOGX+ca>J_B-44o)EpR7#l*Q6;on5F|N@zXM~5Xkh%<3UAEkedS_tF=*x0_ zeW}4T{+RRDT};3ykF6{-mAj1}8u)`3#Y!ZY!l!8k#Y21sJPS>YSlGCXXpGZ$ePbIH zb&lJmx$7%V2g#8+o>jlvSTN~qKR0kFhD34LrG+z)w=j?V` z4!Rwa2yL1$=&ReMGA3HDg>euXa`>;DtCvirZD&!xT4B@1CrF(U0aI9g5BqUdp09J? z?CiI?LUD$JzjofUk#jwdftm9SrtkjDo|_wDVTUsyeB^6ve{2rr!@{P}59M#3Tl(^_ zhl?rt{CBBtcQBTGQ&W*~`~Y0y$I)}JkL1tu!=PfAV7XoQh(=y~E3;s_#Hjmmu!C@j z3WfxWej}wk!gNdp2LlB?G|ST;cw!%0Ns>{u1cD^71#~P*Q@{>?o5O~5SjI6Y8QmqW zs)#%Ux3^O|aj2q#RsI zSi<-CjikJAXScTUE~-K#3%Yfzf%SFcZQ@ax-;tFn|Hp^NJ?-wRDH(EC4|~HGw(6eO z%){Kb70_LVz&yS_Hc^c*KBO$+C#X(RrQ78e$X1E^L#z|G?oSRLxoAN$G8ue51?8dn zE7T&(jTB!Wt(jgXdqB`FVApXktATKdvLVO&7$u-NxGf7DNx;&5Jt=fJk=f#A57$7+ zNtGj3xD)I*2J<9*c@N%vG995k{J_S0V7CRIlH4wU@hgCzP)p0uNqzf0ZBqPcLh7x_ zz-Vo1>KA1}uSr{QQE9^;MoXrKFXBH-wj0_sq>6bWHeBlk!A|*m0!blvIS6W)5_rpl z!`xV#Yjs#}Hy=ysx*Q~dbT-U+%SbJR>_}?>)UFCiiJ1i7mS4&t?G}x#`~raa`D8z8 z06>7Ptp4JtCxz5~+P00q8Lxlmp`dY?mm7c;F6}azbc)NT$UO%`Y0BWCVD_{d3WvPJ z=Q1}X0=~>}WeKU$?wObIYQ~lufyWtgUQYe`_Amcf0!+!iZf!=Z6hqxVS;SmX-ClP) zYRtx==*MiFGOS}O?Su0jOHD0~*p|`xa_?8V3@D4cy_yIgJ(K0v)2pnU1B>NwaopIa zdeHL;_2!sVp3Sc8p6n<75 z4NM+x`?Wj=@&Dp?Q_Ig(%P}+m+pih}Az(=s^~gtm^-#3xx;hI-=Wb*9ObQo+^6orL zS96Fg$*HZAqsuRg37t)U7STge$oZ>eyvM2E%R$VYnfa79_T z-sURI`Or|?RJ#TBgrv_*$3WaNmd$hT(fUb6rdzn)ZhGjuS}uyL1`G_T5uzy))%PMEuRA({a-s%f&^F)XuWek)v-EI|JIpP3IHF2Ui zS+&~D27vr%q3Y+R`RTG;G8C(N2bA$|RGg&nAwcX)=kvNg@^+`0IS$pi14&>Qysr?& ztUIh=COs{KK5O$Ju8sbCa#z>lV|g6>$ax5?Asa-ZqX-Vj@-oxZDXt?5xpPpZn_$ix zncJOQ^kq^(qELkA2d1~ORmjNwwjuhtCKsBTlv+%Mq%c}p3y4bOcOF>YZNM}TzI}u& zCjD$PRgRW6er&;o_u>BfO^Z^Yo1wVtwz92^@IdAM0Rgupx*7}fFtW~FGkxnHT>bEn z8EKf8r&S>sYdBHf+OxforU-VnVYhj{L_9=S3I-#b$hY-`Qs7d>&L_S&nzNxq3r_j> zWJRp-?@!!ScCY)1Irmd2GUTLEN_8HbZbJ8%g^$vxX>Y-9xZjHACABx3f<-EJHaeC*p;;8RRGraFh*^HaS`iX!Vouv-uXuflK zZixBmVd2;v_v*71C5oTk-;*|R$bI)2W#z>M-6ww=w|QVqHHlt)`WL1&u7>AXa5*&H zA;@J89f5=R-dZlOoF*HOxo zGh1h6h-?#dtY(&m)W?c zyxy2H3ssAm(@8|gMCiG3WIo*4Ur$>EQo`uGwwIfNhoAaT{*zg11<8}FYh|gtgu*~M z6Y1sf>kXmUvc5iJW6sNS<5MjyT`Ndfvgc5FXt)k_dYwLdJ8Q$~58iu;UyOvV47dm( z(;u-nmNOz=+2*M=&D3hDZi)g=IpVR~MC=2}1LxyIV0A{D26jX79UfjU6qL!YhAjC+ zZsniq-LFY94OLAw;V;asxqM{ZdKiy@AI8aC#iTup*tnah)h!ax+a#t_Y2#eriM>ZP z{)wp9qivi6G(Rd7r#8<4Z~zGWFITJfxS>V1UwI!$ReOo0Ln0;;We_%B zp_*$CzgEzgM zCTlsUY#y6%JP9iS$XSMsjpkQrfWT6K?@e#Jq6ZLp#%COycTD3 zKabDnNof{z_J$^c4X&gl!B1hIX7SJ|V(y5H+kY_{wa9&)r#OccAiNl?X07e3GB`_0 zn#{CvZ@FFI5UPoVwP-w};q;}>5t&**7$$AAW?Ln9vcT~X#6X5Jv!l_PGBx8*1@;5q zgCd8bKjZI1MWe894)#Ese>dR)l&Xid24D3?OmXf#Br3Wc;ChKch*o z8FdnB$}=gv#wCqQ~0iCZ& zfHt)XkmN3g;F6;6nUn$_`_lBFFKVSTYe2Z5g_QgYBX6&h|FX^l(&_Rk`I1UHJ}f+_ zQBE^OZ6r+Se8hmuU$dBoG4r&urViAHS=w8uXm9TIQcU!Hz3!^*3`nRW-mj_}Mw@MM zS{j>nQ(v$Znv}Zsb~N=ku z+a54kb)QVso4L>C@wzaVfJoK;@}s_0g%YdN8{qWS4|zQ(|03p{oHYkx6bWHz5#oM( zV`Ltj#Yz#4^5A-bbkx&-+z#?$y3kMCxgQ?Pdl4GG$lH6VLKH;GZ%9uu?N0dF}d99#3Ih z2(UP@N%`!?l|Itt-Koc7cJsTR4r1;%ng8TVJxHx|&|hrzJ8~W+j8lA_LANQlu0i>o zJ~TI(k&!OQYCaY$*S&k`0HKK{`e4w#`I zD3WQ98YdPlt{=Ei$(+MYk5=>3f*ju772oG3(C6kCejXDtC7tZz11_3QfN(#hbH)^Y zCimxUlsA}d(rii#I9;z zQ9I}DRc2e?he}mOcI(_z&YwCz2DLisctovTKKb+*d~d;ce2cR_Evo+$T{hKu?}p1T z+d`X1gC|LM zh9s4^hTo7RIhjBQlRiFPKdzkd(?q!he6G-bSUG}%;D!-FsPzd}!yRW=)>`|1oV@6> z+DYxP|3rM_v8Z$QxJ-~0J}P_&`g&rbtq(Xz>5V$0TtZQ`2>QMS(qN$Q1WMqE^HcIl9+BJZ-TphP`p_I@jef%mX=-@L7?s>d3*DY}%y$ zvTh4Tbdv)i$zDSCE!N2Zwj}wX)R)}47LnS!{htYp3iw}`gQUjKeBuDurC@B!F~29- zp^)1NW1%{-fhBfm2YoFX^0zzqrQ+Ni(Ys5+%yXYcq6JF>1NIGLheLiI-X

=JyZ{u z?4gO4M93yc4+o{oohl(oliqlRf&x3t-SI(D`Y&Fr=-mnAR3xkVarB$@NId>BLW|F7 z??6@85f4WnVZNlreDylTq4A3wiuPH9wi45rqAAb9J&XnY=e!}`5n z^xA7s|B_FxR#;8=l}Dd1Hb%Ejy)nZ(6KB-i|7=r(p44|SLB9SsaldBiH4$||I_RB$ zB*j063>VHNSt^#l#;1YUPZKjXF=ZqhmW;5AxRgPS|oApWs=V z_m{DtQ~T?HaB!BqYG_USPWyaMr|nt4>ax11@J{W^=pd%bd{UW-$2XCZ3f>to$aU?m zx1ErX$LD-2gQT0QyS|flvd&z~%`kB34FR6Uo<5X8F^S!tq|bW%FRVf$ho@mL$e-A&|2OS1no|tFW0F-55LM<=t2s+Hjw%QQEuosLJAx8_X~p5_}( zW@Z=BH{rbw%cPE;NXk*hK;Z4st}fGp*t_{sbMn``KKTYF~QwFxJzGCcr5T@=XuF`H{?Fl zFVSd`0CQAHclUCrrSJBvQ#%_Jc z7v*UE-;~|_e|0(jaJkY(!VdrMy;MZUm z5ai@}Z|_q> AhX4Qo literal 0 HcmV?d00001 diff --git a/stories/assets/princess.png b/stories/assets/princess.png new file mode 100644 index 0000000000000000000000000000000000000000..b05837d4f97991b5407aa330749a6c0c3cfd5ccf GIT binary patch literal 21790 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

t<(mW@mTX-R)*;9wMbC$KA2! zYjSr$q)lOh(t3LAwx!Dr7Lx&UCRMSNssz2nnv3`9)0|A@AHc?S`_ zOkKQ4Mhk0IH9W6;=uc`75wGDiNI)GLQmLgXtaT(<6V(E>EQ&x$t;H>c3K&c8F_0=8 zs}suIT7-a!9Ud*tUA)w4V`}f-xDkDUnAC$;VZp}7B#TYitSuC?Md_e3+t;+p@vZmX zy>#V)2X`+H5nuTHPkrY$P3 zJcbf$J{L9_jmHM{gnzGzY=l5c&h)BOh4 z%Iy%`6N@$ov**P!mc0zlKKF~GVY!;<%yP#=#YwY+f~d9FMXPl~BC?}+600%u&0-xB zXw4XleNBY{?i3O0NoCD2e0|fz51mF|F zP>7+_-eVHN%|m#c0-1XDp{`Yo3gC{iJ)n@D^Lp{IMkxXngTgwXf7i1KP~5$>GnXzf zXAr4o+Kja??;cn`DwYq%&<+JC`(PQuS-nG}Sp5NZ} zQbxCEoxGoveHjjxrdB{bZT>4u*NBGNgd~bVl4$*5^CPvl$Q{N|sr80}w$pm*kn8@Je+G6kW zqYsw|47mw8GuuY-V5rqB*!Nolp*W=7@(Z(}E5(~-(sF)&rr$fd{@#0^`}AiYzW>U0 zJO9gn`3LvScXv{!IgUe*HDSo4p~%q?sO>4<=euxBt(~4oPPgt(VI1+{-UP|Auaw#?cvHq(fc^HPiC-3m#2}oY8Dzq2Cy&noB zxRqqq5X{Tf?OV4mpTFS4K*ChXc;ESFpdDnM=Gd7H$&0B54bA-gnKOU#r9XP-+B?^- zU;mwd{x5F6`sQbz{wR<7X5ypQGL8TO=*+Oc7G}jFO<8 zBr9C)FZ#vOmpLEx{o+U|CJG|e9M_RJZKlmeTVujPV-+ZaG=`+L3w6nvLun|00b()o za|jhC9`sBYEG$M8J_IttrZqjDP1k(y`qk~r=PvAS56fPJw{KkUE`K8SbAnVGcN4=n zB}UE0kuA;JhVS4TH_97gPnNv=@y9>*moI<&_y6pXI6urIH|QDg_CV z7N*u7-d>z@3oSJ><}TU4Gm)dl(i6(%L$*ZUpssa)rT=d#%*08BSH-*Beh-89_ z1d_hD+S3a6tua(3$o zzwpFodM~QlSsIo-C=(NVOzp1Q)*P^;?y}6B^UgAsn`>}Fz&G8JP2pflDWq14Ugxq` zp3vB@CqaekVwkEp0#qhNW0FkU1}YxD$#$LwUKc}bN=l8X5+3FF=NkA4hED0sn+_BJ zfdX`f8{XT$`RudL=A5_ZTUXzBa|YY$=KU~8V^F>0gQqA56@hCyy!24|g=e4m;@O8@ zdHy@mG|up4-{nZFT#iVM?VxU5lORQ zP>vNNShMV1wQaOXmDWq3MEG5#>MC(I))M4IoT|)@>9(lI4DWAgfDxA{D>)8s5JecnVMPcGG4z(L~3WCFf9VD1!6qB6y`h? zY*G=3ir7dDIz?)#@-c`2QM?R8S@n6<=e{pF!@~mXOJ1!~O62hwOrW+4778P*P1g=@ z&MxkrKYPwgxW_x+fBm7|i{+><;Y>R9lYhU9p^y%e=4syPd@E)jZXWr{AAi|RvWlA) zl`N9=G-t4qc2p?RS@OfxacQLgpDs?#f!Ew=kMOB}K{Zt(qLvr%Tq2h;3=&hprifsv zKqpg(ohEXeA}}P&kn^gKp=b#yf#S@?SA&#bUszm59kNLVk#*!xOkaQRt;e5wLa0sC zz469XUHSdn=X{m5X{1R$JAms;71?F!Qj%!DWd}2#_sget&cFQJx6Mb7G6lMFuTCH9vWe=;l^1^> zi}{0GwdkG~clW7|^VpTkYKC5+4356kLRo1_fKgd@i>a#@UdmWb$1CSsCeA4-C8W4j zyBPp$BjRWxga}bxc#`B=Td0r7W%cs20>mt}FW-7cFYn%W`C=(U6MW;luRMI_zA_Z# zNKFcqk*_nk?)TaU3UgR#MWxJw1Lfi7(s2FYrK_)Qrv~9gc_QSrAq_>)I0{&H=d;@b z4tg=W$J|amZ9xd#=oTTR#>uPdB3V<+rZRA298$&%S=p>Z=FX{^iI689F*Rc?PZ`x! zIT=$VK^iuTdP@jGsU*k{SstuL0p9Rdj^R)eiZ^b*_sG*vdbrKnH($T%Hy8Jxxi~DA zFtwCw$>Zb7z4yFCXb^MLMm1%pq&Audn+q?#@H`p~35+y?Q{;bL$S5bVRgslfV$Nw> zW&M*BdO;L=5r#zeTuSy*5E0oUyBw$7tjXcihu8HBA}WunVmAt>IN_|Bu|`p)K@|$~51Vbz%!W z!$nH9eEW$@iCUMSb!Lm)!Up2qBcix^NU+bc%)ZKbvGT(OmV*u+Lq?!h8Hw5iBN#b@ zH7-a^Hexjs$4aNyw4w@1RIX8%vZ4T|`+otfH^#`0szHLrkhG9qExd^xR_#u%vZ`SM zaMxwz&7*76*ROu}@lStjd%k5&bM39S_g{VI>H8mD?jJ&6vzCmIdqQvTzX4PyTxxpu zF3rsK-M8PiWbb2FX)X0zTgV+8F&JbnCHI4`hUhch5iG+nELZ*hq8u*U5DC@_Z@C{> z+@@m2R8WNi2LlYy;q~XKvI79Hgb%r9;*b5m?|deX(1@I{I4P!3MKWnDthP)61(nK+ zV82*Oo03C_qGQQO3v_C?5B9EM_3Tf7Zgp@ppKX8rFTeIwcX2n(mdjuU3*8ZLEKxdO#+mX9p2hANj~_n8l)zW1`!C75%54?^4>xY zmROa%8ivKH9Q6_q3St6XRisJUrAS&7MWrZQYUO7{xDOet!Iynm_81DX-`Pl#gs7OP zK^SDjcyNnALBe1#3lD}18A_$3#;n)8Sk2njIQG5?fdv>Lv-#J)`K_ma{b8am`?N=aXP1luWugji|5gfw@!FE2eWSe7r*ZEp z9a+1s6I1u7^&%o-%zl!t>>bUdQIqiCl&{vc?KiK!QqH!|{@BNdqh%-EAN|We{F#q@ zrtP_x^VZgUiqPsLWk0O=%V>5?mGj5<1r1YEcyqC*W-<_RPh*roNNlCnZ!htHSRj<^ z0{3I(!|Ap`Vl@!ZV6DJQfLg1PiC`*UOR|=7rK}c9_FgO9)IiWwMN|l+TC!)#ZvqAG zp)taLQv*HbbX{|C|5tSA)xDcl%HN z*&kf$=Z{`^`0(vpi6L`kc#|QNq99x;AJ7o0+c4CEVr~X=WfoPs!Ut8QJ!My3-a8!V z4W&$380-27vd-=iWQ&dx2W8o4*P{oEvdyGUysPm`VMMh8D>#D@&SXvwlXgt669T1_ z294&x!ke6usA~|lHd(bYVNg}olu}B`nzSaQU_u_&AZo3QR`(PpKcyNUrWS|GyjWV- zksKtF=sZWW-Tn59Uw-`apMK=QhdMM@zxUc}&%OB5ANf?-KU($uY+if1A0XI8vT2%re}PpNQ3Vo3??QGMH9uEJg3hM*6{vLk zYKqE8UBl4#;ju1zGC?M?9;+TFHP)t*F(xwHSq59({pvsP)i67I z?sxv%-+s8g@W}3E9uCpxZfln1hqgEGoVB1q#F>}NTA$~*)s9F+%rsf{>$h%7Vl=c{wL+yGJ zj33(MykkuD+zx8MSoiXnE)pOaGfl(laOJ&NBF3Jj_lR+hO)LFyIn=Z#NT|?so&Te% zwW-eO0enI!H6oJx!E^Rf*6BkhUD^$(DX`Hd>@@xRejxXhnBYS$w+_414k`>_cm7Au zedWhL|Kkrn@kl?{7r*_jm;dO?fBREk&_yuhs1)0tQw2W~4`HOjE8#ykC9r4|G)+Jy4c|ud;|Z1E3K~l>iM{usd}7 zH}@&WM5Zb?cddaGP}_$43oYJk&10UVNs^3>NtdK+LZ$eW#Xd!7as0x%mSW`|n3xYG z4}-PIrP@uRHRB>M^}OLGZ;1eFmEBDxHFD8WJG)M$Cm?`iiA2vg_7$RL4RCY0_riPM z|H41`TZe7D_R_0=_3OX)%OCl|j_!B_S#rOewWed#S1~nr+I)S`4WNXGx!@=yLm(lf zL<$&H$s(~*TqxW5XaJZ(beeY^?ZAyM6}T{xc83FR9ZHhbZqlyRlw#JD)=H8rq(DX2 zCCRw?s_lR(EXC2MNgimwq0000bbVXQnWMOn=I&E)c zX=ZrMmNW?^G=Z*l-Kcrq+8crh$7cri)( RrnCS6002ovPDHLkV1hYARmT7T literal 0 HcmV?d00001 diff --git a/stories/src/accessible/blur-context.js b/stories/src/accessible/blur-context.js new file mode 100644 index 0000000000..cff9bbc5c8 --- /dev/null +++ b/stories/src/accessible/blur-context.js @@ -0,0 +1,6 @@ +// @flow +import React from 'react'; + +const BlurContext = React.createContext(0); + +export default BlurContext; diff --git a/stories/src/accessible/task-app.jsx b/stories/src/accessible/task-app.jsx index fff02250ba..b1f9fc5250 100644 --- a/stories/src/accessible/task-app.jsx +++ b/stories/src/accessible/task-app.jsx @@ -6,6 +6,7 @@ import initial from './data'; import reorder from '../reorder'; import { grid } from '../constants'; import { DragDropContext } from '../../../src'; +import BlurContext from './blur-context'; import type { Announce, DragStart, @@ -27,9 +28,6 @@ const Container = styled.div` flex-direction: column; align-items: center; `; -const Blur = styled.div` - filter: blur(${props => props.amount}px); -`; const BlurControls = styled.div` display: flex; @@ -126,9 +124,9 @@ export default class TaskApp extends Component<*, State> { onDragEnd={this.onDragEnd} > - + - +