diff --git a/src/Umbraco.Web.UI.Client/libs/sorter/sorter.controller.ts b/src/Umbraco.Web.UI.Client/libs/sorter/sorter.controller.ts new file mode 100644 index 0000000000..d3cc5c6683 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/libs/sorter/sorter.controller.ts @@ -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 = { + 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; + toController: UmbSorterController; + }) => 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 implements UmbControllerInterface { + #host; + #config; + #observer; + + #model: Array = []; + #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 | null = null; + + public get unique() { + return this.#config.identifier; + } + + constructor(host: UmbControllerHostElement, config: UmbSorterConfig) { + 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) { + 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, 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) { + 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, 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; + } +}