├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── babel.config.js ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── dispatcher.spec.js │ ├── intersection.spec.js │ ├── listUtil.spec.js │ └── util.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── package.json ├── release-notes.md ├── rollup.config.js ├── src ├── action.js ├── constants.js ├── featureFlags.js ├── helpers │ ├── aria.js │ ├── dispatcher.js │ ├── intersection.js │ ├── listUtil.js │ ├── multiScroller.js │ ├── observer.js │ ├── scroller.js │ ├── styler.js │ ├── svelteNodeClone.js │ └── util.js ├── index.js ├── keyboardAction.js ├── pointerAction.js └── wrappers │ ├── simpleStore.js │ └── withDragHandles.js ├── typings └── index.d.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in the root 2 | /* 3 | # Un-ignore all of subtree 4 | !/src/ 5 | !.gitignore -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true 5 | }, 6 | extends: "eslint:recommended", 7 | parserOptions: { 8 | ecmaVersion: 12, 9 | sourceType: "module" 10 | }, 11 | rules: {} 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | *.iml 5 | node_modules 6 | /dist/ 7 | cypress/videos 8 | cypress/screenshots 9 | 10 | /.eslintcache 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | *.iml 5 | node_modules 6 | /dist/ 7 | cypress/videos 8 | cypress/screenshots 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "tabWidth": 4, 4 | "trailingComma": "none", 5 | "bracketSpacing": false, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Isaac Hagoel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SVELTE DND ACTION [![Known Vulnerabilities](https://snyk.io/test/github/isaacHagoel/svelte-dnd-action/badge.svg?targetFile=package.json)](https://snyk.io/test/github/isaacHagoel/svelte-dnd-action?targetFile=package.json) 2 | 3 | This is a feature-complete implementation of drag and drop for Svelte using a custom action. It supports almost every imaginable drag and drop use-case, any input device and is fully accessible.
4 | It requires very minimal configuration, while offering a rich set of primitives that allow overriding basically any of its default behaviours (using the handler functions).

5 | See full features list below.
6 | 7 | ![dnd_demo2](https://user-images.githubusercontent.com/20507787/81682367-267eb780-9498-11ea-8dbc-5c9582033522.gif) 8 | 9 | [Play with this example in the REPL](https://svelte.dev/playground/e2ef044af75c4b16b424b8219fb31fd9?version=3). 10 | 11 | ### Current Status 12 | 13 | The library is **production ready**, and I am in the process of integrating it into several production systems that will be used at scale. 14 | It is being actively maintained. 15 | **I am doing my best to avoid breaking-changes and keep the API stable**. 16 | 17 | ### Features 18 | 19 | - Awesome drag and drop with minimal fuss 20 | - Supports horizontal, vertical or any other type of container (it doesn't care much about the shape) 21 | - Supports nested dnd-zones (draggable containers with other draggable elements inside, think Trello) 22 | - Rich animations (can be opted out of) 23 | - Touch support 24 | - Define what can be dropped where (dnd-zones optionally have a "type") 25 | - Scroll dnd-zones (of the relevant "type"), parent containers that contains them and/or the window horizontally or vertically by placing the dragged element next to the edge 26 | - Supports advanced use-cases such as various flavours of copy-on-drag and custom drag handles (see examples below) 27 | - Performant and small footprint (no external dependencies, no fluff code) 28 | - Fully accessible (beta) - keyboard support, aria attributes and assistive instructions for screen readers 29 | 30 | ### Why a svelte action rather than a higher order component? 31 | 32 | A custom action allows for a much more elegant API (no slot props thanks god) as well as more control.
33 | If you prefer a generic dnd list component that accepts different child components as your abstraction, you can very easily wrap this library with one (see [here](https://svelte.dev/playground/028674733f67409c94bd52995d5906f1?version=3)). 34 | 35 | ### Installation 36 | 37 | **Pre-requisites**: svelte-3 (>=3.23.0) 38 | 39 | ```bash 40 | yarn add -D svelte-dnd-action 41 | ``` 42 | 43 | or 44 | 45 | ```bash 46 | npm install --save-dev svelte-dnd-action 47 | ``` 48 | 49 | ### Usage 50 | 51 | ```html 52 |
53 | {#each myItems as item(item.id)} 54 |
this is now a draggable div that can be dropped in other dnd zones
55 | {/each} 56 |
57 | ``` 58 | 59 | ##### Basic Example: 60 | 61 | ```html 62 | 79 | 80 | 96 |
97 | {#each items as item(item.id)} 98 |
{item.name}
99 | {/each} 100 |
101 | ``` 102 | 103 | ##### Input: 104 | 105 | An options-object with the following attributes: 106 | | Name | Type | Required? | Default Value | Description | 107 | | ------------------------- | -------------- | ------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------ | 108 | | `items` | Array<Object> | Yes. Each object in the array **has to have** an `id` property (key name can be overridden globally) with a unique value (within all dnd-zones of the same type) | N/A | The data array that is used to produce the list with the draggable items (the same thing you run your #each block on). The dndzone should not have children that don't originate in `items` | 109 | | `flipDurationMs` | Number | No | `0` | The same value you give the flip animation on the items (to make them animated as they "make space" for the dragged item). Set to zero if you dont want animations, if unset it defaults to 100ms | 110 | | `type` | String | No | Internal | dnd-zones that share the same type can have elements from one dragged into another. By default, all dnd-zones have the same type | 111 | | `dragDisabled` | Boolean | No | `false` | Setting it to true will make it impossible to drag elements out of the dnd-zone. You can change it at any time, and the zone will adjust on the fly | 112 | | `morphDisabled` | Boolean | No | `false` | By default, when dragging over a zone, the dragged element is morphed to look like it would if dropped. You can prevent it by setting this option. | 113 | | `dropFromOthersDisabled` | Boolean | No | `false` | Setting it to true will make it impossible to drop elements from other dnd-zones of the same type. Can be useful if you want to limit the max number of items for example. You can change it at any time, and the zone will adjust on the fly | 114 | | `zoneTabIndex` | Number | No | `0` | Allow user to set custom tabindex to the list container when not dragging. Can be useful if you want to make the screen reader to skip the list container. You can change it at any time. | 115 | | `zoneItemTabIndex` | Number | No | `0` | Allow user to set custom tabindex to the list container items when not dragging. Can be useful if you use [Drag handles](https://github.com/isaacHagoel/svelte-dnd-action#examples-and-recipes). You can change it at any time. | 116 | | `dropTargetStyle` | Object<String> | No | `{outline: 'rgba(255, 255, 102, 0.7) solid 2px'}` | An object of styles to apply to the dnd-zone when items can be dragged into it. Note: the styles override any inline styles applied to the dnd-zone. When the styles are removed, any original inline styles will be lost | 117 | | `dropTargetClasses`| Array<String> | No | `[]` | A list of classes to apply to the dnd-zone when items can be dragged into it. Note: make sure the classes you use are global. | 118 | | `transformDraggedElement` | Function | No | `() => {}` | A function that is invoked when the draggable element enters the dnd-zone or hover overs a new index in the current dnd-zone.
Signature:
function(element, data, index) {}
**element**: The dragged element.
**data**: The data of the item from the items array.
**index**: The index the dragged element will become in the new dnd-zone.

This allows you to override properties on the dragged element, such as innerHTML to change how it displays. If what you are after is altering styles, do it to the children, not to the dragged element itself | 119 | | `autoAriaDisabled` | Boolean | No | `false` | Setting it to true will disable all the automatically added aria attributes and aria alerts (for example when the user starts/ stops dragging using the keyboard).
**Use it only if you intend to implement your own custom instructions, roles and alerts.** In such a case, you might find the exported function `alertToScreenReader(string)` useful. | 120 | | `centreDraggedOnCursor` | Boolean | No | `false` | Setting it to true will cause elements from this dnd-zone to position their center on the cursor on drag start, effectively turning the cursor to the focal point that triggers all the dnd events (ex: entering another zone). Useful for dnd-zones with large items that can be dragged over small items. | 121 | | `dropAnimationDisabled` | Boolean | No | `false` | Setting it to true will disable the animation of the dropped element to its final place. | 122 | 123 | ##### Output: 124 | 125 | The action dispatches two custom events: 126 | 127 | - `consider` - dispatched whenever the dragged element needs to make room for itself in a new position in the items list and when it leaves. The host (your component) is expected to update the items list (you can keep a copy of the original list if you need to) 128 | - `finalize` - dispatched on the target and origin dnd-zones when the dragged element is dropped into position. This is the event you want to use to [save the items to the server](https://svelte.dev/playground/964fdac31cb9496da9ded35002300abb?version=3) for example. 129 | 130 | The expectation is the same for both event handlers - update the list of items. 131 | In both cases the payload (within e.detail) is the same: an object with two attributes: `items` and `info`. 132 | 133 | - `items`: contains the updated items list. 134 | - `info`: This one can be used to achieve very advanced custom behaviours (ex: copy on drag). In most cases, don't worry about it. It is an object with the following properties: 135 | - `trigger`: will be one of the exported list of TRIGGERS (Please import if you plan to use): [DRAG_STARTED, DRAGGED_ENTERED, DRAGGED_ENTERED_ANOTHER, DRAGGED_OVER_INDEX, DRAGGED_LEFT, DRAGGED_LEFT_ALL, DROPPED_INTO_ZONE, DROPPED_INTO_ANOTHER, DROPPED_OUTSIDE_OF_ANY, DRAG_STOPPED]. Most triggers apply to both pointer and keyboard, but some are only relevant for pointer (dragged_entered, dragged_over_index and dragged_left), and some only for keyboard (drag_stopped). 136 | - `id`: the item id of the dragged element 137 | - `source`: will be one of the exported list of SOURCES (Please import if you plan to use): [POINTER, KEYBOARD] 138 | 139 | You have to listen for both events and update the list of items in order for this library to work correctly. 140 | 141 | For advanced use-cases (ex: [custom styling for the placeholder element](https://svelte.dev/playground/9c8db8b91b2142d19dcf9bc963a27838?version=3)) you might also need to import `SHADOW_ITEM_MARKER_PROPERTY_NAME`, which marks the placeholder element that is temporarily added to the list the dragged element hovers over. 142 | For use cases that have recursively nested zones (ex: [crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3)), you might want to import `SHADOW_PLACEHOLDER_ITEM_ID` in order to filter the placeholder out when passing the items in to the nested component. 143 | If you need to manipulate the dragged element either dynamically (and don't want to use the `transformDraggedElement` option), or statically targeting it or its children with CSS, you can import and use `DRAGGED_ELEMENT_ID`; 144 | 145 | ### Accessibility (beta) 146 | 147 | If you want screen-readers to tell the user which item is being dragged and which container it interacts with, **please add `aria-label` on the container and on every draggable item**. The library will take care of the rest. 148 | For example: 149 | 150 | ```html 151 |

{listName}

152 |
153 | {#each items as item(item.id)} 154 |
{item.name}
155 | {/each} 156 |
157 | ``` 158 | 159 | If you don't provide the aria-labels everything will still work, but the messages to the user will be less informative. 160 | _Note_: in general you probably want to use semantic-html (ex: `ol` and `li` elements rather than `section` and `div`) but the library is screen readers friendly regardless (or at least that's the goal :)). 161 | If you want to implement your own custom screen-reader alerts, roles and instructions, you can use the `autoAriaDisabled` options and wire everything up yourself using markup and the `consider` and `finalize` handlers (for example: [unsortable list](https://svelte.dev/playground/e020ea1051dc4ae3ac2b697064f234bc?version=3)). 162 | 163 | ##### Keyboard support 164 | 165 | - Tab into a dnd container to get a description and instructions 166 | - Tab into an item and press the _Space_/_Enter_ key to enter dragging-mode. The reader will tell the user a drag has started. 167 | - Use the _arrow keys_ while in dragging-mode to change the item's position in the list (down and right are the same, up and left are the same). The reader will tell the user about position changes. 168 | - Tab to another dnd container while in dragging-mode in order to move the item to it (the item will be moved to it when it gets focus). The reader will tell the user that item was added to the new list. 169 | - Press _Space_/_Enter_ key while focused on an item, or the _Escape_ key anywhere to exit dragging mode. The reader will tell the user that they are no longer dragging. 170 | - Clicking on another item while in drag mode will make it the new drag target. Clicking outside of any draggable will exit dragging-mode (and tell the user) 171 | - Mouse drag and drop can be preformed independently of keyboard dragging (as in an item can be dragged with the mouse while in or out of keyboard initiated dragging-mode) 172 | - Keyboard drag uses the same `consider` (only on drag start) and `finalize` (every time the item is moved) events but share only some of the `TRIGGERS`. The same handlers should work fine for both. 173 | 174 | ### Drag Handles Support 175 | 176 | Due to popular demand, starting in version 0.9.46 the library exports a wrapper action that greatly improves the ergonomics around using drag handles. 177 | Notes: 178 | 179 | - A draggable item within a `dragHandleZone` would not be draggable unless it has an element that uses the `dragHandle` action inside (doesn't have to be a direct child but has to be inside the bounding rect of the item). 180 | - Don't forget an aria-label on the handle 181 | Usage: 182 | 183 | ```html 184 | 208 | 209 | 227 | 228 |

Drag Handles

229 |

Items can be dragged using the grey handles via mouse, touch or keyboard. The text on the items can be selected without starting a drag

230 |
231 |
232 | {#each items as item (item.id)} 233 |
234 |
235 | {item.text} 236 |
237 | {/each} 238 |
239 | ``` 240 | 241 | ### Examples and Recipes 242 | 243 | - [Super basic, single list, no animation](https://svelte.dev/playground/bbd709b1a00b453e94658392c97a018a?version=3) 244 | - [Super basic, single list, with animation](https://svelte.dev/playground/3d544791e5c24fd4aa1eb983d749f776?version=3) 245 | - [Multiple dndzones, multiple types](https://svelte.dev/playground/4d23eb3b9e184b90b58f0867010ad258?version=3) 246 | - [Board (nested zones and multiple types), scrolling containers, scrolling page](https://svelte.dev/playground/e2ef044af75c4b16b424b8219fb31fd9?version=3) 247 | - [Selectively enable/disable drag/drop](https://svelte.dev/playground/44c9229556f3456e9883c10fc0aa0ee9?version=3) 248 | - [Custom active dropzone styling](https://svelte.dev/playground/4ceecc5bae54490b811bd62d4d613e59?version=3) 249 | - [Customizing the dragged element](https://svelte.dev/playground/438fca28bb1f4eb1b34eff9dc6a728dc?version=3) 250 | - [Styling the dragged element](https://svelte.dev/playground/3d8be94b2bbd407c8a706d5054c8df6a?version=3) 251 | - [Customizing the placeholder(shadow) element](https://svelte.dev/playground/9c8db8b91b2142d19dcf9bc963a27838?version=3) 252 | 253 | - [Copy on drag, simple and Dragula like](https://svelte.dev/playground/924b4cc920524065a637fa910fe10193?version=3) 254 | - [Copy on drop and a drop area with a single slot](https://svelte.dev/playground/b4e120c45c3e48e49a0d637f0cf097d9?version=3) 255 | - [Drag handles using wrapper actions](https://svelte.dev/playground/cc1bc63be7a74830b4c97d428f62054d?version=4.2.17), for nested scenarios (same usage, it "just works"), see [here](https://svelte.dev/playground/4f7cbeb7b11b470b948e9af03b82a073?version=4.2.17) and [here](https://svelte.dev/playground/47c5f52f4c774cad8c367516395c7f99?version=4.2.17) 256 | - [Drag handles - legacy](https://svelte.dev/playground/4949485c5a8f46e7bdbeb73ed565a9c7?version=3), use before version 0.9.46, courtesy of @gleuch 257 | - [Interaction (save/get items) with an asynchronous server](https://svelte.dev/playground/964fdac31cb9496da9ded35002300abb?version=3) 258 | - [Unsortable lists with custom aria instructions](https://svelte.dev/playground/e020ea1051dc4ae3ac2b697064f234bc?version=3) 259 | - [Crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3), courtesy of @zahachtah 260 | - [Generic List Component (Alternative to Slots)](https://svelte.dev/playground/028674733f67409c94bd52995d5906f1?version=3) 261 | - [Maitaining internal scroll poisition on scrollable dragabble](https://svelte.dev/playground/eb2f5988bd2f46488810606c1fb13392?version=3) 262 | - [Scrabble like board using over a 100 single slot dnd-zones](https://svelte.dev/playground/ed2e138417094281be6db1aef23d7859?version=3) 263 | - [Select multiple elements to drag (multi-drag) with mouse or keyboard](https://svelte.dev/playground/c4eb917bb8df42c4b17402a7dda54856?version=3) 264 | 265 | - [Fade in/out but without using Svelte transitions](https://svelte.dev/playground/3f1e68203ef140969a8240eba3475a8d?version=3) 266 | - [Nested fade in/out without using Svelte transitions](https://svelte.dev/playground/49b09aedfe0543b4bc8f575c8dbf9a53?version=3) 267 | 268 | ### Rules/ assumptions to keep in mind 269 | 270 | - Only one element can be dragged in any given time 271 | - The data that represents items within dnd-zones **of the same type** is expected to have the same shape (as in a data object that represents an item in one container can be added to another without conversion). 272 | - Item ids (#each keys) are unique in all dnd containers of the same type. EVERY DRAGGABLE ITEM (passed in through `items`) MUST HAVE AN ID PROPERTY CALLED `id`. You can override it globally if you'd like to use a different key (see below) 273 | - Item ids are provided as the key for the #each block (no keyless each blocks please) 274 | - If you need to make a copy an item, you allocate a new id for the copy upon creation. 275 | - The items in the list that is passed-in are in the same order as the children of the container (i.e the items are rendered in an #each block), and the container has no extra (and no fewer) children. 276 | - Any data that should "survive" when the items are dragged around and dropped should be included in the `items` array that is passed in. 277 | - The host component must refresh the items that are passed in to the custom-action when receiving consider and finalize events (do not omit any handler). 278 | - FYI, the library assumes it is okay to add a temporary item to the items list in any of the dnd-zones while an element is dragged around. 279 | - Svelte's built-in transitions might not play nice with this library. Luckily, it is an easy issue to work around. There are examples above. 280 | 281 | ### Overriding the item id key name 282 | 283 | Sometimes it is useful to use a different key for your items instead of `id`, for example when working with PouchDB which expects `_id`. It can save some annoying conversions back and forth. 284 | In such cases you can import and call `overrideItemIdKeyNameBeforeInitialisingDndZones`. This function accepts one parameter of type `string` which is the new id key name. 285 | For example: 286 | 287 | ```javascript 288 | import {overrideItemIdKeyNameBeforeInitialisingDndZones} from "svelte-dnd-action"; 289 | overrideItemIdKeyNameBeforeInitialisingDndZones("_id"); 290 | ``` 291 | 292 | It applies globally (as in, all of your items everywhere are expected to have a unique identifier with this name). It can only be called when there are no rendered dndzones (I recommend calling it within the top-level 403 |
404 | {#each items as item(item.id)} 405 |
{item.title}
406 | {/each} 407 |
408 | ``` 409 | 410 | #### Custom types with `DndEvent` 411 | 412 | You can use generics to set the type of `items` you are expecting in `DndEvent`. Simply add a type to it like so: `DndEvent`. For example: 413 | 414 | ```html 415 | 436 | ``` 437 | 438 | ##### Svelte 5: 439 | 440 | Svelte 5 prefers `onconsider` and `onfinalize` (over `on:consider` and `on:finalize`) but works both ways as long as it's consistent within a file. 441 | 442 | ### Nested Zones Optional Optimization (experimental) 443 | 444 | This is an experimental feature added in version 0.9.29. If you have multiple levels of nesting, the lib might do unnecessary work when dragging an element that has nested zones inside. 445 | Specifically, it allows nested zones within the shadow element (the placeholder under the dragged element) to register and destroy. 446 | This is because Svelte calls nested actions before the parent action (opposite to the rendering order). 447 | You can use a data attribute **on the items** to help the lib prevent this: `data-is-dnd-shadow-item-hint={item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} ` 448 | Starting with version 0.9.42. if you use the hint make sure to include it in the key you provide in your each block e.g: 449 | 450 | ```sveltehtml 451 | {#each columnItems as column (`${column._id}${column[SHADOW_ITEM_MARKER_PROPERTY_NAME] ? "_" + column[SHADOW_ITEM_MARKER_PROPERTY_NAME] : ""}`)} 452 | ... 453 | {/each} 454 | ``` 455 | 456 | #### Simplified Example (just shows where to place the attribute): 457 | 458 | ```html 459 | 463 |
464 |
items = e.detail.items} on:finalize={e => items = e.detail.items}> 465 | {#each items as item (item.id)} 466 |
467 |

{item.title}

468 | 469 |
470 | {/each} 471 |
472 |
473 | ``` 474 | 475 | ### Contributing [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/isaacHagoel/svelte-dnd-action/issues) 476 | 477 | There is still quite a lot to do. If you'd like to contribute please get in touch (raise an issue or comment on an existing one). 478 | Ideally, be specific about which area you'd like to help with. 479 | Thank you for reading :) 480 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel is only used in order to make the tests work 2 | module.exports = { 3 | presets: [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | targets: { 8 | node: "current" 9 | } 10 | } 11 | ] 12 | ] 13 | }; 14 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/integration/dispatcher.spec.js: -------------------------------------------------------------------------------- 1 | import {SOURCES, TRIGGERS} from "../../src"; 2 | import {dispatchFinalizeEvent, dispatchConsiderEvent} from "../../src/helpers/dispatcher"; 3 | 4 | describe("dispatcher", () => { 5 | let divEl = document.createElement("div"); 6 | let items; 7 | let info; 8 | beforeEach(() => { 9 | items = []; 10 | info = {}; 11 | }); 12 | it("honors contract - finalize", () => { 13 | divEl.addEventListener("finalize", e => { 14 | items = e.detail.items; 15 | info = e.detail.info; 16 | }); 17 | const myItems = [1, 2]; 18 | const myInfo = {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: "someId", source: SOURCES.POINTER}; 19 | dispatchFinalizeEvent(divEl, myItems, myInfo); 20 | expect(items).to.deep.equal(myItems); 21 | expect(info).to.deep.equal(myInfo); 22 | }); 23 | it("honors contract - consider", () => { 24 | divEl.addEventListener("consider", e => { 25 | items = e.detail.items; 26 | info = e.detail.info; 27 | }); 28 | const myItems = [3, 4]; 29 | const myInfo = {trigger: TRIGGERS.DRAGGED_ENTERED, id: "someId", source: SOURCES.KEYBOARD}; 30 | dispatchConsiderEvent(divEl, myItems, myInfo); 31 | expect(items).to.deep.equal(myItems); 32 | expect(info).to.deep.equal(myInfo); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /cypress/integration/intersection.spec.js: -------------------------------------------------------------------------------- 1 | import {isCenterOfAInsideB, calcDistanceBetweenCenters, isElementOffDocument} from "../../src/helpers/intersection"; 2 | 3 | function makeDiv(widthPx = 50, heightPx = 50) { 4 | const el = document.createElement("div"); 5 | el.style.width = `${widthPx}px`; 6 | el.style.height = `${heightPx}px`; 7 | return el; 8 | } 9 | 10 | describe("intersection", () => { 11 | describe("isCenterOfAInsideB", () => { 12 | it("center is inside", () => { 13 | const el = makeDiv(50, 50); 14 | document.body.style.width = "1000px"; 15 | document.body.style.height = "1000px"; 16 | document.body.appendChild(el); 17 | expect(isCenterOfAInsideB(el, document.body)).to.equal(true); 18 | }); 19 | it("center is outside", () => { 20 | const elA = makeDiv(); 21 | const elB = makeDiv(); 22 | document.body.appendChild(elA); 23 | document.body.appendChild(elB); 24 | expect(isCenterOfAInsideB(elA, elB)).to.equal(false); 25 | }); 26 | }); 27 | 28 | describe("calcDistanceBetweenCenters", () => { 29 | it("distance from self is zero", () => { 30 | const el = makeDiv(); 31 | document.body.appendChild(el); 32 | expect(calcDistanceBetweenCenters(el, el)).to.equal(0); 33 | }); 34 | it("calculates distance correctly", () => { 35 | const elA = makeDiv(80, 60); 36 | const elB = makeDiv(40, 30); 37 | elA.style.position = "relative"; 38 | elB.style.position = "absolute"; 39 | elB.top = 0; 40 | elB.left = 0; 41 | document.body.appendChild(elA); 42 | elA.appendChild(elB); 43 | expect(calcDistanceBetweenCenters(elA, elB)).to.equal(25); 44 | expect(calcDistanceBetweenCenters(elB, elA)).to.equal(25); 45 | }); 46 | }); 47 | 48 | describe("isElementOffDocument", () => { 49 | before(() => { 50 | document.body.style.width = "100vw"; 51 | document.body.style.height = "100vh"; 52 | }); 53 | it("returns false when element is inside", () => { 54 | const el = makeDiv(50, 50); 55 | document.body.appendChild(el); 56 | expect(isElementOffDocument(el)).to.equal(false); 57 | }); 58 | it("returns false when partially outside to the left", () => { 59 | const el = makeDiv(50, 50); 60 | el.style.position = "fixed"; 61 | el.style.top = "-30px"; 62 | el.style.left = "-45px"; 63 | document.body.appendChild(el); 64 | expect(isElementOffDocument(el)).to.equal(false); 65 | }); 66 | it("returns true when fully outside to the right", () => { 67 | const el = makeDiv(50, 50); 68 | el.style.position = "fixed"; 69 | el.style.top = "0"; 70 | el.style.right = "51px"; 71 | document.body.appendChild(el); 72 | expect(isElementOffDocument(el)).to.equal(true); 73 | }); 74 | it("returns true when fully outside to the top", () => { 75 | const el = makeDiv(50, 50); 76 | el.style.position = "fixed"; 77 | el.style.top = "-51px"; 78 | el.style.right = "0"; 79 | document.body.appendChild(el); 80 | expect(isElementOffDocument(el)).to.equal(true); 81 | }); 82 | it("returns true when fully outside to the bottom", () => { 83 | const el = makeDiv(50, 50); 84 | el.style.position = "fixed"; 85 | el.style.bottom = "51px"; 86 | el.style.left = "80px"; 87 | document.body.appendChild(el); 88 | expect(isElementOffDocument(el)).to.equal(true); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /cypress/integration/listUtil.spec.js: -------------------------------------------------------------------------------- 1 | import {findWouldBeIndex} from "../../src/helpers/listUtil"; 2 | 3 | describe("listUtil", () => { 4 | describe("findWouldBeIndex", () => { 5 | let containerEl, draggedEl; 6 | before(() => { 7 | document.body.style.height = "2000px"; 8 | document.body.style.width = "2000px"; 9 | 10 | containerEl = document.createElement("section"); 11 | containerEl.style.height = "500px"; 12 | containerEl.style.width = "100px"; 13 | containerEl.style.position = "fixed"; 14 | containerEl.style.top = "0"; 15 | containerEl.style.left = "0"; 16 | document.body.appendChild(containerEl); 17 | function addListItem() { 18 | const divEl = document.createElement("div"); 19 | divEl.style.height = "100px"; 20 | divEl.style.width = "50px"; 21 | containerEl.appendChild(divEl); 22 | } 23 | addListItem(); 24 | addListItem(); 25 | addListItem(); 26 | 27 | draggedEl = document.createElement("div"); 28 | draggedEl.style.height = "100px"; 29 | draggedEl.style.width = "50px"; 30 | draggedEl.style.position = "fixed"; 31 | document.body.appendChild(draggedEl); 32 | }); 33 | beforeEach(() => { 34 | draggedEl.style.top = "0"; 35 | draggedEl.style.left = "0"; 36 | }); 37 | it("returns null when element is outside of containers", () => { 38 | draggedEl.style.top = "600px"; 39 | draggedEl.style.left = "0"; 40 | expect(findWouldBeIndex(draggedEl, containerEl)).to.equal(null); 41 | }); 42 | it("works correctly, not proximity based", () => { 43 | draggedEl.style.top = "150px"; 44 | draggedEl.style.left = "5px"; 45 | expect(findWouldBeIndex(draggedEl, containerEl)).to.deep.equal({index: 1, isProximityBased: false}); 46 | }); 47 | it("works correctly, proximity based", () => { 48 | draggedEl.style.top = "450px"; 49 | draggedEl.style.left = "5px"; 50 | expect(findWouldBeIndex(draggedEl, containerEl)).to.deep.equal({index: 2, isProximityBased: true}); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /cypress/integration/util.spec.js: -------------------------------------------------------------------------------- 1 | import {areArraysShallowEqualSameOrder, areObjectsShallowEqual, getDepth} from "../../src/helpers/util"; 2 | import {printDebug, setDebugMode} from "../../src/constants"; 3 | 4 | describe("util", () => { 5 | describe("getDepth", () => { 6 | it("get correct depth", () => { 7 | const div = document.createElement("div"); 8 | const p = document.createElement("p"); 9 | const section = document.createElement("section"); 10 | const div2 = document.createElement("div"); 11 | document.body.appendChild(div); 12 | div.appendChild(p); 13 | div.appendChild(section); 14 | section.appendChild(div2); 15 | expect(getDepth(div)).to.equal(1); 16 | expect(getDepth(p)).to.equal(2); 17 | expect(getDepth(section)).to.equal(2); 18 | expect(getDepth(div2)).to.equal(3); 19 | }); 20 | }); 21 | describe("areObjectsShallowEqual", () => { 22 | it("equal when both empty", () => { 23 | expect(areObjectsShallowEqual({}, {})).to.equal(true); 24 | }); 25 | it("simple objects equal ", () => { 26 | expect(areObjectsShallowEqual({a: 1, b: 2, c: "lala"}, {b: 2, c: "lala", a: 1})).to.equal(true); 27 | }); 28 | it("not equal with additional entries", () => { 29 | expect(areObjectsShallowEqual({a: 1, b: 2, c: 3}, {b: 2, a: 1})).to.equal(false); 30 | expect(areObjectsShallowEqual({a: 1, b: 2}, {b: 2, a: 1, c: 3})).to.equal(false); 31 | }); 32 | it("not equal same key different value", () => { 33 | expect(areObjectsShallowEqual({a: 1}, {a: 9})).to.equal(false); 34 | }); 35 | it("not equal at all", () => { 36 | expect(areObjectsShallowEqual({a: 1, z: "lala"}, {b: 9, h: 7})).to.equal(false); 37 | }); 38 | }); 39 | describe("debug output can be configured", () => { 40 | let consoleStub = null; 41 | const logStub = message => (consoleStub = message); 42 | const logMessage = () => "some debug message"; 43 | 44 | it("does not log anything by default", () => { 45 | printDebug(logMessage, logStub); 46 | expect(consoleStub).to.equal(null); 47 | }); 48 | it("does log if debugMode is set, stops logging when turned off again", () => { 49 | setDebugMode(true); 50 | printDebug(logMessage, logStub); 51 | expect(consoleStub).to.equal(logMessage()); 52 | consoleStub = null; 53 | setDebugMode(false); 54 | printDebug(logMessage, logStub); 55 | expect(consoleStub).to.equal(null); 56 | }); 57 | }); 58 | describe("areArraysShallowEqual", () => { 59 | it("return true when equal same order", () => { 60 | expect(areArraysShallowEqualSameOrder([1, "hello", null], [1, "hello", null])); 61 | expect(areArraysShallowEqualSameOrder([], [])); 62 | }); 63 | it("return false when equal but different order", () => { 64 | expect(areArraysShallowEqualSameOrder([1, "hello", null], ["hello", 1, null])); 65 | }); 66 | it("return false when different size", () => { 67 | expect(areArraysShallowEqualSameOrder(["hello"], ["hello", 1, null])); 68 | expect(areArraysShallowEqualSameOrder(["hello", 1, null], ["hello"])); 69 | }); 70 | it("return false when different", () => { 71 | expect(areArraysShallowEqualSameOrder([1, "hello", null], ["1", "hello", null])); 72 | expect(areArraysShallowEqualSameOrder([1, 2], [])); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | }; 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-dnd-action", 3 | "description": "*An awesome drag and drop library for Svelte 3 and 4 (not using the browser's built-in dnd, thanks god): Rich animations, nested containers, touch support and more *", 4 | "version": "0.9.61", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/isaacHagoel/svelte-dnd-action.git" 8 | }, 9 | "author": "Isaac Hagoel", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/isaacHagoel/svelte-dnd-action/issues" 13 | }, 14 | "homepage": "https://github.com/isaacHagoel/svelte-dnd-action#readme", 15 | "module": "dist/index.mjs", 16 | "main": "dist/index.js", 17 | "types": "dist/index.d.ts", 18 | "svelte": "src/index.js", 19 | "exports": { 20 | ".": { 21 | "import": { 22 | "types": "./dist/index.d.ts", 23 | "default": "./dist/index.mjs" 24 | }, 25 | "require": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "svelte": "./src/index.js" 30 | } 31 | }, 32 | "scripts": { 33 | "test": "cypress run", 34 | "lint": "eslint .", 35 | "format": "prettier --write .", 36 | "build": "yarn lint && rollup -c", 37 | "prepublishOnly": "yarn build" 38 | }, 39 | "dependencies": {}, 40 | "devDependencies": { 41 | "@babel/core": "^7.9.6", 42 | "@babel/preset-env": "^7.9.6", 43 | "@rollup/plugin-node-resolve": "^6.0.0", 44 | "babel-jest": "^26.0.1", 45 | "cypress": "^4.5.0", 46 | "eslint": "^7.11.0", 47 | "husky": "^4.3.0", 48 | "lint-staged": "^10.5.1", 49 | "prettier": "^2.1.2", 50 | "rollup": "^2.79.2", 51 | "rollup-plugin-babel": "^4.3.2", 52 | "rollup-plugin-copy": "^3.3.0" 53 | }, 54 | "peerDependencies": { 55 | "svelte": ">=3.23.0 || ^5.0.0-next.0" 56 | }, 57 | "keywords": [ 58 | "svelte", 59 | "drag and drop", 60 | "sortable", 61 | "dnd", 62 | "draggable", 63 | "accessible", 64 | "touch" 65 | ], 66 | "files": [ 67 | "src", 68 | "dist" 69 | ], 70 | "husky": { 71 | "hooks": { 72 | "pre-commit": "lint-staged" 73 | } 74 | }, 75 | "lint-staged": { 76 | "*.js": "eslint --cache --fix", 77 | "*.{js,css,md}": "prettier --write" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /release-notes.md: -------------------------------------------------------------------------------- 1 | ## Svelte Dnd Action - Release Notes 2 | 3 | ### [0.9.61](https://github.com/isaacHagoel/svelte-dnd-action/pull/645) 4 | 5 | Bugfix: Fixed an issue in `dragHandle` where clicking and releasing without dragging left `isItemsDragDisabled` as `false`, making the entire element draggable. 6 | 7 | ### [0.9.60](https://github.com/isaacHagoel/svelte-dnd-action/pull/639) 8 | 9 | Bugfix: the touchend listener was emulating a click using the click() function and that broke when the user clicked on an SVG 10 | 11 | ### [0.9.59](https://github.com/isaacHagoel/svelte-dnd-action/pull/638) 12 | 13 | Bugfix: fixed an issue that affected $state (for items) that was introduced by version 0.9.58 14 | 15 | ### [0.9.58](https://github.com/isaacHagoel/svelte-dnd-action/pull/636) 16 | 17 | Svelte 5 $state users (if you store items as $state) - please skip this version! 18 | Bugfix: when the items in the origin zone shrink in height right on drag start, the pointer would find itself outside the dragged element 19 | 20 | ### [0.9.57](https://github.com/isaacHagoel/svelte-dnd-action/pull/629) 21 | 22 | Readme update (`on` vs `on:` handlers in Svelte 5) 23 | 24 | ### [0.9.56](https://github.com/isaacHagoel/svelte-dnd-action/pull/628) 25 | 26 | Fixed dndzones inside an element with the 'popover' attribute 27 | 28 | ### [0.9.55](https://github.com/isaacHagoel/svelte-dnd-action/pull/626) 29 | 30 | Fixed logic that could leave the shadow element invisible after drop if the dom wasn't yet updated to reflect the data list (rare) 31 | 32 | ### [0.9.54](https://github.com/isaacHagoel/svelte-dnd-action/pull/621) 33 | 34 | Readme REPL links updated repl -> playground 35 | 36 | ### [0.9.53](https://github.com/isaacHagoel/svelte-dnd-action/pull/618) 37 | 38 | Added a check to address edge cases where multiScroller is undefined when accessed on destroy 39 | 40 | ### [0.9.52](https://github.com/isaacHagoel/svelte-dnd-action/pull/610) 41 | 42 | Fixed a bug that affected dndzone inside scrollable parents - calculated the target index incorrectly when the element was above a hidden part of the zone 43 | 44 | ### [0.9.51](https://github.com/isaacHagoel/svelte-dnd-action/pull/608) 45 | 46 | Added the option to disable the final drop animation 47 | 48 | ### [0.9.50](https://github.com/isaacHagoel/svelte-dnd-action/pull/598) 49 | 50 | Fixed a bug where library would crash when a store updated "considers" in local storage from another tab 51 | 52 | ### [0.9.49](https://github.com/isaacHagoel/svelte-dnd-action/pull/588) 53 | 54 | Fixed a bug where library would crash if one of the canvas had an empty size 55 | 56 | ### [0.9.48](https://github.com/isaacHagoel/svelte-dnd-action/pull/582) 57 | 58 | Fixed a bug where the scroller would stay active and scroll the page after drop 59 | 60 | ### [0.9.47](https://github.com/isaacHagoel/svelte-dnd-action/pull/578) 61 | 62 | Added examples to the README (for the drag handles wrapper actions). 63 | 64 | ### [0.9.46](https://github.com/isaacHagoel/svelte-dnd-action/pull/576) 65 | 66 | Added two wrapper actions (`dragHandleZone` and `dragHandle`) to make using drag handle easy. 67 | 68 | ### [0.9.45](https://github.com/isaacHagoel/svelte-dnd-action/pull/573) 69 | 70 | Bug fix - calling transformDraggedElement after the element was morphed so that changes transform makes aren't overridden by the morphing. 71 | 72 | ### [0.9.44](https://github.com/isaacHagoel/svelte-dnd-action/pull/567) 73 | 74 | Allows Svelte 5.0.0-next as peer dependency. 75 | 76 | ### [0.9.43](https://github.com/isaacHagoel/svelte-dnd-action/pull/556) 77 | 78 | Fixes an issue on some touch devices, where attempting to drag an item causes the page to scroll. 79 | 80 | ### [0.9.42](https://github.com/isaacHagoel/svelte-dnd-action/pull/553) 81 | 82 | Fixes that won't affect most use cases (but do affect recursive nesting). 83 | Fixed updating the items config prior to configure being called. 84 | Restored using the real id throughout the drag operation after the initial frame to prevent issues from implementations relying on it. 85 | This affects the each loop key should be set up when using `data-is-dnd-shadow-item-hint` (see README). 86 | 87 | ### [0.9.41](https://github.com/isaacHagoel/svelte-dnd-action/pull/549) 88 | 89 | The library can now scroll dropzones and any scrollable element that contains dropzones inside, including the window. 90 | This happens when the mouse pointer is near one of the edges of a scrollable container during drag. 91 | 92 | ### [0.9.40](https://github.com/isaacHagoel/svelte-dnd-action/pull/542) 93 | 94 | Added custom events typings with generics to support TypeScript out of the box. 95 | 96 | ### [0.9.39](https://github.com/isaacHagoel/svelte-dnd-action/pull/538) 97 | 98 | Updated README to help set up sveltekit + typescript 99 | 100 | ### [0.9.38](https://github.com/isaacHagoel/svelte-dnd-action/pull/533) 101 | 102 | Added fault tolerance for use cases in which the user removes the shadow item from the list (e.g zones with limited slots) 103 | 104 | ### [0.9.37](https://github.com/isaacHagoel/svelte-dnd-action/pull/532) 105 | 106 | Added support for class instances as list items 107 | 108 | ### [0.9.36](https://github.com/isaacHagoel/svelte-dnd-action/pull/528) 109 | 110 | Added `import` and `require` to the export block in package.json so that types are properly resolved. 111 | 112 | ### [0.9.35](https://github.com/isaacHagoel/svelte-dnd-action/pull/527) 113 | 114 | Added an export block to package.json to remove a Svelte 5 (actually vite) warning about the deprecated "svelte" entry 115 | 116 | ### [0.9.34](https://github.com/isaacHagoel/svelte-dnd-action/pull/524) 117 | 118 | Don't use this version please. It has a silly mistake that cause an error with Sveltekit 2 119 | 120 | ### [0.9.33](https://github.com/isaacHagoel/svelte-dnd-action/pull/499) 121 | 122 | bugfix - now works properly inside a `` element 123 | 124 | ### [0.9.32](https://github.com/isaacHagoel/svelte-dnd-action/pull/517) 125 | 126 | Fixed canvas content not getting cloned on dragged node. 127 | 128 | ### [0.9.31](https://github.com/isaacHagoel/svelte-dnd-action/pull/496) 129 | 130 | Introduce zoneItemTabindex - It allows the user to set custom tabindex to the list container items when not dragging. Can be useful if you use [Drag handles](https://github.com/isaacHagoel/svelte-dnd-action#examples-and-recipes) 131 | 132 | ### [0.9.30](https://github.com/isaacHagoel/svelte-dnd-action/pull/493) 133 | 134 | This version introduces a way for the user to set the flipdurationms to 0, and have it actually have 0 animation (20ms or 1 frame animation). Useful for gaming 135 | as before the 100ms minimum made it so that it was too slow. 136 | 137 | ### [0.9.29](https://github.com/isaacHagoel/svelte-dnd-action/pull/488) 138 | 139 | This version addresses some issues around nested zones. By-default the shadow element now has a temporary id until it's dropped. 140 | This version also adds the option to provide a hint data attribute (`data-is-dnd-shadow-item-hint`) that helps the lib optimise when there is a lot of nesting (see readme). 141 | 142 | ### [0.9.28](https://github.com/isaacHagoel/svelte-dnd-action/pull/484) 143 | 144 | A revert of the problematic part in 0.9.27. This version is functionally equal to 0.9.26 145 | 146 | ### [0.9.27](https://github.com/isaacHagoel/svelte-dnd-action/pull/481) 147 | 148 | PLEASE DON'T USE THIS VERSION 149 | An unsuccessful attempt to support for dropzone being added mid-drag. It breaks in nested scenarios. 150 | 151 | ### [0.9.26](https://github.com/isaacHagoel/svelte-dnd-action/pull/476) 152 | 153 | Readme typo fix in an example: setFeatueFlag -> setFeatureFlag 154 | 155 | ### [0.9.25](https://github.com/isaacHagoel/svelte-dnd-action/pull/473) 156 | 157 | Made the fix that was introduced in version 0.9.23 available via feature flag but inactive by default 158 | 159 | ### [0.9.24](https://github.com/isaacHagoel/svelte-dnd-action/pull/459) 160 | 161 | Updated readme with Svelte 4 types configuration 162 | 163 | ### [0.9.23](https://github.com/isaacHagoel/svelte-dnd-action/pull/457) 164 | 165 | Fix morphing when within css grid 166 | 167 | ### [0.9.22](https://github.com/isaacHagoel/svelte-dnd-action/pull/410) 168 | 169 | Fix repl examples in Readme. Add svelte >=3.23.0 as peerDependency 170 | 171 | ### [0.9.21](https://github.com/isaacHagoel/svelte-dnd-action/pull/405) 172 | 173 | transformDraggedElement is called even if morphing is disabled and a bug that has to do with morphing is now fixed (it was moving the element before styling it) 174 | 175 | ### [0.9.20](https://github.com/isaacHagoel/svelte-dnd-action/pull/401) 176 | 177 | update README to fix global.d.ts example 178 | 179 | ### [0.9.19](https://github.com/isaacHagoel/svelte-dnd-action/pull/382) 180 | 181 | enhancement: DndEvent now allows the use of generics. 182 | 183 | ### [0.9.18](https://github.com/isaacHagoel/svelte-dnd-action/pull/365) 184 | 185 | fix: if a drop zone is removed mid-drag it was causing the lib to throw errors 186 | 187 | ### [0.9.17](https://github.com/isaacHagoel/svelte-dnd-action/pull/320) 188 | 189 | fix: dropdowns (select elements) will now maintain their value during drag 190 | 191 | ### [0.9.16](https://github.com/isaacHagoel/svelte-dnd-action/pull/356) 192 | 193 | fixed a bug that made dropTargetClasses and dropTarget styles work incorrectly when applied to nested zones 194 | 195 | ### [0.9.15](https://github.com/isaacHagoel/svelte-dnd-action/pull/350) 196 | 197 | made the aria support more friendly for multi-page apps (ex: SvelteKit) by having the lib lazy init and clean up the aria divs when the last instance is removed 198 | 199 | ### [0.9.14](https://github.com/isaacHagoel/svelte-dnd-action/pull/340/) 200 | 201 | fixed an issue with items sometimes not making way for the dragged element after autoscroll 202 | 203 | ### [0.9.13](https://github.com/isaacHagoel/svelte-dnd-action/pull/331/) 204 | 205 | fixed the typescript type for dropTargetClasses 206 | 207 | ### [0.9.12](https://github.com/isaacHagoel/svelte-dnd-action/pull/328/) 208 | 209 | added a link example for a basic implementation of multi-drag in the README 210 | 211 | ### [0.9.11](https://github.com/isaacHagoel/svelte-dnd-action/pull/315/) 212 | 213 | added a new option, `zoneTabIndex`, that allows to set custom tabindex in the list container. 214 | 215 | ### 0.9.10 216 | 217 | Please do not use. It was deployed with unintended changes 218 | 219 | ### [0.9.9](https://github.com/isaacHagoel/svelte-dnd-action/pull/301) 220 | 221 | bugfix - works properly when under shadow dom 222 | 223 | ### [0.9.7](https://github.com/isaacHagoel/svelte-dnd-action/pull/290) 224 | 225 | bugfix - works properly now when dropFromOtherDisabled is set to true while the shadow element is in the zone 226 | 227 | ### [0.9.5](https://github.com/isaacHagoel/svelte-dnd-action/pull/271) 228 | 229 | added a new option, `morphDisabled`, that allows to disable morphing of dragged item. 230 | 231 | ### [0.9.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/274) 232 | 233 | bug fix - not crashing when a new dnd zone is created mid drag 234 | 235 | ### [0.9.3](https://github.com/isaacHagoel/svelte-dnd-action/pull/273) 236 | 237 | exporting `DRAGGED_ELEMENT_ID` to allow targeting the dragged element and its subtree using CSS or to fetch it with `document.getElementById`. 238 | 239 | ### [0.9.2](https://github.com/isaacHagoel/svelte-dnd-action/pull/264) 240 | 241 | fixed a race condition that could happen under extremely rapid drag-start -> drop while spam-clicking feverishly 242 | 243 | ### [0.9.1](https://github.com/isaacHagoel/svelte-dnd-action/pull/256) 244 | 245 | exporting `SHADOW_PLACEHOLDER_ITEM_ID` for easier filtering in recursive zones use-cases 246 | 247 | ### [0.9.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/250) 248 | 249 | added the `centreDraggedOnCursor` option to deal with zones that have large items (wide, tall or both) in them that can be dragged over much smaller items.
250 | in these cases, having the center of the items (which is the focal point that triggers all dnd events), and the cursor be the same point makes it more intuitive to drag the large items around. 251 | 252 | ### [0.8.6](https://github.com/isaacHagoel/svelte-dnd-action/pull/231) 253 | 254 | fixed an issue when dragging an item on top of a droppedFromItemsDisabled zone (it is treated as outside of any now, as it should) 255 | 256 | ### [0.8.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/226) 257 | 258 | fixed a keyboard related bug - it is now possible to tab back to the dragged item after tabbing to external elements mid drag 259 | 260 | ### [0.8.2](https://github.com/isaacHagoel/svelte-dnd-action/pull/221) 261 | 262 | accessibility features now work when the library is dynamically imported (in other words, keyboard navigation now works in the REPL again). 263 | 264 | ### [0.8.1](https://github.com/isaacHagoel/svelte-dnd-action/pull/220) 265 | 266 | Made `dropTargetClasses` when initiating drag via keyboard. 267 | 268 | ### [v0.8.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/218) 269 | 270 | Added a new option, `dropTargetClasses`, that allows adding global classes to a dnd-zone when it is a potential drop target (during drag). 271 | 272 | ### [v0.7.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/213) 273 | 274 | This release introduces a subtle change to the dragStarted event.
275 | If you are using [Dragula Copy on Drag](https://svelte.dev/playground/924b4cc920524065a637fa910fe10193?version=3.31.2), you will need to update your consider handler (add 1 line of code to remove the newly added shadow placeholder, see linked REPL).
276 | Same goes for the [crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3.31.2) example
277 | Starting with this version, the initial consider event (dragStarted) places a placeholder item with a new id instead of the dragged item in the items list (old behaviour: removing the dragged item from the list altogether). The placeholder is replaced with the real shadow element (the one that has the same id as the original item) in the next event (basically instantly). 278 | This change makes the initial behaviour of large items (relative to their peers) much smoother. 279 | 280 | ### [v0.7.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/202) 281 | 282 | All the changes in this release only affect pointer (mouse/ touch) based drag and drop operations. 283 | It changes some default behaviours (for the better). 284 | 285 | - When an element is being dragged outside of any dnd zone, the placeholder element now appears in the original dnd zone in the original index and indicates where the element would land if dropped. This was added for better UX and to address single sortable list use cases. 286 | - This change includes the introduction of two new triggers, that can be intercepted by the `consider` handler: `DRAGGED_LEFT_ALL` which fires when the placeholder is added to the origin dndzone, and `DRAGGED_ENTERED_ANOTHER` which fires when the placeholder is removed from the origin dnd zone. 287 | - When drag starts - the library now locks the minimum width and height of the origin dropzone for the duration of the drag operation. This is done in order to prevent the container from shrinking and growing jarringly as the element is dragged around. This is especially helpful when the user drags the last element, which in previous versions could make the dndzone shrink underneath such that the dragged element wasn't over it anymore. 288 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import babel from "rollup-plugin-babel"; 3 | import copy from "rollup-plugin-copy"; 4 | import pkg from "./package.json"; 5 | 6 | const name = pkg.name 7 | .replace(/^(@\S+\/)?(svelte-)?(\S+)/, "$3") 8 | .replace(/^\w/, m => m.toUpperCase()) 9 | .replace(/-\w/g, m => m[1].toUpperCase()); 10 | 11 | export default { 12 | input: "src/index.js", 13 | output: [ 14 | {file: pkg.module, format: "es"}, 15 | {file: pkg.main, format: "umd", name} 16 | ], 17 | plugins: [ 18 | babel({ 19 | presets: [ 20 | [ 21 | "@babel/preset-env", 22 | { 23 | modules: false 24 | } 25 | ] 26 | ] 27 | }), 28 | resolve(), 29 | copy({ 30 | targets: [{src: "typings/index.d.ts", dest: "dist/"}] 31 | }) 32 | ] 33 | }; 34 | -------------------------------------------------------------------------------- /src/action.js: -------------------------------------------------------------------------------- 1 | import {dndzone as pointerDndZone} from "./pointerAction"; 2 | import {dndzone as keyboardDndZone} from "./keyboardAction"; 3 | import {ITEM_ID_KEY, SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME} from "./constants"; 4 | import {toString} from "./helpers/util"; 5 | 6 | /** 7 | * A custom action to turn any container to a dnd zone and all of its direct children to draggables 8 | * Supports mouse, touch and keyboard interactions. 9 | * Dispatches two events that the container is expected to react to by modifying its list of items, 10 | * which will then feed back in to this action via the update function 11 | * 12 | * @typedef {object} Options 13 | * @property {array} items - the list of items that was used to generate the children of the given node (the list used in the #each block 14 | * @property {string} [type] - the type of the dnd zone. children dragged from here can only be dropped in other zones of the same type, default to a base type 15 | * @property {number} [flipDurationMs] - if the list animated using flip (recommended), specifies the flip duration such that everything syncs with it without conflict, defaults to zero 16 | * @property {boolean} [dragDisabled] 17 | * @property {boolean} [morphDisabled] - whether dragged element should morph to zone dimensions 18 | * @property {boolean} [dropFromOthersDisabled] 19 | * @property {number} [zoneTabIndex] - set the tabindex of the list container when not dragging 20 | * @property {number} [zoneItemTabIndex] - set the tabindex of the list container items when not dragging 21 | * @property {object} [dropTargetStyle] 22 | * @property {string[]} [dropTargetClasses] 23 | * @property {boolean} [dropAnimationDisabled] - cancels the drop animation to place 24 | * @property {function} [transformDraggedElement] 25 | * @param {HTMLElement} node - the element to enhance 26 | * @param {Options} options 27 | * @return {{update: function, destroy: function}} 28 | */ 29 | export function dndzone(node, options) { 30 | if (shouldIgnoreZone(node)) { 31 | return { 32 | update: () => {}, 33 | destroy: () => {} 34 | }; 35 | } 36 | validateOptions(options); 37 | const pointerZone = pointerDndZone(node, options); 38 | const keyboardZone = keyboardDndZone(node, options); 39 | return { 40 | update: newOptions => { 41 | validateOptions(newOptions); 42 | pointerZone.update(newOptions); 43 | keyboardZone.update(newOptions); 44 | }, 45 | destroy: () => { 46 | pointerZone.destroy(); 47 | keyboardZone.destroy(); 48 | } 49 | }; 50 | } 51 | 52 | /** 53 | * If the user marked something in the ancestry of our node as shadow element, we can ignore it 54 | * We need the user to mark it for us because svelte updates the action from deep to shallow (but renders top down) 55 | * @param {HTMLElement} node 56 | * @return {boolean} 57 | */ 58 | function shouldIgnoreZone(node) { 59 | return !!node.closest(`[${SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME}="true"]`); 60 | } 61 | 62 | function validateOptions(options) { 63 | /*eslint-disable*/ 64 | const { 65 | items, 66 | flipDurationMs, 67 | type, 68 | dragDisabled, 69 | morphDisabled, 70 | dropFromOthersDisabled, 71 | zoneTabIndex, 72 | zoneItemTabIndex, 73 | dropTargetStyle, 74 | dropTargetClasses, 75 | transformDraggedElement, 76 | autoAriaDisabled, 77 | centreDraggedOnCursor, 78 | dropAnimationDisabled, 79 | ...rest 80 | } = options; 81 | /*eslint-enable*/ 82 | if (Object.keys(rest).length > 0) { 83 | console.warn(`dndzone will ignore unknown options`, rest); 84 | } 85 | if (!items) { 86 | throw new Error("no 'items' key provided to dndzone"); 87 | } 88 | const itemWithMissingId = items.find(item => !{}.hasOwnProperty.call(item, ITEM_ID_KEY)); 89 | if (itemWithMissingId) { 90 | throw new Error(`missing '${ITEM_ID_KEY}' property for item ${toString(itemWithMissingId)}`); 91 | } 92 | if (dropTargetClasses && !Array.isArray(dropTargetClasses)) { 93 | throw new Error(`dropTargetClasses should be an array but instead it is a ${typeof dropTargetClasses}, ${toString(dropTargetClasses)}`); 94 | } 95 | if (zoneTabIndex && !isInt(zoneTabIndex)) { 96 | throw new Error(`zoneTabIndex should be a number but instead it is a ${typeof zoneTabIndex}, ${toString(zoneTabIndex)}`); 97 | } 98 | if (zoneItemTabIndex && !isInt(zoneItemTabIndex)) { 99 | throw new Error(`zoneItemTabIndex should be a number but instead it is a ${typeof zoneItemTabIndex}, ${toString(zoneItemTabIndex)}`); 100 | } 101 | } 102 | 103 | function isInt(value) { 104 | return ( 105 | !isNaN(value) && 106 | (function (x) { 107 | return (x | 0) === x; 108 | })(parseFloat(value)) 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import {DRAGGED_ENTERED_EVENT_NAME, DRAGGED_LEFT_EVENT_NAME, DRAGGED_OVER_INDEX_EVENT_NAME} from "./helpers/dispatcher"; 2 | 3 | export const TRIGGERS = { 4 | DRAG_STARTED: "dragStarted", 5 | DRAGGED_ENTERED: DRAGGED_ENTERED_EVENT_NAME, 6 | DRAGGED_ENTERED_ANOTHER: "dragEnteredAnother", 7 | DRAGGED_OVER_INDEX: DRAGGED_OVER_INDEX_EVENT_NAME, 8 | DRAGGED_LEFT: DRAGGED_LEFT_EVENT_NAME, 9 | DRAGGED_LEFT_ALL: "draggedLeftAll", 10 | DROPPED_INTO_ZONE: "droppedIntoZone", 11 | DROPPED_INTO_ANOTHER: "droppedIntoAnother", 12 | DROPPED_OUTSIDE_OF_ANY: "droppedOutsideOfAny", 13 | DRAG_STOPPED: "dragStopped" 14 | }; 15 | 16 | export const SOURCES = { 17 | POINTER: "pointer", 18 | KEYBOARD: "keyboard" 19 | }; 20 | 21 | export const SHADOW_ITEM_MARKER_PROPERTY_NAME = "isDndShadowItem"; 22 | export const SHADOW_ELEMENT_ATTRIBUTE_NAME = "data-is-dnd-shadow-item-internal"; 23 | export const SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME = "data-is-dnd-shadow-item-hint"; 24 | export const SHADOW_PLACEHOLDER_ITEM_ID = "id:dnd-shadow-placeholder-0000"; 25 | export const DRAGGED_ELEMENT_ID = "dnd-action-dragged-el"; 26 | 27 | export let ITEM_ID_KEY = "id"; 28 | let activeDndZoneCount = 0; 29 | export function incrementActiveDropZoneCount() { 30 | activeDndZoneCount++; 31 | } 32 | export function decrementActiveDropZoneCount() { 33 | if (activeDndZoneCount === 0) { 34 | throw new Error("Bug! trying to decrement when there are no dropzones"); 35 | } 36 | activeDndZoneCount--; 37 | } 38 | 39 | /** 40 | * Allows using another key instead of "id" in the items data. This is global and applies to all dndzones. 41 | * Has to be called when there are no rendered dndzones whatsoever. 42 | * @param {String} newKeyName 43 | * @throws {Error} if it was called when there are rendered dndzones or if it is given the wrong type (not a string) 44 | */ 45 | export function overrideItemIdKeyNameBeforeInitialisingDndZones(newKeyName) { 46 | if (activeDndZoneCount > 0) { 47 | throw new Error("can only override the id key before initialising any dndzone"); 48 | } 49 | if (typeof newKeyName !== "string") { 50 | throw new Error("item id key has to be a string"); 51 | } 52 | printDebug(() => ["overriding item id key name", newKeyName]); 53 | ITEM_ID_KEY = newKeyName; 54 | } 55 | 56 | export const isOnServer = typeof window === "undefined"; 57 | 58 | export let printDebug = () => {}; 59 | 60 | /** 61 | * Allows the user to show/hide console debug output 62 | * * @param {boolean} isDebug 63 | */ 64 | export function setDebugMode(isDebug) { 65 | if (isDebug) { 66 | printDebug = (generateMessage, logFunction = console.debug) => { 67 | const message = generateMessage(); 68 | if (Array.isArray(message)) { 69 | logFunction(...message); 70 | } else { 71 | logFunction(message); 72 | } 73 | }; 74 | } else { 75 | printDebug = () => {}; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/featureFlags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {{USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT: string}} 3 | */ 4 | export const FEATURE_FLAG_NAMES = Object.freeze({ 5 | // This flag exists as a workaround for issue 454 (basically a browser bug) - seems like these rect values take time to update when in grid layout. Setting it to true can cause strange behaviour in the REPL for non-grid zones, see issue 470 6 | USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT: "USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT" 7 | }); 8 | 9 | const featureFlagsMap = { 10 | [FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT]: false 11 | }; 12 | 13 | /** 14 | * @param {FEATURE_FLAG_NAMES} flagName 15 | * @param {boolean} flagValue 16 | */ 17 | export function setFeatureFlag(flagName, flagValue) { 18 | if (!FEATURE_FLAG_NAMES[flagName]) 19 | throw new Error(`Can't set non existing feature flag ${flagName}! Supported flags: ${Object.keys(FEATURE_FLAG_NAMES)}`); 20 | featureFlagsMap[flagName] = !!flagValue; 21 | } 22 | 23 | /** 24 | * 25 | * @param {FEATURE_FLAG_NAMES} flagName 26 | * @return {boolean} 27 | */ 28 | export function getFeatureFlag(flagName) { 29 | if (!FEATURE_FLAG_NAMES[flagName]) 30 | throw new Error(`Can't get non existing feature flag ${flagName}! Supported flags: ${Object.keys(FEATURE_FLAG_NAMES)}`); 31 | return featureFlagsMap[flagName]; 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers/aria.js: -------------------------------------------------------------------------------- 1 | import {isOnServer} from "../constants"; 2 | 3 | const INSTRUCTION_IDs = { 4 | DND_ZONE_ACTIVE: "dnd-zone-active", 5 | DND_ZONE_DRAG_DISABLED: "dnd-zone-drag-disabled" 6 | }; 7 | const ID_TO_INSTRUCTION = { 8 | [INSTRUCTION_IDs.DND_ZONE_ACTIVE]: "Tab to one the items and press space-bar or enter to start dragging it", 9 | [INSTRUCTION_IDs.DND_ZONE_DRAG_DISABLED]: "This is a disabled drag and drop list" 10 | }; 11 | 12 | const ALERT_DIV_ID = "dnd-action-aria-alert"; 13 | let alertsDiv; 14 | 15 | function initAriaOnBrowser() { 16 | if (alertsDiv) { 17 | // it is already initialized 18 | return; 19 | } 20 | // setting the dynamic alerts 21 | alertsDiv = document.createElement("div"); 22 | (function initAlertsDiv() { 23 | alertsDiv.id = ALERT_DIV_ID; 24 | // tab index -1 makes the alert be read twice on chrome for some reason 25 | //alertsDiv.tabIndex = -1; 26 | alertsDiv.style.position = "fixed"; 27 | alertsDiv.style.bottom = "0"; 28 | alertsDiv.style.left = "0"; 29 | alertsDiv.style.zIndex = "-5"; 30 | alertsDiv.style.opacity = "0"; 31 | alertsDiv.style.height = "0"; 32 | alertsDiv.style.width = "0"; 33 | alertsDiv.setAttribute("role", "alert"); 34 | })(); 35 | document.body.prepend(alertsDiv); 36 | 37 | // setting the instructions 38 | Object.entries(ID_TO_INSTRUCTION).forEach(([id, txt]) => document.body.prepend(instructionToHiddenDiv(id, txt))); 39 | } 40 | 41 | /** 42 | * Initializes the static aria instructions so they can be attached to zones 43 | * @return {{DND_ZONE_ACTIVE: string, DND_ZONE_DRAG_DISABLED: string} | null} - the IDs for static aria instruction (to be used via aria-describedby) or null on the server 44 | */ 45 | export function initAria() { 46 | if (isOnServer) return null; 47 | if (document.readyState === "complete") { 48 | initAriaOnBrowser(); 49 | } else { 50 | window.addEventListener("DOMContentLoaded", initAriaOnBrowser); 51 | } 52 | return {...INSTRUCTION_IDs}; 53 | } 54 | 55 | /** 56 | * Removes all the artifacts (dom elements) added by this module 57 | */ 58 | export function destroyAria() { 59 | if (isOnServer || !alertsDiv) return; 60 | Object.keys(ID_TO_INSTRUCTION).forEach(id => document.getElementById(id)?.remove()); 61 | alertsDiv.remove(); 62 | alertsDiv = undefined; 63 | } 64 | 65 | function instructionToHiddenDiv(id, txt) { 66 | const div = document.createElement("div"); 67 | div.id = id; 68 | div.innerHTML = `

${txt}

`; 69 | div.style.display = "none"; 70 | div.style.position = "fixed"; 71 | div.style.zIndex = "-5"; 72 | return div; 73 | } 74 | 75 | /** 76 | * Will make the screen reader alert the provided text to the user 77 | * @param {string} txt 78 | */ 79 | export function alertToScreenReader(txt) { 80 | if (isOnServer) return; 81 | if (!alertsDiv) { 82 | initAriaOnBrowser(); 83 | } 84 | alertsDiv.innerHTML = ""; 85 | const alertText = document.createTextNode(txt); 86 | alertsDiv.appendChild(alertText); 87 | // this is needed for Safari 88 | alertsDiv.style.display = "none"; 89 | alertsDiv.style.display = "inline"; 90 | } 91 | -------------------------------------------------------------------------------- /src/helpers/dispatcher.js: -------------------------------------------------------------------------------- 1 | // external events 2 | const FINALIZE_EVENT_NAME = "finalize"; 3 | const CONSIDER_EVENT_NAME = "consider"; 4 | 5 | /** 6 | * @typedef {Object} Info 7 | * @property {string} trigger 8 | * @property {string} id 9 | * @property {string} source 10 | * @param {Node} el 11 | * @param {Array} items 12 | * @param {Info} info 13 | */ 14 | export function dispatchFinalizeEvent(el, items, info) { 15 | el.dispatchEvent( 16 | new CustomEvent(FINALIZE_EVENT_NAME, { 17 | detail: {items, info} 18 | }) 19 | ); 20 | } 21 | 22 | /** 23 | * Dispatches a consider event 24 | * @param {Node} el 25 | * @param {Array} items 26 | * @param {Info} info 27 | */ 28 | export function dispatchConsiderEvent(el, items, info) { 29 | el.dispatchEvent( 30 | new CustomEvent(CONSIDER_EVENT_NAME, { 31 | detail: {items, info} 32 | }) 33 | ); 34 | } 35 | 36 | // internal events 37 | export const DRAGGED_ENTERED_EVENT_NAME = "draggedEntered"; 38 | export const DRAGGED_LEFT_EVENT_NAME = "draggedLeft"; 39 | export const DRAGGED_OVER_INDEX_EVENT_NAME = "draggedOverIndex"; 40 | export const DRAGGED_LEFT_DOCUMENT_EVENT_NAME = "draggedLeftDocument"; 41 | 42 | export const DRAGGED_LEFT_TYPES = { 43 | LEFT_FOR_ANOTHER: "leftForAnother", 44 | OUTSIDE_OF_ANY: "outsideOfAny" 45 | }; 46 | 47 | export function dispatchDraggedElementEnteredContainer(containerEl, indexObj, draggedEl) { 48 | containerEl.dispatchEvent( 49 | new CustomEvent(DRAGGED_ENTERED_EVENT_NAME, { 50 | detail: {indexObj, draggedEl} 51 | }) 52 | ); 53 | } 54 | 55 | /** 56 | * @param containerEl - the dropzone the element left 57 | * @param draggedEl - the dragged element 58 | * @param theOtherDz - the new dropzone the element entered 59 | */ 60 | export function dispatchDraggedElementLeftContainerForAnother(containerEl, draggedEl, theOtherDz) { 61 | containerEl.dispatchEvent( 62 | new CustomEvent(DRAGGED_LEFT_EVENT_NAME, { 63 | detail: {draggedEl, type: DRAGGED_LEFT_TYPES.LEFT_FOR_ANOTHER, theOtherDz} 64 | }) 65 | ); 66 | } 67 | 68 | export function dispatchDraggedElementLeftContainerForNone(containerEl, draggedEl) { 69 | containerEl.dispatchEvent( 70 | new CustomEvent(DRAGGED_LEFT_EVENT_NAME, { 71 | detail: {draggedEl, type: DRAGGED_LEFT_TYPES.OUTSIDE_OF_ANY} 72 | }) 73 | ); 74 | } 75 | export function dispatchDraggedElementIsOverIndex(containerEl, indexObj, draggedEl) { 76 | containerEl.dispatchEvent( 77 | new CustomEvent(DRAGGED_OVER_INDEX_EVENT_NAME, { 78 | detail: {indexObj, draggedEl} 79 | }) 80 | ); 81 | } 82 | export function dispatchDraggedLeftDocument(draggedEl) { 83 | window.dispatchEvent( 84 | new CustomEvent(DRAGGED_LEFT_DOCUMENT_EVENT_NAME, { 85 | detail: {draggedEl} 86 | }) 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/helpers/intersection.js: -------------------------------------------------------------------------------- 1 | // This is based off https://stackoverflow.com/questions/27745438/how-to-compute-getboundingclientrect-without-considering-transforms/57876601#57876601 2 | // It removes the transforms that are potentially applied by the flip animations 3 | /** 4 | * Gets the bounding rect but removes transforms (ex: flip animation) 5 | * @param {HTMLElement} el 6 | * @param {boolean} [onlyVisible] - use the visible rect defaults to true 7 | * @return {{top: number, left: number, bottom: number, right: number}} 8 | */ 9 | export function getBoundingRectNoTransforms(el, onlyVisible = true) { 10 | let ta; 11 | const rect = onlyVisible ? getVisibleRectRecursive(el) : el.getBoundingClientRect(); 12 | const style = getComputedStyle(el); 13 | const tx = style.transform; 14 | 15 | if (tx) { 16 | let sx, sy, dx, dy; 17 | if (tx.startsWith("matrix3d(")) { 18 | ta = tx.slice(9, -1).split(/, /); 19 | sx = +ta[0]; 20 | sy = +ta[5]; 21 | dx = +ta[12]; 22 | dy = +ta[13]; 23 | } else if (tx.startsWith("matrix(")) { 24 | ta = tx.slice(7, -1).split(/, /); 25 | sx = +ta[0]; 26 | sy = +ta[3]; 27 | dx = +ta[4]; 28 | dy = +ta[5]; 29 | } else { 30 | return rect; 31 | } 32 | 33 | const to = style.transformOrigin; 34 | const x = rect.x - dx - (1 - sx) * parseFloat(to); 35 | const y = rect.y - dy - (1 - sy) * parseFloat(to.slice(to.indexOf(" ") + 1)); 36 | const w = sx ? rect.width / sx : el.offsetWidth; 37 | const h = sy ? rect.height / sy : el.offsetHeight; 38 | return { 39 | x: x, 40 | y: y, 41 | width: w, 42 | height: h, 43 | top: y, 44 | right: x + w, 45 | bottom: y + h, 46 | left: x 47 | }; 48 | } else { 49 | return rect; 50 | } 51 | } 52 | 53 | /** 54 | * Gets the absolute bounding rect (accounts for the window's scroll position and removes transforms) 55 | * @param {HTMLElement} el 56 | * @return {{top: number, left: number, bottom: number, right: number}} 57 | */ 58 | export function getAbsoluteRectNoTransforms(el) { 59 | const rect = getBoundingRectNoTransforms(el); 60 | return { 61 | top: rect.top + window.scrollY, 62 | bottom: rect.bottom + window.scrollY, 63 | left: rect.left + window.scrollX, 64 | right: rect.right + window.scrollX 65 | }; 66 | } 67 | 68 | /** 69 | * Gets the absolute bounding rect (accounts for the window's scroll position) 70 | * @param {HTMLElement} el 71 | * @return {{top: number, left: number, bottom: number, right: number}} 72 | */ 73 | export function getAbsoluteRect(el) { 74 | const rect = el.getBoundingClientRect(); 75 | return { 76 | top: rect.top + window.scrollY, 77 | bottom: rect.bottom + window.scrollY, 78 | left: rect.left + window.scrollX, 79 | right: rect.right + window.scrollX 80 | }; 81 | } 82 | 83 | /** 84 | * finds the center :) 85 | * @typedef {Object} Rect 86 | * @property {number} top 87 | * @property {number} bottom 88 | * @property {number} left 89 | * @property {number} right 90 | * @param {Rect} rect 91 | * @return {{x: number, y: number}} 92 | */ 93 | export function findCenter(rect) { 94 | return { 95 | x: (rect.left + rect.right) / 2, 96 | y: (rect.top + rect.bottom) / 2 97 | }; 98 | } 99 | 100 | /** 101 | * @typedef {Object} Point 102 | * @property {number} x 103 | * @property {number} y 104 | * @param {Point} pointA 105 | * @param {Point} pointB 106 | * @return {number} 107 | */ 108 | function calcDistance(pointA, pointB) { 109 | return Math.sqrt(Math.pow(pointA.x - pointB.x, 2) + Math.pow(pointA.y - pointB.y, 2)); 110 | } 111 | 112 | /** 113 | * @param {Point} point 114 | * @param {Rect} rect 115 | * @return {boolean|boolean} 116 | */ 117 | export function isPointInsideRect(point, rect) { 118 | return point.y <= rect.bottom && point.y >= rect.top && point.x >= rect.left && point.x <= rect.right; 119 | } 120 | 121 | /** 122 | * find the absolute coordinates of the center of a dom element 123 | * @param el {HTMLElement} 124 | * @returns {{x: number, y: number}} 125 | */ 126 | export function findCenterOfElement(el) { 127 | return findCenter(getAbsoluteRect(el)); 128 | } 129 | 130 | /** 131 | * @param {HTMLElement} elA 132 | * @param {HTMLElement} elB 133 | * @return {boolean} 134 | */ 135 | export function isCenterOfAInsideB(elA, elB) { 136 | const centerOfA = findCenterOfElement(elA); 137 | const rectOfB = getAbsoluteRectNoTransforms(elB); 138 | return isPointInsideRect(centerOfA, rectOfB); 139 | } 140 | 141 | /** 142 | * @param {HTMLElement|ChildNode} elA 143 | * @param {HTMLElement|ChildNode} elB 144 | * @return {number} 145 | */ 146 | export function calcDistanceBetweenCenters(elA, elB) { 147 | const centerOfA = findCenterOfElement(elA); 148 | const centerOfB = findCenterOfElement(elB); 149 | return calcDistance(centerOfA, centerOfB); 150 | } 151 | 152 | /** 153 | * @param {HTMLElement} el - the element to check 154 | * @returns {boolean} - true if the element in its entirety is off-screen including the scrollable area (the normal dom events look at the mouse rather than the element) 155 | */ 156 | export function isElementOffDocument(el) { 157 | const rect = getAbsoluteRect(el); 158 | return rect.right < 0 || rect.left > document.documentElement.scrollWidth || rect.bottom < 0 || rect.top > document.documentElement.scrollHeight; 159 | } 160 | 161 | function getVisibleRectRecursive(element) { 162 | let rect = element.getBoundingClientRect(); 163 | let visibleRect = { 164 | top: rect.top, 165 | bottom: rect.bottom, 166 | left: rect.left, 167 | right: rect.right 168 | }; 169 | 170 | // Traverse up the DOM hierarchy, checking for scrollable ancestors 171 | let parent = element.parentElement; 172 | while (parent && parent !== document.body) { 173 | let parentRect = parent.getBoundingClientRect(); 174 | 175 | // Check if the parent has a scrollable overflow 176 | const overflowY = window.getComputedStyle(parent).overflowY; 177 | const overflowX = window.getComputedStyle(parent).overflowX; 178 | const isScrollableY = overflowY === "scroll" || overflowY === "auto"; 179 | const isScrollableX = overflowX === "scroll" || overflowX === "auto"; 180 | 181 | // Constrain the visible area to the parent's visible area 182 | if (isScrollableY) { 183 | visibleRect.top = Math.max(visibleRect.top, parentRect.top); 184 | visibleRect.bottom = Math.min(visibleRect.bottom, parentRect.bottom); 185 | } 186 | if (isScrollableX) { 187 | visibleRect.left = Math.max(visibleRect.left, parentRect.left); 188 | visibleRect.right = Math.min(visibleRect.right, parentRect.right); 189 | } 190 | 191 | parent = parent.parentElement; 192 | } 193 | 194 | // Finally, constrain the visible rect to the viewport 195 | visibleRect.top = Math.max(visibleRect.top, 0); 196 | visibleRect.bottom = Math.min(visibleRect.bottom, window.innerHeight); 197 | visibleRect.left = Math.max(visibleRect.left, 0); 198 | visibleRect.right = Math.min(visibleRect.right, window.innerWidth); 199 | 200 | // Return the visible rectangle, ensuring that all values are valid 201 | return { 202 | top: visibleRect.top, 203 | bottom: visibleRect.bottom, 204 | left: visibleRect.left, 205 | right: visibleRect.right, 206 | width: Math.max(0, visibleRect.right - visibleRect.left), 207 | height: Math.max(0, visibleRect.bottom - visibleRect.top) 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /src/helpers/listUtil.js: -------------------------------------------------------------------------------- 1 | import {isCenterOfAInsideB, calcDistanceBetweenCenters, getAbsoluteRectNoTransforms, isPointInsideRect, findCenterOfElement} from "./intersection"; 2 | import {printDebug, SHADOW_ELEMENT_ATTRIBUTE_NAME} from "../constants"; 3 | 4 | let dzToShadowIndexToRect; 5 | 6 | /** 7 | * Resets the cache that allows for smarter "would be index" resolution. Should be called after every drag operation 8 | */ 9 | export function resetIndexesCache() { 10 | printDebug(() => "resetting indexes cache"); 11 | dzToShadowIndexToRect = new Map(); 12 | } 13 | resetIndexesCache(); 14 | 15 | /** 16 | * Caches the coordinates of the shadow element when it's in a certain index in a certain dropzone. 17 | * Helpful in order to determine "would be index" more effectively 18 | * @param {HTMLElement} dz 19 | * @return {number} - the shadow element index 20 | */ 21 | function cacheShadowRect(dz) { 22 | const shadowElIndex = Array.from(dz.children).findIndex(child => child.getAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME)); 23 | if (shadowElIndex >= 0) { 24 | if (!dzToShadowIndexToRect.has(dz)) { 25 | dzToShadowIndexToRect.set(dz, new Map()); 26 | } 27 | dzToShadowIndexToRect.get(dz).set(shadowElIndex, getAbsoluteRectNoTransforms(dz.children[shadowElIndex])); 28 | return shadowElIndex; 29 | } 30 | return undefined; 31 | } 32 | 33 | /** 34 | * @typedef {Object} Index 35 | * @property {number} index - the would be index 36 | * @property {boolean} isProximityBased - false if the element is actually over the index, true if it is not over it but this index is the closest 37 | */ 38 | /** 39 | * Find the index for the dragged element in the list it is dragged over 40 | * @param {HTMLElement} floatingAboveEl 41 | * @param {HTMLElement} collectionBelowEl 42 | * @returns {Index|null} - if the element is over the container the Index object otherwise null 43 | */ 44 | export function findWouldBeIndex(floatingAboveEl, collectionBelowEl) { 45 | if (!isCenterOfAInsideB(floatingAboveEl, collectionBelowEl)) { 46 | return null; 47 | } 48 | const children = collectionBelowEl.children; 49 | // the container is empty, floating element should be the first 50 | if (children.length === 0) { 51 | return {index: 0, isProximityBased: true}; 52 | } 53 | const shadowElIndex = cacheShadowRect(collectionBelowEl); 54 | 55 | // the search could be more efficient but keeping it simple for now 56 | // a possible improvement: pass in the lastIndex it was found in and check there first, then expand from there 57 | for (let i = 0; i < children.length; i++) { 58 | if (isCenterOfAInsideB(floatingAboveEl, children[i])) { 59 | const cachedShadowRect = dzToShadowIndexToRect.has(collectionBelowEl) && dzToShadowIndexToRect.get(collectionBelowEl).get(i); 60 | if (cachedShadowRect) { 61 | if (!isPointInsideRect(findCenterOfElement(floatingAboveEl), cachedShadowRect)) { 62 | return {index: shadowElIndex, isProximityBased: false}; 63 | } 64 | } 65 | return {index: i, isProximityBased: false}; 66 | } 67 | } 68 | // this can happen if there is space around the children so the floating element has 69 | //entered the container but not any of the children, in this case we will find the nearest child 70 | let minDistanceSoFar = Number.MAX_VALUE; 71 | let indexOfMin = undefined; 72 | // we are checking all of them because we don't know whether we are dealing with a horizontal or vertical container and where the floating element entered from 73 | for (let i = 0; i < children.length; i++) { 74 | const distance = calcDistanceBetweenCenters(floatingAboveEl, children[i]); 75 | if (distance < minDistanceSoFar) { 76 | minDistanceSoFar = distance; 77 | indexOfMin = i; 78 | } 79 | } 80 | return {index: indexOfMin, isProximityBased: true}; 81 | } 82 | -------------------------------------------------------------------------------- /src/helpers/multiScroller.js: -------------------------------------------------------------------------------- 1 | import {makeScroller} from "./scroller"; 2 | import {printDebug} from "../constants"; 3 | import {getDepth} from "./util"; 4 | import {isPointInsideRect} from "./intersection"; 5 | 6 | /** 7 | @typedef {Object} MultiScroller 8 | @property {function():boolean} multiScrollIfNeeded - call this on every "tick" to scroll containers if needed, returns true if anything was scrolled 9 | /** 10 | * Creates a scroller than can scroll any of the provided containers or any of their scrollable parents (including the document's scrolling element) 11 | * @param {HTMLElement[]} baseElementsForScrolling 12 | * @param {function():Point} getPointerPosition 13 | * @return {MultiScroller} 14 | */ 15 | export function createMultiScroller(baseElementsForScrolling = [], getPointerPosition) { 16 | printDebug(() => "creating multi-scroller"); 17 | const scrollingContainersSet = findRelevantScrollContainers(baseElementsForScrolling); 18 | const scrollingContainersDeepToShallow = Array.from(scrollingContainersSet).sort((dz1, dz2) => getDepth(dz2) - getDepth(dz1)); 19 | const {scrollIfNeeded, resetScrolling} = makeScroller(); 20 | 21 | /** 22 | * @return {boolean} - was any container scrolled 23 | */ 24 | function tick() { 25 | const mousePosition = getPointerPosition(); 26 | if (!mousePosition || !scrollingContainersDeepToShallow) { 27 | return false; 28 | } 29 | const scrollContainersUnderCursor = scrollingContainersDeepToShallow.filter( 30 | el => isPointInsideRect(mousePosition, el.getBoundingClientRect()) || el === document.scrollingElement 31 | ); 32 | for (let i = 0; i < scrollContainersUnderCursor.length; i++) { 33 | const scrolled = scrollIfNeeded(mousePosition, scrollContainersUnderCursor[i]); 34 | if (scrolled) { 35 | return true; 36 | } 37 | } 38 | return false; 39 | } 40 | return { 41 | multiScrollIfNeeded: scrollingContainersSet.size > 0 ? tick : () => false, 42 | destroy: () => resetScrolling() 43 | }; 44 | } 45 | 46 | // internal utils 47 | function findScrollableParents(element) { 48 | if (!element) { 49 | return []; 50 | } 51 | const scrollableContainers = []; 52 | let parent = element; 53 | while (parent) { 54 | const {overflow} = window.getComputedStyle(parent); 55 | if (overflow.split(" ").some(o => o.includes("auto") || o.includes("scroll"))) { 56 | scrollableContainers.push(parent); 57 | } 58 | parent = parent.parentElement; 59 | } 60 | return scrollableContainers; 61 | } 62 | function findRelevantScrollContainers(dropZones) { 63 | const scrollingContainers = new Set(); 64 | for (let dz of dropZones) { 65 | findScrollableParents(dz).forEach(container => scrollingContainers.add(container)); 66 | } 67 | // The scrolling element might have overflow visible and still be scrollable 68 | if ( 69 | document.scrollingElement.scrollHeight > document.scrollingElement.clientHeight || 70 | document.scrollingElement.scrollWidth > document.scrollingElement.clientHeight 71 | ) { 72 | scrollingContainers.add(document.scrollingElement); 73 | } 74 | return scrollingContainers; 75 | } 76 | -------------------------------------------------------------------------------- /src/helpers/observer.js: -------------------------------------------------------------------------------- 1 | import {findWouldBeIndex, resetIndexesCache} from "./listUtil"; 2 | import {findCenterOfElement, isElementOffDocument} from "./intersection"; 3 | import { 4 | dispatchDraggedElementEnteredContainer, 5 | dispatchDraggedElementLeftContainerForAnother, 6 | dispatchDraggedElementLeftContainerForNone, 7 | dispatchDraggedLeftDocument, 8 | dispatchDraggedElementIsOverIndex 9 | } from "./dispatcher"; 10 | import {getDepth} from "./util"; 11 | import {printDebug} from "../constants"; 12 | 13 | const INTERVAL_MS = 200; 14 | const TOLERANCE_PX = 10; 15 | let next; 16 | 17 | /** 18 | * Tracks the dragged elements and performs the side effects when it is dragged over a drop zone (basically dispatching custom-events scrolling) 19 | * @param {Set} dropZones 20 | * @param {HTMLElement} draggedEl 21 | * @param {number} [intervalMs = INTERVAL_MS] 22 | * @param {MultiScroller} multiScroller 23 | */ 24 | export function observe(draggedEl, dropZones, intervalMs = INTERVAL_MS, multiScroller) { 25 | // initialization 26 | let lastDropZoneFound; 27 | let lastIndexFound; 28 | let lastIsDraggedInADropZone = false; 29 | let lastCentrePositionOfDragged; 30 | // We are sorting to make sure that in case of nested zones of the same type the one "on top" is considered first 31 | const dropZonesFromDeepToShallow = Array.from(dropZones).sort((dz1, dz2) => getDepth(dz2) - getDepth(dz1)); 32 | 33 | /** 34 | * The main function in this module. Tracks where everything is/ should be a take the actions 35 | */ 36 | function andNow() { 37 | const currentCenterOfDragged = findCenterOfElement(draggedEl); 38 | const scrolled = multiScroller.multiScrollIfNeeded(); 39 | // we only want to make a new decision after the element was moved a bit to prevent flickering 40 | if ( 41 | !scrolled && 42 | lastCentrePositionOfDragged && 43 | Math.abs(lastCentrePositionOfDragged.x - currentCenterOfDragged.x) < TOLERANCE_PX && 44 | Math.abs(lastCentrePositionOfDragged.y - currentCenterOfDragged.y) < TOLERANCE_PX 45 | ) { 46 | next = window.setTimeout(andNow, intervalMs); 47 | return; 48 | } 49 | if (isElementOffDocument(draggedEl)) { 50 | printDebug(() => "off document"); 51 | dispatchDraggedLeftDocument(draggedEl); 52 | return; 53 | } 54 | 55 | lastCentrePositionOfDragged = currentCenterOfDragged; 56 | // this is a simple algorithm, potential improvement: first look at lastDropZoneFound 57 | let isDraggedInADropZone = false; 58 | for (const dz of dropZonesFromDeepToShallow) { 59 | if (scrolled) resetIndexesCache(); 60 | const indexObj = findWouldBeIndex(draggedEl, dz); 61 | if (indexObj === null) { 62 | // it is not inside 63 | continue; 64 | } 65 | const {index} = indexObj; 66 | isDraggedInADropZone = true; 67 | // the element is over a container 68 | if (dz !== lastDropZoneFound) { 69 | lastDropZoneFound && dispatchDraggedElementLeftContainerForAnother(lastDropZoneFound, draggedEl, dz); 70 | dispatchDraggedElementEnteredContainer(dz, indexObj, draggedEl); 71 | lastDropZoneFound = dz; 72 | } else if (index !== lastIndexFound) { 73 | dispatchDraggedElementIsOverIndex(dz, indexObj, draggedEl); 74 | lastIndexFound = index; 75 | } 76 | // we handle looping with the 'continue' statement above 77 | break; 78 | } 79 | // the first time the dragged element is not in any dropzone we need to notify the last dropzone it was in 80 | if (!isDraggedInADropZone && lastIsDraggedInADropZone && lastDropZoneFound) { 81 | dispatchDraggedElementLeftContainerForNone(lastDropZoneFound, draggedEl); 82 | lastDropZoneFound = undefined; 83 | lastIndexFound = undefined; 84 | lastIsDraggedInADropZone = false; 85 | } else { 86 | lastIsDraggedInADropZone = true; 87 | } 88 | next = window.setTimeout(andNow, intervalMs); 89 | } 90 | andNow(); 91 | } 92 | 93 | // assumption - we can only observe one dragged element at a time, this could be changed in the future 94 | export function unobserve() { 95 | printDebug(() => "unobserving"); 96 | clearTimeout(next); 97 | resetIndexesCache(); 98 | } 99 | -------------------------------------------------------------------------------- /src/helpers/scroller.js: -------------------------------------------------------------------------------- 1 | import {isPointInsideRect} from "./intersection"; 2 | const SCROLL_ZONE_PX = 30; 3 | 4 | /** 5 | * Will make a scroller that can scroll any element given to it in any direction 6 | * @returns {{scrollIfNeeded: function(Point, HTMLElement): boolean, resetScrolling: function(void):void}} 7 | */ 8 | export function makeScroller() { 9 | let scrollingInfo; 10 | function resetScrolling() { 11 | scrollingInfo = {directionObj: undefined, stepPx: 0}; 12 | } 13 | resetScrolling(); 14 | // directionObj {x: 0|1|-1, y:0|1|-1} - 1 means down in y and right in x 15 | function scrollContainer(containerEl) { 16 | const {directionObj, stepPx} = scrollingInfo; 17 | if (directionObj) { 18 | containerEl.scrollBy(directionObj.x * stepPx, directionObj.y * stepPx); 19 | window.requestAnimationFrame(() => scrollContainer(containerEl)); 20 | } 21 | } 22 | function calcScrollStepPx(distancePx) { 23 | return SCROLL_ZONE_PX - distancePx; 24 | } 25 | 26 | /** 27 | * @param {Point} pointer - the pointer will be used to decide in which direction to scroll 28 | * @param {HTMLElement} elementToScroll - the scroll container 29 | * If the pointer is next to the sides of the element to scroll, will trigger scrolling 30 | * Can be called repeatedly with updated pointer and elementToScroll values without issues 31 | * @return {boolean} - true if scrolling was needed 32 | */ 33 | function scrollIfNeeded(pointer, elementToScroll) { 34 | if (!elementToScroll) { 35 | return false; 36 | } 37 | const distances = calcInnerDistancesBetweenPointAndSidesOfElement(pointer, elementToScroll); 38 | const isAlreadyScrolling = !!scrollingInfo.directionObj; 39 | if (distances === null) { 40 | if (isAlreadyScrolling) resetScrolling(); 41 | return false; 42 | } 43 | let [scrollingVertically, scrollingHorizontally] = [false, false]; 44 | // vertical 45 | if (elementToScroll.scrollHeight > elementToScroll.clientHeight) { 46 | if (distances.bottom < SCROLL_ZONE_PX) { 47 | scrollingVertically = true; 48 | scrollingInfo.directionObj = {x: 0, y: 1}; 49 | scrollingInfo.stepPx = calcScrollStepPx(distances.bottom); 50 | } else if (distances.top < SCROLL_ZONE_PX) { 51 | scrollingVertically = true; 52 | scrollingInfo.directionObj = {x: 0, y: -1}; 53 | scrollingInfo.stepPx = calcScrollStepPx(distances.top); 54 | } 55 | if (!isAlreadyScrolling && scrollingVertically) { 56 | scrollContainer(elementToScroll); 57 | return true; 58 | } 59 | } 60 | // horizontal 61 | if (elementToScroll.scrollWidth > elementToScroll.clientWidth) { 62 | if (distances.right < SCROLL_ZONE_PX) { 63 | scrollingHorizontally = true; 64 | scrollingInfo.directionObj = {x: 1, y: 0}; 65 | scrollingInfo.stepPx = calcScrollStepPx(distances.right); 66 | } else if (distances.left < SCROLL_ZONE_PX) { 67 | scrollingHorizontally = true; 68 | scrollingInfo.directionObj = {x: -1, y: 0}; 69 | scrollingInfo.stepPx = calcScrollStepPx(distances.left); 70 | } 71 | if (!isAlreadyScrolling && scrollingHorizontally) { 72 | scrollContainer(elementToScroll); 73 | return true; 74 | } 75 | } 76 | resetScrolling(); 77 | return false; 78 | } 79 | 80 | return { 81 | scrollIfNeeded, 82 | resetScrolling 83 | }; 84 | } 85 | 86 | /** 87 | * If the point is inside the element returns its distances from the sides, otherwise returns null 88 | * @param {Point} point 89 | * @param {HTMLElement} el 90 | * @return {null|{top: number, left: number, bottom: number, right: number}} 91 | */ 92 | function calcInnerDistancesBetweenPointAndSidesOfElement(point, el) { 93 | // Even if the scrolling element is small it acts as a scroller for the viewport 94 | const rect = 95 | el === document.scrollingElement 96 | ? { 97 | top: 0, 98 | bottom: window.innerHeight, 99 | left: 0, 100 | right: window.innerWidth 101 | } 102 | : el.getBoundingClientRect(); 103 | if (!isPointInsideRect(point, rect)) { 104 | return null; 105 | } 106 | return { 107 | top: point.y - rect.top, 108 | bottom: rect.bottom - point.y, 109 | left: point.x - rect.left, 110 | right: rect.right - point.x 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /src/helpers/styler.js: -------------------------------------------------------------------------------- 1 | import {SHADOW_ELEMENT_ATTRIBUTE_NAME, DRAGGED_ELEMENT_ID} from "../constants"; 2 | import {findCenter} from "./intersection"; 3 | import {svelteNodeClone} from "./svelteNodeClone"; 4 | import {getFeatureFlag, FEATURE_FLAG_NAMES} from "../featureFlags"; 5 | 6 | const TRANSITION_DURATION_SECONDS = 0.2; 7 | 8 | /** 9 | * private helper function - creates a transition string for a property 10 | * @param {string} property 11 | * @return {string} - the transition string 12 | */ 13 | function trs(property) { 14 | return `${property} ${TRANSITION_DURATION_SECONDS}s ease`; 15 | } 16 | /** 17 | * clones the given element and applies proper styles and transitions to the dragged element 18 | * @param {HTMLElement} originalElement 19 | * @param {Point} [positionCenterOnXY] 20 | * @return {Node} - the cloned, styled element 21 | */ 22 | export function createDraggedElementFrom(originalElement, positionCenterOnXY) { 23 | const rect = originalElement.getBoundingClientRect(); 24 | const draggedEl = svelteNodeClone(originalElement); 25 | copyStylesFromTo(originalElement, draggedEl); 26 | draggedEl.id = DRAGGED_ELEMENT_ID; 27 | draggedEl.style.position = "fixed"; 28 | let elTopPx = rect.top; 29 | let elLeftPx = rect.left; 30 | draggedEl.style.top = `${elTopPx}px`; 31 | draggedEl.style.left = `${elLeftPx}px`; 32 | if (positionCenterOnXY) { 33 | const center = findCenter(rect); 34 | elTopPx -= center.y - positionCenterOnXY.y; 35 | elLeftPx -= center.x - positionCenterOnXY.x; 36 | window.setTimeout(() => { 37 | draggedEl.style.top = `${elTopPx}px`; 38 | draggedEl.style.left = `${elLeftPx}px`; 39 | }, 0); 40 | } 41 | draggedEl.style.margin = "0"; 42 | // we can't have relative or automatic height and width or it will break the illusion 43 | draggedEl.style.boxSizing = "border-box"; 44 | draggedEl.style.height = `${rect.height}px`; 45 | draggedEl.style.width = `${rect.width}px`; 46 | draggedEl.style.transition = `${trs("top")}, ${trs("left")}, ${trs("background-color")}, ${trs("opacity")}, ${trs("color")} `; 47 | // this is a workaround for a strange browser bug that causes the right border to disappear when all the transitions are added at the same time 48 | window.setTimeout(() => (draggedEl.style.transition += `, ${trs("width")}, ${trs("height")}`), 0); 49 | draggedEl.style.zIndex = "9999"; 50 | draggedEl.style.cursor = "grabbing"; 51 | 52 | return draggedEl; 53 | } 54 | 55 | /** 56 | * styles the dragged element to a 'dropped' state 57 | * @param {HTMLElement} draggedEl 58 | */ 59 | export function moveDraggedElementToWasDroppedState(draggedEl) { 60 | draggedEl.style.cursor = "grab"; 61 | } 62 | 63 | /** 64 | * Morphs the dragged element style, maintains the mouse pointer within the element 65 | * @param {HTMLElement} draggedEl 66 | * @param {HTMLElement} copyFromEl - the element the dragged element should look like, typically the shadow element 67 | * @param {number} currentMouseX 68 | * @param {number} currentMouseY 69 | */ 70 | export function morphDraggedElementToBeLike(draggedEl, copyFromEl, currentMouseX, currentMouseY) { 71 | copyStylesFromTo(copyFromEl, draggedEl); 72 | const newRect = copyFromEl.getBoundingClientRect(); 73 | const draggedElRect = draggedEl.getBoundingClientRect(); 74 | const widthChange = newRect.width - draggedElRect.width; 75 | const heightChange = newRect.height - draggedElRect.height; 76 | if (widthChange || heightChange) { 77 | const relativeDistanceOfMousePointerFromDraggedSides = { 78 | left: (currentMouseX - draggedElRect.left) / draggedElRect.width, 79 | top: (currentMouseY - draggedElRect.top) / draggedElRect.height 80 | }; 81 | if (!getFeatureFlag(FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT)) { 82 | draggedEl.style.height = `${newRect.height}px`; 83 | draggedEl.style.width = `${newRect.width}px`; 84 | } 85 | draggedEl.style.left = `${parseFloat(draggedEl.style.left) - relativeDistanceOfMousePointerFromDraggedSides.left * widthChange}px`; 86 | draggedEl.style.top = `${parseFloat(draggedEl.style.top) - relativeDistanceOfMousePointerFromDraggedSides.top * heightChange}px`; 87 | } 88 | } 89 | 90 | /** 91 | * @param {HTMLElement} copyFromEl 92 | * @param {HTMLElement} copyToEl 93 | */ 94 | function copyStylesFromTo(copyFromEl, copyToEl) { 95 | const computedStyle = window.getComputedStyle(copyFromEl); 96 | Array.from(computedStyle) 97 | .filter( 98 | s => 99 | s.startsWith("background") || 100 | s.startsWith("padding") || 101 | s.startsWith("font") || 102 | s.startsWith("text") || 103 | s.startsWith("align") || 104 | s.startsWith("justify") || 105 | s.startsWith("display") || 106 | s.startsWith("flex") || 107 | s.startsWith("border") || 108 | s === "opacity" || 109 | s === "color" || 110 | s === "list-style-type" || 111 | // copying with and height to make up for rect update timing issues in some browsers 112 | (getFeatureFlag(FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT) && (s === "width" || s === "height")) 113 | ) 114 | .forEach(s => copyToEl.style.setProperty(s, computedStyle.getPropertyValue(s), computedStyle.getPropertyPriority(s))); 115 | } 116 | 117 | /** 118 | * makes the element compatible with being draggable 119 | * @param {HTMLElement} draggableEl 120 | * @param {boolean} dragDisabled 121 | */ 122 | export function styleDraggable(draggableEl, dragDisabled) { 123 | draggableEl.draggable = false; 124 | draggableEl.ondragstart = () => false; 125 | if (!dragDisabled) { 126 | draggableEl.style.userSelect = "none"; 127 | draggableEl.style.WebkitUserSelect = "none"; 128 | draggableEl.style.cursor = "grab"; 129 | } else { 130 | draggableEl.style.userSelect = ""; 131 | draggableEl.style.WebkitUserSelect = ""; 132 | draggableEl.style.cursor = ""; 133 | } 134 | } 135 | 136 | /** 137 | * Hides the provided element so that it can stay in the dom without interrupting 138 | * @param {HTMLElement} dragTarget 139 | */ 140 | export function hideElement(dragTarget) { 141 | dragTarget.style.display = "none"; 142 | dragTarget.style.position = "fixed"; 143 | dragTarget.style.zIndex = "-5"; 144 | } 145 | 146 | /** 147 | * styles the shadow element 148 | * @param {HTMLElement} shadowEl 149 | */ 150 | export function decorateShadowEl(shadowEl) { 151 | shadowEl.style.visibility = "hidden"; 152 | shadowEl.setAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME, "true"); 153 | } 154 | 155 | /** 156 | * undo the styles the shadow element 157 | * @param {HTMLElement} shadowEl 158 | */ 159 | export function unDecorateShadowElement(shadowEl) { 160 | shadowEl.style.visibility = ""; 161 | shadowEl.removeAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME); 162 | } 163 | 164 | /** 165 | * will mark the given dropzones as visually active 166 | * @param {Array} dropZones 167 | * @param {Function} getStyles - maps a dropzone to a styles object (so the styles can be removed) 168 | * @param {Function} getClasses - maps a dropzone to a classList 169 | */ 170 | export function styleActiveDropZones(dropZones, getStyles = () => {}, getClasses = () => []) { 171 | dropZones.forEach(dz => { 172 | const styles = getStyles(dz); 173 | Object.keys(styles).forEach(style => { 174 | dz.style[style] = styles[style]; 175 | }); 176 | getClasses(dz).forEach(c => dz.classList.add(c)); 177 | }); 178 | } 179 | 180 | /** 181 | * will remove the 'active' styling from given dropzones 182 | * @param {Array} dropZones 183 | * @param {Function} getStyles - maps a dropzone to a styles object 184 | * @param {Function} getClasses - maps a dropzone to a classList 185 | */ 186 | export function styleInactiveDropZones(dropZones, getStyles = () => {}, getClasses = () => []) { 187 | dropZones.forEach(dz => { 188 | const styles = getStyles(dz); 189 | Object.keys(styles).forEach(style => { 190 | dz.style[style] = ""; 191 | }); 192 | getClasses(dz).forEach(c => dz.classList.contains(c) && dz.classList.remove(c)); 193 | }); 194 | } 195 | 196 | /** 197 | * will prevent the provided element from shrinking by setting its minWidth and minHeight to the current width and height values 198 | * @param {HTMLElement} el 199 | * @return {function(): void} - run this function to undo the operation and restore the original values 200 | */ 201 | export function preventShrinking(el) { 202 | const originalMinHeight = el.style.minHeight; 203 | el.style.minHeight = window.getComputedStyle(el).getPropertyValue("height"); 204 | const originalMinWidth = el.style.minWidth; 205 | el.style.minWidth = window.getComputedStyle(el).getPropertyValue("width"); 206 | return function undo() { 207 | el.style.minHeight = originalMinHeight; 208 | el.style.minWidth = originalMinWidth; 209 | }; 210 | } 211 | -------------------------------------------------------------------------------- /src/helpers/svelteNodeClone.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fixes svelte issue when cloning node containing (or being)