novo-elements
Version:
366 lines (361 loc) • 16 kB
JavaScript
import { OverlayContainer } from '@angular/cdk/overlay';
import * as i0 from '@angular/core';
import { inject, EventEmitter, signal, computed, HostListener, HostBinding, Output, Input, Directive, NgModule } from '@angular/core';
const SCROLL_DRAGGABLE_PADDING_REGION = 30;
const SCROLL_DRAGGABLE_EVENT_AMT = 10;
class NovoDragBoxParent {
get itemsReordered() {
return this.trackedItems.map(item => item.item);
}
get dragging() {
return Boolean(this.pickedUp);
}
constructor(elementRef, renderer) {
this.elementRef = elementRef;
this.renderer = renderer;
this.overlayContainer = inject(OverlayContainer, { optional: true });
this.novoDragDropFinish = new EventEmitter();
this.scrollContainer = signal(null, ...(ngDevMode ? [{ debugName: "scrollContainer" }] : []));
this.scrollContainerRect = computed(() => this.scrollContainer()?.getBoundingClientRect(), ...(ngDevMode ? [{ debugName: "scrollContainerRect" }] : []));
this.scrollX = signal(false, ...(ngDevMode ? [{ debugName: "scrollX" }] : []));
this.scrollY = signal(false, ...(ngDevMode ? [{ debugName: "scrollY" }] : []));
this.mutationObserver = new MutationObserver(this.mutationDetected.bind(this));
}
get element() {
return this.elementRef.nativeElement;
}
ngAfterViewInit() {
if (this.items) {
this.registerChildren();
this.mutationObserver.observe(this.element, { childList: true });
}
// if items is falsey, then no behavior is set up. In future, this component may support late enablement,
// but for now it's not a priority. (It would open the question of late disablement)
}
ngOnDestroy() {
this.trackedItems?.forEach(item => {
item.removeListeners();
});
}
registerChildren() {
if (this.items) {
if (this.items.length !== this.element.children.length) {
throw new Error(`Could not match item list to children list - drag box contains ${this.items.length} items, but has ${this.element.children.length} elements`);
}
this.trackedItems = [];
for (let i = 0; i < this.element.children.length; i++) {
this.registerChild(this.element.children[i], i);
}
}
}
registerChild(element, index) {
const trackedItem = {
item: this.items[index],
element,
};
if (this.dragFilter && !this.dragFilter(this.items[index])) {
element.draggable = false;
trackedItem.removeListeners = () => { };
}
else {
const listeners = [
this.renderer.listen(element, 'dragstart', this.onDragStart.bind(this)),
this.renderer.listen(element, 'drop', this.onDragFinish.bind(this)),
this.renderer.listen(element, 'dragend', this.onDragStop.bind(this)),
];
element.draggable = true;
trackedItem.removeListeners = () => listeners.forEach(cb => cb());
}
this.trackedItems.push(trackedItem);
}
mutationDetected(mutations) {
if (this.pickedUp) {
return;
}
const addedNodes = new Set();
const removedNodes = new Set();
for (const mutation of mutations) {
mutation.addedNodes.forEach((a) => {
if (!removedNodes.delete(a)) {
addedNodes.add(a);
}
});
mutation.removedNodes.forEach((a) => {
if (!addedNodes.delete(a)) {
removedNodes.add(a);
}
});
}
addedNodes.forEach(node => {
const idx = Array.prototype.indexOf.call(this.element.children, node);
this.registerChild(node, idx);
});
if (removedNodes.size > 0) {
this.trackedItems = this.trackedItems.filter(item => {
const keep = !removedNodes.has(item.element);
if (!keep) {
item.removeListeners();
}
return keep;
});
}
}
/** Per-item listeners */
onDragStart(event) {
if (this.shouldBlockDragStart(event)) {
event.preventDefault();
return;
}
const dataTransfer = event.dataTransfer;
// Present a native 'move item' effect
dataTransfer.effectAllowed = 'move';
if (!this.disableScroll) {
this.scrollContainer.set(this.findScrollableParent());
}
this.pickedUp = event.target;
event.stopPropagation();
this.savedOrder = [...this.trackedItems];
}
// Equivalent of "finally" - this runs whether or not the drag finished on a valid ending location
onDragStop(event) {
this.pickedUp = null;
this.previewReplacing = null;
this.savedOrder = null;
event.stopPropagation();
}
onDragFinish(event) {
event.preventDefault();
event.stopPropagation();
if (!this.element.contains(event.currentTarget)) {
// this is for a different drag region - ignore
return;
}
const draggedItem = this.trackedItems.find(item => item.element === this.pickedUp)?.item;
this.trackedItems = Array.prototype.map.call(this.element.children, child => {
const item = this.trackedItems.find(trackedItem => trackedItem.element === child);
if (!item) {
throw new Error('DragDrop: Error - could not reassociate an item post-drag');
}
return item;
});
this.novoDragDropFinish.emit({
draggedItem,
allItems: this.itemsReordered,
event,
});
}
/** - end per-item listeners */
onDragOver(event) {
if (!this.pickedUp || this.inOverlay(event)) {
return;
}
let target = event.target;
if (!this.element.contains(target)) {
target = null;
}
// In some cases (maybe browser-specific) we may have this event reported from a sub-element of a drag destination.
// We need to go upwards in the tree to find the actual target
if (target && !target.draggable) {
target = this.findDraggableParentOfElement(target);
}
// Check if this drag event is within this drag box
if (target && target.parentElement === this.element) {
event.stopPropagation();
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (this.previewReplacing !== target) {
if (this.isValidTarget(target)) {
this.previewReplacing = target;
this.applyTempSort(this.pickedUp, target);
}
else {
event.dataTransfer.dropEffect = 'none';
}
}
}
else {
// if not within this drag box, then move this item back to its original position and show a diabled drag effect
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'none';
}
this.resetSorting();
}
if (!this.disableScroll) {
this.processScrollDuringMove(event);
}
}
inOverlay(event) {
// Make sure a drag event is not looking at a tooltip or similar.
// Return false if the dragbox itself is in an overlay - these scenarios will be harder to account for.
const overlayElement = this.overlayContainer?.getContainerElement();
return overlayElement && (overlayElement.contains(event.target) && !overlayElement.contains(this.element));
}
isValidTarget(target) {
return target.draggable;
}
findDraggableParentOfElement(target) {
const parentElement = target.parentElement;
if (!parentElement) {
return null;
}
else if (parentElement.draggable) {
return parentElement;
}
else {
return this.findDraggableParentOfElement(parentElement);
}
}
applyTempSort(showXElement, inPlaceOfY) {
if (showXElement === inPlaceOfY) {
// same element - ignoring
return;
}
// Apply the "preview" effect from dragging one item to another
const aIndex = Array.prototype.indexOf.call(this.elementRef.nativeElement.children, showXElement);
const bIndex = Array.prototype.indexOf.call(this.elementRef.nativeElement.children, inPlaceOfY);
const diff = bIndex - aIndex;
let insertPosition;
if (diff > 0) {
insertPosition = 'afterend';
}
else if (diff < 0) {
insertPosition = 'beforebegin';
}
else {
throw new Error('DragDrop: Two elements are in the same position');
}
inPlaceOfY.insertAdjacentElement(insertPosition, showXElement);
}
resetSorting() {
// return to the order of elements from the last time we called onDragPickup
if (!this.savedOrder) {
throw new Error('DragDrop: Cannot reset sorting with no saved order');
}
const boxElem = this.elementRef.nativeElement;
for (let i = 0; i < boxElem.children.length; i++) {
const item = boxElem.children[i];
if (this.savedOrder[i].element !== item && i > 0) {
this.savedOrder[i - 1].element.insertAdjacentElement('afterend', this.savedOrder[i].element);
}
}
}
// If the user has provided classes indicating they only want a certain region to be draggable, ignore
// this drag event if it is outside of there.
shouldBlockDragStart(event) {
const dragTarget = event.target;
// TODO: Allow for multiple drag targets, and drag exclusion targets
const userDragTarget = dragTarget.querySelector('.novo-drag-target');
if (userDragTarget) {
return !this.isElementWithinEventBounds(userDragTarget, event);
}
}
processScrollDuringMove(event) {
const scrollElement = this.scrollContainer();
if (!scrollElement) {
return;
}
const rect = this.scrollContainerRect();
if (this.scrollX()) {
if (event.x > rect.left && event.x < rect.left + SCROLL_DRAGGABLE_PADDING_REGION) {
scrollElement.scrollLeft -= 10;
}
else if (event.x < rect.right && event.x > rect.right - SCROLL_DRAGGABLE_PADDING_REGION) {
scrollElement.scrollLeft += 10;
}
}
if (this.scrollY()) {
if (event.y > rect.top && event.y < rect.top + SCROLL_DRAGGABLE_PADDING_REGION) {
scrollElement.scrollTop -= 10;
}
else if (event.y < rect.bottom && event.y > rect.bottom - SCROLL_DRAGGABLE_PADDING_REGION) {
scrollElement.scrollTop += 10;
}
}
}
isElementWithinEventBounds(element, event) {
const rect = element.getBoundingClientRect();
const isInside = event.clientX > rect.left && event.clientX < rect.right &&
event.clientY < rect.bottom && event.clientY > rect.top;
return isInside;
}
findScrollableParent() {
let currentElement = this.element.parentElement;
this.scrollX.set(false);
this.scrollY.set(false);
while (currentElement) {
const hasVerticalScroll = currentElement.scrollHeight > currentElement.clientHeight;
const hasHorizontalScroll = currentElement.scrollWidth > currentElement.clientWidth;
const isScrollable = hasVerticalScroll || hasHorizontalScroll;
if (isScrollable) {
const computedStyle = window.getComputedStyle(currentElement);
const overflowY = computedStyle.overflowY;
const overflowX = computedStyle.overflowX;
// Check if overflow is set to allow scrolling
if ((overflowY === 'auto' || overflowY === 'scroll') ||
(overflowX === 'auto' || overflowX === 'scroll')) {
// Verify the scrollable parent is smaller than the drag container
if (currentElement.clientHeight < this.element.clientHeight) {
this.scrollY.set(true);
}
if (currentElement.clientWidth < this.element.clientWidth) {
this.scrollX.set(true);
}
if (this.scrollX() || this.scrollY()) {
return currentElement;
}
}
}
currentElement = currentElement.parentElement;
}
return null;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NovoDragBoxParent, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.19", type: NovoDragBoxParent, isStandalone: false, selector: "[novoDragDrop]", inputs: { items: ["novoDragDrop", "items"], dragFilter: ["novoDragDropFilter", "dragFilter"], disableScroll: ["novoDragDropDisableScroll", "disableScroll"] }, outputs: { novoDragDropFinish: "novoDragDropFinish" }, host: { listeners: { "window:dragover": "onDragOver($event)" }, properties: { "class.dragging": "this.dragging" }, classAttribute: "novo-drag-container" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NovoDragBoxParent, decorators: [{
type: Directive,
args: [{
selector: '[novoDragDrop]',
standalone: false,
host: {
class: 'novo-drag-container',
'[class.dragging]': '!!pickedUp',
},
}]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { items: [{
type: Input,
args: ['novoDragDrop']
}], dragFilter: [{
type: Input,
args: ['novoDragDropFilter']
}], disableScroll: [{
type: Input,
args: ['novoDragDropDisableScroll']
}], novoDragDropFinish: [{
type: Output
}], dragging: [{
type: HostBinding,
args: ['class.dragging']
}], onDragOver: [{
type: HostListener,
args: ['window:dragover', ['$event']]
}] } });
class NovoDragDropModule {
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NovoDragDropModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.19", ngImport: i0, type: NovoDragDropModule, declarations: [NovoDragBoxParent], exports: [NovoDragBoxParent] }); }
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NovoDragDropModule }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NovoDragDropModule, decorators: [{
type: NgModule,
args: [{
declarations: [NovoDragBoxParent],
exports: [NovoDragBoxParent],
}]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { NovoDragBoxParent, NovoDragDropModule };
//# sourceMappingURL=novo-elements-elements-drag-drop.mjs.map