initial convert

This commit is contained in:
Niels Lyngsø
2023-04-14 16:32:53 +02:00
parent 24a371d3b9
commit ff94a986c8

View File

@@ -0,0 +1,745 @@
import { UmbControllerInterface, UmbControllerHostElement } from '@umbraco-cms/backoffice/controller';
const autoScrollSensitivity = 50;
const autoScrollSpeed = 16;
function isWithinRect(x: number, y: number, rect: DOMRect, modifier: number = 0) {
return x > rect.left - modifier && x < rect.right + modifier && y > rect.top - modifier && y < rect.bottom + modifier;
}
function getParentScrollElement(el: Element, includeSelf: boolean) {
if (!el || !el.getBoundingClientRect) return null;
let elem = el;
let gotSelf = false;
while (elem) {
// we don't need to get elem css if it isn't even overflowing in the first place (performance)
if (elem.clientWidth < elem.scrollWidth || elem.clientHeight < elem.scrollHeight) {
const elemCSS = getComputedStyle(elem);
if (
(elem.clientHeight < elem.scrollHeight && (elemCSS.overflowY == 'auto' || elemCSS.overflowY == 'scroll')) ||
(elem.clientWidth < elem.scrollWidth && (elemCSS.overflowX == 'auto' || elemCSS.overflowX == 'scroll'))
) {
if (!elem.getBoundingClientRect || elem === document.body) return null;
if (gotSelf || includeSelf) return elem;
gotSelf = true;
}
}
if (elem.parentNode === document) {
return null;
} else if (elem.parentNode instanceof ShadowRoot) {
elem = elem.parentNode.host;
} else {
elem = elem.parentNode as Element;
}
}
return null;
}
function preventDragOver(e: Event) {
e.preventDefault();
}
function setupIgnorerElements(element: HTMLElement, ignorerSelectors: string) {
ignorerSelectors.split(',').forEach(function (criteria) {
element.querySelectorAll(criteria.trim()).forEach(setupPreventEvent);
});
}
function destroyIgnorerElements(element: HTMLElement, ignorerSelectors: string) {
ignorerSelectors.split(',').forEach(function (criteria: string) {
element.querySelectorAll(criteria.trim()).forEach(destroyPreventEvent);
});
}
function setupPreventEvent(element: Element) {
(element as HTMLElement).draggable = false;
}
function destroyPreventEvent(element: Element) {
element.removeAttribute('draggable');
}
export type UmbSorterConfig<T> = {
compareElementToModel: (el: HTMLElement, modelEntry: T) => boolean;
querySelectModelToElement: (container: HTMLElement, modelEntry: T) => HTMLElement;
identifier: string;
ignorerSelector: string;
itemSelector: string;
placeholderClass: string;
containerSelector: string;
draggableSelector?: string;
boundarySelector?: string;
dataTransferResolver?: (dataTransfer: DataTransfer | null, currentItem: T) => void;
onStart?: (argument: { item: T; element: HTMLElement }) => void;
onChange?: (argument: { item: T; element: HTMLElement }) => void;
onContainerChange?: (argument: { item: T; element: HTMLElement }) => void;
onEnd?: (argument: { item: T; element: HTMLElement }) => void;
onSync?: (argument: {
item: T;
fromController: UmbSorterController<T>;
toController: UmbSorterController<T>;
}) => void;
itemHasNestedContainersResolver?: (element: HTMLElement) => boolean;
onDisallowed?: () => void;
onAllowed?: () => void;
onRequestDrop?: (argument: { item: T }) => boolean | void;
resolveVerticalDirection?: (argument: {
containerElement: Element;
containerRect: DOMRect;
item: T;
element: HTMLElement;
elementRect: DOMRect;
relatedElement: HTMLElement;
relatedRect: DOMRect;
placeholderIsInThisRow: boolean;
horizontalPlaceAfter: boolean;
}) => void;
};
export class UmbSorterController<T> implements UmbControllerInterface {
#host;
#config;
#observer;
#model: Array<T> = [];
#rqaId?: number;
#currentContainerVM = this;
#currentContainerElement: Element;
#scrollElement?: Element | null;
#currentElement?: HTMLElement;
#currentDragElement?: Element;
#currentDragRect?: DOMRect;
#currentItem?: T | null;
#dragX = 0;
#dragY = 0;
private _lastIndicationContainerVM: UmbSorterController<T> | null = null;
public get unique() {
return this.#config.identifier;
}
constructor(host: UmbControllerHostElement, config: UmbSorterConfig<T>) {
this.#host = host;
this.#config = config;
host.addController(this);
this.#currentContainerElement = host;
this.#observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((addedNode) => {
if ((addedNode as HTMLElement).matches && (addedNode as HTMLElement).matches(this.#config.itemSelector)) {
this.setupItem(addedNode as HTMLElement);
}
});
mutation.removedNodes.forEach((removedNode) => {
if ((removedNode as HTMLElement).matches && (removedNode as HTMLElement).matches(this.#config.itemSelector)) {
this.destroyItem(removedNode as HTMLElement);
}
});
});
});
(host as any)['__umbBlockGridSorterController'] = () => {
return this;
};
host.addEventListener('dragover', preventDragOver);
}
setModel(model: Array<T>) {
this.#model = model;
// TODO: Some update?
}
hostConnected() {
this.#observer.observe(this.#host, { childList: true, subtree: false });
}
hostDisconnected() {
this.#observer.disconnect();
}
setupItem(element: HTMLElement) {
setupIgnorerElements(element, this.#config.ignorerSelector);
element.draggable = true;
element.addEventListener('dragstart', this.handleDragStart);
}
destroyItem(element: HTMLElement) {
destroyIgnorerElements(element, this.#config.ignorerSelector);
element.removeEventListener('dragstart', this.handleDragStart);
}
handleDragStart = (event: DragEvent) => {
if (this.#currentElement) {
this.handleDragEnd();
}
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'; // copyMove when we enhance the drag with clipboard data.
event.dataTransfer.dropEffect = 'none'; // visual feedback when dropped.
}
if (!this.#scrollElement) {
this.#scrollElement = getParentScrollElement(this.#host, true);
}
const element = (event.target as HTMLElement).closest(this.#config.itemSelector);
if (!element) return;
this.#currentDragElement = this.#config.draggableSelector
? element.querySelector(this.#config.draggableSelector) ?? undefined
: element;
if (!this.#currentDragElement) {
throw new Error(
'Could not find drag element, query was made with the `draggableSelector` of "' +
this.#config.draggableSelector +
'"'
);
return;
}
this.#currentElement = element as HTMLElement;
this.#currentDragRect = this.#currentDragElement.getBoundingClientRect();
this.#currentItem = this.getItemOfElement(this.#currentElement);
if (!this.#currentItem) {
console.error('Could not find item related to this element.');
return;
}
this.#currentElement.style.transform = 'translateZ(0)'; // Solves problem with FireFox and ShadowDom in the drag-image.
if (this.#config.dataTransferResolver) {
this.#config.dataTransferResolver(event.dataTransfer, this.#currentItem);
}
if (this.#config.onStart) {
this.#config.onStart({ item: this.#currentItem!, element: this.#currentElement });
}
window.addEventListener('dragover', this.handleDragMove);
window.addEventListener('dragend', this.handleDragEnd);
// We must wait one frame before changing the look of the block.
this.#rqaId = requestAnimationFrame(() => {
// It should be okay to use the same rqafId, as the move does not or is okay not to happen on first frame/drag-move.
this.#rqaId = undefined;
if (this.#currentElement) {
this.#currentElement.style.transform = '';
this.#currentElement.classList.add(this.#config.placeholderClass);
}
});
};
handleDragEnd = () => {
if (!this.#currentElement || !this.#currentItem) {
return;
}
window.removeEventListener('dragover', this.handleDragMove);
window.removeEventListener('dragend', this.handleDragEnd);
this.#currentElement.style.transform = '';
this.#currentElement.classList.remove(this.#config.placeholderClass);
this.stopAutoScroll();
this.removeAllowIndication();
if (this.#currentContainerVM.sync(this.#currentElement, this) === false) {
// Sync could not succeed, might be because item is not allowed here.
this.#currentContainerVM = this;
if (this.#config.onContainerChange) {
this.#config.onContainerChange({
item: this.#currentItem,
element: this.#currentElement,
//ownerVM: this.#currentContainerVM.ownerVM,
});
}
// Lets move the Element back to where it came from:
const movingItemIndex = this.#model.indexOf(this.#currentItem);
if (movingItemIndex < this.#model.length - 1) {
const afterItem = this.#model[movingItemIndex + 1];
const afterEl = this.#config.querySelectModelToElement(this.#host, afterItem);
this.#host.insertBefore(this.#currentElement, afterEl);
} else {
this.#host.appendChild(this.#currentElement);
}
}
if (this.#config.onEnd) {
this.#config.onEnd({ item: this.#currentItem, element: this.#currentElement });
}
if (this.#rqaId) {
cancelAnimationFrame(this.#rqaId);
}
this.#currentContainerElement = this.#host;
this.#currentContainerVM = this;
this.#rqaId = undefined;
this.#currentItem = undefined;
this.#currentElement = undefined;
this.#currentDragElement = undefined;
this.#currentDragRect = undefined;
this.#dragX = 0;
this.#dragY = 0;
};
handleDragMove = (event: DragEvent) => {
if (!this.#currentElement) {
return;
}
const clientX = (event as unknown as TouchEvent).touches
? (event as unknown as TouchEvent).touches[0].clientX
: event.clientX;
const clientY = (event as unknown as TouchEvent).touches
? (event as unknown as TouchEvent).touches[0].clientY
: event.clientY;
if (clientX !== 0 && clientY !== 0) {
if (this.#dragX === clientX && this.#dragY === clientY) {
return;
}
this.#dragX = clientX;
this.#dragY = clientY;
handleAutoScroll(this.#dragX, this.#dragY);
this.#currentDragRect = this.#currentDragElement!.getBoundingClientRect();
const insideCurrentRect = isWithinRect(this.#dragX, this.#dragY, this.#currentDragRect);
if (!insideCurrentRect) {
if (this.#rqaId === null) {
this.#rqaId = requestAnimationFrame(this.moveCurrentElement);
}
}
}
};
moveCurrentElement = () => {
this.#rqaId = undefined;
if (!this.#currentElement || !this.#currentContainerElement || !this.#currentItem) {
return;
}
const currentElementRect = this.#currentElement.getBoundingClientRect();
const insideCurrentRect = isWithinRect(this.#dragX, this.#dragY, currentElementRect);
if (insideCurrentRect) {
return;
}
// If we have a boundarySelector, try it, if we didn't get anything fall back to currentContainerElement.
const currentBoundaryElement =
(this.#config.boundarySelector
? this.#currentContainerElement.closest(this.#config.boundarySelector)
: this.#currentContainerElement) || this.#currentContainerElement;
const currentBoundaryRect = currentBoundaryElement.getBoundingClientRect();
const currentContainerHasItems = this.#currentContainerVM.hasOtherItemsThan(this.#currentItem!);
// if empty we will be move likely to accept an item (add 20px to the bounding box)
// If we have items we must be 10 within the container to accept the move.
const offsetEdge = currentContainerHasItems ? -10 : 20;
if (!isWithinRect(this.#dragX, this.#dragY, currentBoundaryRect, offsetEdge)) {
// we are outside the current container boundary, so lets see if there is a parent we can move.
const parentNode = this.#currentContainerElement.parentNode;
const parentContainer = parentNode ? (parentNode as HTMLElement).closest(this.#config.containerSelector) : null;
if (parentContainer) {
const parentContainerVM = (parentContainer as any)['__umbBlockGridSorterController']();
if (parentContainerVM.unique === this.unique) {
this.#currentContainerElement = parentContainer;
this.#currentContainerVM = parentContainerVM;
if (this.#config.onContainerChange) {
this.#config.onContainerChange({
item: this.#currentItem,
element: this.#currentElement,
//ownerVM: this.#currentContainerVM.ownerVM,
});
}
}
}
}
// We want to retrieve the children of the container, every time to ensure we got the right order and index
const orderedContainerElements = Array.from(this.#currentContainerElement.children);
const currentContainerRect = this.#currentContainerElement.getBoundingClientRect();
// gather elements on the same row.
const elementsInSameRow = [];
let placeholderIsInThisRow = false;
for (const el of orderedContainerElements) {
const elRect = el.getBoundingClientRect();
// gather elements on the same row.
if (this.#dragY >= elRect.top && this.#dragY <= elRect.bottom) {
const dragElement = this.#config.draggableSelector ? el.querySelector(this.#config.draggableSelector) : el;
if (dragElement) {
const dragElementRect = dragElement.getBoundingClientRect();
if (el !== this.#currentElement) {
elementsInSameRow.push({ el: el, dragRect: dragElementRect });
} else {
placeholderIsInThisRow = true;
}
}
}
}
let lastDistance = 99999;
let foundEl: Element | null = null;
let foundElDragRect!: DOMRect;
let placeAfter = false;
elementsInSameRow.forEach((sameRow) => {
const centerX = sameRow.dragRect.left + sameRow.dragRect.width * 0.5;
const distance = Math.abs(this.#dragX - centerX);
if (distance < lastDistance) {
foundEl = sameRow.el;
foundElDragRect = sameRow.dragRect;
lastDistance = distance;
placeAfter = this.#dragX > centerX;
}
});
if (foundEl) {
// If we are on top or closest to our self, we should not do anything.
if (foundEl === this.#currentElement) {
return;
}
const isInsideFound = isWithinRect(this.#dragX, this.#dragY, foundElDragRect, 0);
// If we are inside the found element, lets look for sub containers.
// use the itemHasNestedContainersResolver, if not configured fallback to looking for the existence of a container via DOM.
if (
isInsideFound && this.#config.itemHasNestedContainersResolver
? this.#config.itemHasNestedContainersResolver(foundEl)
: (foundEl as HTMLElement).querySelector(this.#config.containerSelector)
) {
// Find all sub containers:
const subLayouts = (foundEl as HTMLElement).querySelectorAll(this.#config.containerSelector);
for (const subLayoutEl of subLayouts) {
// Use boundary element or fallback to container element.
const subBoundaryElement =
(this.#config.boundarySelector ? subLayoutEl.closest(this.#config.boundarySelector) : subLayoutEl) ||
subLayoutEl;
const subBoundaryRect = subBoundaryElement.getBoundingClientRect();
const subContainerHasItems = subLayoutEl.querySelector(
this.#config.itemSelector + ':not(.' + this.#config.placeholderClass + ')'
);
// gather elements on the same row.
const subOffsetEdge = subContainerHasItems ? -10 : 20;
if (isWithinRect(this.#dragX, this.#dragY, subBoundaryRect, subOffsetEdge)) {
const subVm = (subLayoutEl as any)['__umbBlockGridSorterController']();
if (subVm.unique === this.unique) {
this.#currentContainerElement = subLayoutEl as HTMLElement;
this.#currentContainerVM = subVm;
if (this.#config.onContainerChange) {
this.#config.onContainerChange({
item: this.#currentItem,
element: this.#currentElement,
//ownerVM: this.#currentContainerVM.ownerVM,
});
}
this.moveCurrentElement();
return;
}
}
}
}
// Indication if drop is good:
if (this.updateAllowIndication(this.#currentContainerVM, this.#currentItem) === false) {
return;
}
const verticalDirection = this.#config.resolveVerticalDirection
? this.#config.resolveVerticalDirection({
containerElement: this.#currentContainerElement,
containerRect: currentContainerRect,
item: this.#currentItem,
element: this.#currentElement,
elementRect: currentElementRect,
relatedElement: foundEl,
relatedRect: foundElDragRect,
placeholderIsInThisRow: placeholderIsInThisRow,
horizontalPlaceAfter: placeAfter,
})
: true;
if (verticalDirection) {
placeAfter = this.#dragY > foundElDragRect.top + foundElDragRect.height * 0.5;
}
if (verticalDirection) {
let el;
if (placeAfter === false) {
let lastLeft = foundElDragRect.left;
elementsInSameRow.findIndex((x) => {
if (x.dragRect.left < lastLeft) {
lastLeft = x.dragRect.left;
el = x.el;
}
});
} else {
let lastRight = foundElDragRect.right;
elementsInSameRow.findIndex((x) => {
if (x.dragRect.right > lastRight) {
lastRight = x.dragRect.right;
el = x.el;
}
});
}
if (el) {
foundEl = el;
}
}
const foundElIndex = orderedContainerElements.indexOf(foundEl);
const placeAt = placeAfter ? foundElIndex + 1 : foundElIndex;
this.move(orderedContainerElements, placeAt);
return;
}
// We skipped the above part cause we are above or below container:
// Indication if drop is good:
if (this.updateAllowIndication(this.#currentContainerVM, this.#currentItem) === false) {
return;
}
if (this.#dragY < currentContainerRect.top) {
this.move(orderedContainerElements, 0);
} else if (this.#dragY > currentContainerRect.bottom) {
this.move(orderedContainerElements, -1);
}
};
move(orderedContainerElements: Array<Element>, newElIndex: number) {
if (!this.#currentElement || !this.#currentItem) return;
newElIndex = newElIndex === -1 ? orderedContainerElements.length : newElIndex;
const placeBeforeElement = orderedContainerElements[newElIndex];
if (placeBeforeElement) {
// We do not need to move this, if the element to be placed before is it self.
if (placeBeforeElement !== this.#currentElement) {
this.#currentContainerElement.insertBefore(this.#currentElement, placeBeforeElement);
}
} else {
this.#currentContainerElement.appendChild(this.#currentElement);
}
if (this.#config.onChange) {
this.#config.onChange({
element: this.#currentElement,
item: this.#currentItem,
//ownerVM: this.#currentContainerVM.ownerVM
});
}
}
/** Management methods: */
public getItemOfElement(element: HTMLElement) {
if (!element) {
return null;
}
return this.#model.find((entry: T) => this.#config.compareElementToModel(element, entry));
}
public removeItem(item: T) {
if (!item) {
return null;
}
const oldIndex = this.#model.indexOf(item);
if (oldIndex !== -1) {
return this.#model.splice(oldIndex, 1)[0];
}
return null;
}
hasOtherItemsThan(item: T) {
return this.#model.filter((x) => x !== item).length > 0;
}
public sync(element: HTMLElement, fromVm: UmbSorterController<T>) {
const movingItem = fromVm.getItemOfElement(element);
if (!movingItem) {
console.error('Could not find item of sync item');
return false;
}
if (this.notifyRequestDrop({ item: movingItem }) === false) {
return false;
}
if (fromVm.removeItem(movingItem) === null) {
console.error('Sync could not remove item');
return false;
}
/** Find next element, to then find the index of that element in items-data, to use as a safe reference to where the item will go in our items-data.
* This enables the container to contain various other elements and as well having these elements change while sorting is occurring.
*/
// find next valid element (This assumes the next element in DOM is presented in items-data, aka. only moving one item between each sync)
let nextEl: Element | null = null;
let loopEl: Element | null = element;
while ((loopEl = loopEl?.nextElementSibling)) {
if (loopEl.matches && loopEl.matches(this.#config.itemSelector)) {
nextEl = loopEl;
break;
}
}
let newIndex = this.#model.length;
if (nextEl) {
// We had a reference element, we want to get the index of it.
// This is problem if a item is being moved forward?
newIndex = this.#model.findIndex((entry) => this.#config.compareElementToModel(nextEl! as HTMLElement, entry));
}
this.#model.splice(newIndex, 0, movingItem);
const eventData = { item: movingItem, fromController: fromVm, toController: this };
if (fromVm !== this) {
fromVm.notifySync(eventData);
}
this.notifySync(eventData);
return true;
}
updateAllowIndication(contextVM: UmbSorterController<T>, item: T) {
// Remove old indication:
if (this._lastIndicationContainerVM !== null && this._lastIndicationContainerVM !== contextVM) {
this._lastIndicationContainerVM.notifyAllowed();
}
this._lastIndicationContainerVM = contextVM;
if (contextVM.notifyRequestDrop({ item: item }) === true) {
contextVM.notifyAllowed();
return true;
}
contextVM.notifyDisallowed(); // This block is not accepted to we will indicate that its not allowed.
return false;
}
removeAllowIndication() {
// Remove old indication:
if (this._lastIndicationContainerVM !== null) {
this._lastIndicationContainerVM.notifyAllowed();
}
this._lastIndicationContainerVM = null;
}
// TODO: Move auto scroll into its own class?
#autoScrollRAF: number | null = null;
#autoScrollEl?: Element;
private autoScrollX = 0;
private autoScrollY = 0;
private handleAutoScroll(clientX: number, clientY: number) {
let scrollRect = null;
if (this.#scrollElement) {
this.#autoScrollEl = this.#scrollElement;
scrollRect = this.#autoScrollEl.getBoundingClientRect();
} else {
this.#autoScrollEl = document.scrollingElement || document.documentElement;
scrollRect = {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
height: window.innerHeight,
width: window.innerWidth,
};
}
const scrollWidth = this.#autoScrollEl.scrollWidth;
const scrollHeight = this.#autoScrollEl.scrollHeight;
const canScrollX = scrollRect.width < scrollWidth;
const canScrollY = scrollRect.height < scrollHeight;
const scrollPosX = this.#autoScrollEl.scrollLeft;
const scrollPosY = this.#autoScrollEl.scrollTop;
cancelAnimationFrame(this.#autoScrollRAF!);
if (canScrollX || canScrollY) {
this.autoScrollX =
Math.abs(scrollRect.right - clientX) <= autoScrollSensitivity && scrollPosX + scrollRect.width < scrollWidth
? 1
: Math.abs(scrollRect.left - clientX) <= autoScrollSensitivity && !!scrollPosX
? -1
: 0;
this.autoScrollY =
Math.abs(scrollRect.bottom - clientY) <= autoScrollSensitivity && scrollPosY + scrollRect.height < scrollHeight
? 1
: Math.abs(scrollRect.top - clientY) <= autoScrollSensitivity && !!scrollPosY
? -1
: 0;
this.#autoScrollRAF = requestAnimationFrame(this._performAutoScroll);
}
}
private _performAutoScroll() {
this.#autoScrollEl!.scrollLeft += this.autoScrollX * autoScrollSpeed;
this.#autoScrollEl!.scrollTop += this.autoScrollY * autoScrollSpeed;
this.#autoScrollRAF = requestAnimationFrame(this._performAutoScroll);
}
private stopAutoScroll() {
cancelAnimationFrame(this.#autoScrollRAF!);
this.#autoScrollRAF = null;
}
public notifySync(data: any) {
if (this.#config.onSync) {
this.#config.onSync(data);
}
}
public notifyDisallowed() {
if (this.#config.onDisallowed) {
this.#config.onDisallowed();
}
}
public notifyAllowed() {
if (this.#config.onAllowed) {
this.#config.onAllowed();
}
}
public notifyRequestDrop(data: any) {
if (this.#config.onRequestDrop) {
return this.#config.onRequestDrop(data) || false;
}
return true;
}
destroy() {
// Do something when host element is destroyed.
if (this.#currentElement) {
this.handleDragEnd();
}
this._lastIndicationContainerVM = null;
(this.#host as any)['__umbBlockGridSorterController'] = null;
this.#host.removeEventListener('dragover', preventDragOver);
this.#observer.disconnect();
// For auto scroller:
this.#scrollElement = null;
this.#autoScrollEl = undefined;
}
}