You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
287 lines
7.6 KiB
287 lines
7.6 KiB
// @ts-check |
|
/* |
|
Original implementation: |
|
https://github.com/TahaSh/drag-to-reorder |
|
MIT License |
|
|
|
Copyright (c) 2023 Taha Shashtari |
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
|
of this software and associated documentation files (the "Software"), to deal |
|
in the Software without restriction, including without limitation the rights |
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
copies of the Software, and to permit persons to whom the Software is |
|
furnished to do so, subject to the following conditions: |
|
|
|
The above copyright notice and this permission notice shall be included in all |
|
copies or substantial portions of the Software. |
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
SOFTWARE. |
|
*/ |
|
|
|
import { $el } from "../ui.js"; |
|
|
|
$el("style", { |
|
parent: document.head, |
|
textContent: ` |
|
.draggable-item { |
|
position: relative; |
|
will-change: transform; |
|
user-select: none; |
|
} |
|
.draggable-item.is-idle { |
|
transition: 0.25s ease transform; |
|
} |
|
.draggable-item.is-draggable { |
|
z-index: 10; |
|
} |
|
` |
|
}); |
|
|
|
export class DraggableList extends EventTarget { |
|
listContainer; |
|
draggableItem; |
|
pointerStartX; |
|
pointerStartY; |
|
scrollYMax; |
|
itemsGap = 0; |
|
items = []; |
|
itemSelector; |
|
handleClass = "drag-handle"; |
|
off = []; |
|
offDrag = []; |
|
|
|
constructor(element, itemSelector) { |
|
super(); |
|
this.listContainer = element; |
|
this.itemSelector = itemSelector; |
|
|
|
if (!this.listContainer) return; |
|
|
|
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart)); |
|
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart)); |
|
this.off.push(this.on(document, "mouseup", this.dragEnd)); |
|
this.off.push(this.on(document, "touchend", this.dragEnd)); |
|
} |
|
|
|
getAllItems() { |
|
if (!this.items?.length) { |
|
this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector)); |
|
this.items.forEach((element) => { |
|
element.classList.add("is-idle"); |
|
}); |
|
} |
|
return this.items; |
|
} |
|
|
|
getIdleItems() { |
|
return this.getAllItems().filter((item) => item.classList.contains("is-idle")); |
|
} |
|
|
|
isItemAbove(item) { |
|
return item.hasAttribute("data-is-above"); |
|
} |
|
|
|
isItemToggled(item) { |
|
return item.hasAttribute("data-is-toggled"); |
|
} |
|
|
|
on(source, event, listener, options) { |
|
listener = listener.bind(this); |
|
source.addEventListener(event, listener, options); |
|
return () => source.removeEventListener(event, listener); |
|
} |
|
|
|
dragStart(e) { |
|
if (e.target.classList.contains(this.handleClass)) { |
|
this.draggableItem = e.target.closest(this.itemSelector); |
|
} |
|
|
|
if (!this.draggableItem) return; |
|
|
|
this.pointerStartX = e.clientX || e.touches[0].clientX; |
|
this.pointerStartY = e.clientY || e.touches[0].clientY; |
|
this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight; |
|
|
|
this.setItemsGap(); |
|
this.initDraggableItem(); |
|
this.initItemsState(); |
|
|
|
this.offDrag.push(this.on(document, "mousemove", this.drag)); |
|
this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false })); |
|
|
|
this.dispatchEvent( |
|
new CustomEvent("dragstart", { |
|
detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) }, |
|
}) |
|
); |
|
} |
|
|
|
setItemsGap() { |
|
if (this.getIdleItems().length <= 1) { |
|
this.itemsGap = 0; |
|
return; |
|
} |
|
|
|
const item1 = this.getIdleItems()[0]; |
|
const item2 = this.getIdleItems()[1]; |
|
|
|
const item1Rect = item1.getBoundingClientRect(); |
|
const item2Rect = item2.getBoundingClientRect(); |
|
|
|
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top); |
|
} |
|
|
|
initItemsState() { |
|
this.getIdleItems().forEach((item, i) => { |
|
if (this.getAllItems().indexOf(this.draggableItem) > i) { |
|
item.dataset.isAbove = ""; |
|
} |
|
}); |
|
} |
|
|
|
initDraggableItem() { |
|
this.draggableItem.classList.remove("is-idle"); |
|
this.draggableItem.classList.add("is-draggable"); |
|
} |
|
|
|
drag(e) { |
|
if (!this.draggableItem) return; |
|
|
|
e.preventDefault(); |
|
|
|
const clientX = e.clientX || e.touches[0].clientX; |
|
const clientY = e.clientY || e.touches[0].clientY; |
|
|
|
const listRect = this.listContainer.getBoundingClientRect(); |
|
|
|
if (clientY > listRect.bottom) { |
|
if (this.listContainer.scrollTop < this.scrollYMax) { |
|
this.listContainer.scrollBy(0, 10); |
|
this.pointerStartY -= 10; |
|
} |
|
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) { |
|
this.pointerStartY += 10; |
|
this.listContainer.scrollBy(0, -10); |
|
} |
|
|
|
const pointerOffsetX = clientX - this.pointerStartX; |
|
const pointerOffsetY = clientY - this.pointerStartY; |
|
|
|
this.updateIdleItemsStateAndPosition(); |
|
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`; |
|
} |
|
|
|
updateIdleItemsStateAndPosition() { |
|
const draggableItemRect = this.draggableItem.getBoundingClientRect(); |
|
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2; |
|
|
|
// Update state |
|
this.getIdleItems().forEach((item) => { |
|
const itemRect = item.getBoundingClientRect(); |
|
const itemY = itemRect.top + itemRect.height / 2; |
|
if (this.isItemAbove(item)) { |
|
if (draggableItemY <= itemY) { |
|
item.dataset.isToggled = ""; |
|
} else { |
|
delete item.dataset.isToggled; |
|
} |
|
} else { |
|
if (draggableItemY >= itemY) { |
|
item.dataset.isToggled = ""; |
|
} else { |
|
delete item.dataset.isToggled; |
|
} |
|
} |
|
}); |
|
|
|
// Update position |
|
this.getIdleItems().forEach((item) => { |
|
if (this.isItemToggled(item)) { |
|
const direction = this.isItemAbove(item) ? 1 : -1; |
|
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`; |
|
} else { |
|
item.style.transform = ""; |
|
} |
|
}); |
|
} |
|
|
|
dragEnd() { |
|
if (!this.draggableItem) return; |
|
|
|
this.applyNewItemsOrder(); |
|
this.cleanup(); |
|
} |
|
|
|
applyNewItemsOrder() { |
|
const reorderedItems = []; |
|
|
|
let oldPosition = -1; |
|
this.getAllItems().forEach((item, index) => { |
|
if (item === this.draggableItem) { |
|
oldPosition = index; |
|
return; |
|
} |
|
if (!this.isItemToggled(item)) { |
|
reorderedItems[index] = item; |
|
return; |
|
} |
|
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1; |
|
reorderedItems[newIndex] = item; |
|
}); |
|
|
|
for (let index = 0; index < this.getAllItems().length; index++) { |
|
const item = reorderedItems[index]; |
|
if (typeof item === "undefined") { |
|
reorderedItems[index] = this.draggableItem; |
|
} |
|
} |
|
|
|
reorderedItems.forEach((item) => { |
|
this.listContainer.appendChild(item); |
|
}); |
|
|
|
this.items = reorderedItems; |
|
|
|
this.dispatchEvent( |
|
new CustomEvent("dragend", { |
|
detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) }, |
|
}) |
|
); |
|
} |
|
|
|
cleanup() { |
|
this.itemsGap = 0; |
|
this.items = []; |
|
this.unsetDraggableItem(); |
|
this.unsetItemState(); |
|
|
|
this.offDrag.forEach((f) => f()); |
|
this.offDrag = []; |
|
} |
|
|
|
unsetDraggableItem() { |
|
this.draggableItem.style = null; |
|
this.draggableItem.classList.remove("is-draggable"); |
|
this.draggableItem.classList.add("is-idle"); |
|
this.draggableItem = null; |
|
} |
|
|
|
unsetItemState() { |
|
this.getIdleItems().forEach((item, i) => { |
|
delete item.dataset.isAbove; |
|
delete item.dataset.isToggled; |
|
item.style.transform = ""; |
|
}); |
|
} |
|
|
|
dispose() { |
|
this.off.forEach((f) => f()); |
|
} |
|
}
|
|
|