// drag-move // drag-drop // drag-finish // drag-cancel export class DragManager { public listenerEvents: string[] = ["mousedown", "mouseup", "mousemove"] public capture: boolean = true public behavior: DragManager.Behavior public lifeCircle: DragManager.LifeCircle public minMovement: 0 public lastEvent: MouseEvent // body will add this class when we are dragging public bodyDraggingClass = "dragging" public shadow: DragManager.ShadowManager constructor() { this.behavior = new DragManager.Behavior(this) this.shadow = new DragManager.ShadowManager(this) this.lifeCircle = new DragManager.LifeCircle(this) for (let name of this.listenerEvents) { let fnName = name + "Listener" this[fnName] = (e) => { this.lastEvent = e this.lifeCircle.feed("mouse", e) } } } public initialize() { this.attachListeners() this.lifeCircle.init() } public attachListeners() { for (let name of this.listenerEvents) { window.addEventListener(name, this[name + "Listener"], this.capture) } } public detachListeners() { for (let name of this.listenerEvents) { window.removeEventListener(name, this[name + "Listener"]) } } } export namespace DragManager { export class LifeCircle extends Leaf.States<"waitMouseDown" | "waitInitMouseMove" | "handleInitMove" | "waitMouseContinue" | "handleMouseUp", { mouse: MouseEvent }> { protected data: { initMouseDown: MouseEvent initMoveEvent: MouseEvent finalUpEvent: MouseEvent } constructor(public readonly dragManager: DragManager) { super() } init() { if (this.state === "void") { this.setState("waitMouseDown") } } public reset() { super.reset() if (this.dragManager) this.dragManager.behavior.clearDrag() } atWaitMouseDown() { this.dragManager.behavior.clearDrag() this.consumeWhenAvailable("mouse", (e: MouseEvent) => { if (e.type === "mousedown") { this.data.initMouseDown = e this.setState("waitInitMouseMove") } else { this.setState("waitMouseDown") } }) } atWaitInitMouseMove() { this.consumeWhenAvailable("mouse", (e: MouseEvent) => { if (e.type === "mousemove") { if (MouseEventHelper.getMouseDistance(e, this.data.initMouseDown) < this.dragManager.minMovement) { this.setState("waitInitMouseMove") } else { this.data.initMoveEvent = e this.setState("handleInitMove") } } else { this.setState("waitMouseDown") } }) } atHandleInitMove() { if (!this.dragManager.behavior.dragStart(this.data.initMouseDown)) { this.setState("waitMouseDown") return } this.setState("waitMouseContinue") } atWaitMouseContinue() { this.consumeWhenAvailable("mouse", (e: MouseEvent) => { if (e.type === "mousemove") { this.dragManager.behavior.dragMove(e) this.setState("waitMouseContinue") } else if (e.type === "mouseup") { this.data.finalUpEvent = e this.setState("handleMouseUp") } else { this.setState("waitMouseDown") } }) } atHandleMouseUp() { this.dragManager.behavior.drop(this.data.finalUpEvent) this.setState("waitMouseDown") } } export class MouseEventHelper { static computeRelativeMousePosition(el: HTMLElement, e: MouseEvent) { let src = this.getMouseSrc(e) if (!el.contains(src)) return null let rect = el.getBoundingClientRect() return { x: rect.left - e.clientX , y: rect.top - e.clientY } } static getMouseSrc(e: Event): HTMLElement | null { if (!e) return null return e.srcElement as HTMLElement || e.target as HTMLElement } static isElementDraggable(el: DragManager.DraggableElement) { return el && (el.dragSupport === "enabled" || el.getAttribute("drag-support") === "enabled") } static isElementDragless(el: DragManager.DraggableElement) { return !el || (el.dragSupport === "disabled" || el.getAttribute("drag-support") === "disabled") } static getDefaultShadow(el) { let copy = document.createElement("div") copy.style.width = "100px" copy.style.height = "100px" copy.style.border = "3px solid rgba(0,0,0,0.7)" copy.style.opacity = "0.5" copy.style.backgroundColor = "white" copy.style.pointerEvents = "none" copy.style.transition = "opacity 200ms" copy.style.transform = "scale(0.7)" return copy } static getMousePosition(e: MouseEvent) { if (!e) return null return { x: e.clientX , y: e.clientY } } static getMouseDistance(e1: MouseEvent, e2: MouseEvent) { let p1 = this.getMousePosition(e1) let p2 = this.getMousePosition(e2) let dx = p1.x - p2.x let dy = p1.y - p2.y return Math.sqrt(dx * dx + dy * dy) } static getDraggable(e: Event): HTMLElement { let el = this.getMouseSrc(e) while (el) { if (this.isElementDragless(el)) { return null } else if (this.isElementDraggable(el)) { return el } el = el.parentElement } } static createCustomEvent(name: string, data: any) { return new CustomEvent(name, data) } } export class Behavior { // getter/setter public currentDraggable: DraggableElement public draggingElement: DraggableElement public dragSession: DragManager.Session constructor(public readonly dragManager: DragManager) { let preventDefault = (e: Event) => { e.preventDefault() e.stopImmediatePropagation() } let draggingElement = null // preventing Object.defineProperty(this, "draggingElement", { get: () => { return draggingElement }, set: (v) => { if (draggingElement) { } if (v === draggingElement) return if (draggingElement) { let el = draggingElement setTimeout(() => { el.removeEventListener("click", preventDefault, true) }, 1) } draggingElement = v if (!v) return draggingElement.addEventListener("click", preventDefault, true) } }) } public clearDrag() { this.dragManager.shadow.clear() this.draggingElement = null document.body.classList.remove(this.dragManager.bodyDraggingClass) } public dragStart(e: MouseEvent): boolean { let src = MouseEventHelper.getDraggable(e) this.clearDrag() if (!src) return false e.preventDefault() e.stopImmediatePropagation() document.body.classList.add(this.dragManager.bodyDraggingClass) this.dragSession = new Session(this.dragManager) this.currentDraggable = src let event = MouseEventHelper.createCustomEvent("drag-init", { detail: this.dragSession, bubbles: true }) src.dispatchEvent(event) this.draggingElement = src if (this.draggingElement.dragBehavior === "auto" || this.draggingElement.getAttribute("drag-behavior") === "auto") { this.dragManager.shadow.shadowScale = 0.7 this.dragManager.shadow.setDraggingStyle() this.dragManager.shadow.setShadowElement(MouseEventHelper.getDefaultShadow(this.draggingElement), MouseEventHelper.computeRelativeMousePosition(src, e)) } return true } public dragMove(e: MouseEvent) { let event = MouseEventHelper.createCustomEvent("drag-move", { detail: this.dragSession, bubbles: true }) if (this.draggingElement) { this.draggingElement.dispatchEvent(event) } let target = MouseEventHelper.getMouseSrc(e) event = MouseEventHelper.createCustomEvent("drag-stand", { detail: this.dragSession, bubbles: true }) target.dispatchEvent(event) if (!event.defaultPrevented) { for (let p of this.dragSession.protocols) { let event = MouseEventHelper.createCustomEvent(`drag-stand/${p.type}`, { detail: this.dragSession, protocol: p, bubbles: true }) target.dispatchEvent(event) } } target.dispatchEvent(event) this.dragManager.shadow.updateShadowPosition() } public drop(e: MouseEvent) { this.dragManager.shadow.restoreDraggingStyle() let target = MouseEventHelper.getMouseSrc(e) let event = MouseEventHelper.createCustomEvent("drag-drop", { detail: this.dragSession, bubbles: true }) target.dispatchEvent(event) if (!event.defaultPrevented) { for (let p of this.dragSession.protocols) { let event = MouseEventHelper.createCustomEvent(`drag-drop/${p.type}`, { detail: this.dragSession , bubbles: true }) target.dispatchEvent(event) } } event = MouseEventHelper.createCustomEvent("drag-finish", { detail: this.dragSession , bubbles: true }) if (this.draggingElement.contains(target)) { e.preventDefault() e.stopImmediatePropagation() } if (this.dragSession.isConsumed) { event = MouseEventHelper.createCustomEvent("drag-cancel", { detail: this.dragSession , bubbles: true }) } this.draggingElement.dispatchEvent(event) this.draggingElement = null this.dragManager.shadow.clear() } } export class ShadowManager { constructor(public readonly dragManager: DragManager) { } public draggingShadow: HTMLElement public dragFix = { x: 0, y: 0 } public savedStyle: { opacity: string transition: string } = null public savedElement: DraggableElement // scaling the shadow public shadowScale: number = 0.7 public shadowZIndex: number = 1000000 public setShadowElement(el: HTMLElement, fix: { x: number, y: number } = { x: 0, y: 0 }) { if (this.draggingShadow && this.draggingShadow.parentElement) { this.draggingShadow.parentElement.removeChild(this.draggingShadow) } this.draggingShadow = el document.body.appendChild(el) el.style.position = "absolute" el.style.pointerEvents = "none" el.style.top = "0" el.style.left = "0" el.style.zIndex = this.shadowZIndex.toString() this.dragFix = fix || { x: 0, y: 0 } if (!this.dragFix.x) this.dragFix.x = 0 if (!this.dragFix.y) this.dragFix.y = 0 this.updateShadowPosition() } public updateShadowPosition() { if (!this.draggingShadow) return let point = MouseEventHelper.getMousePosition(this.dragManager.lastEvent) let scaleFix = this.shadowScale || 0.7 this.draggingShadow.style.position = "absolute" this.draggingShadow.style.left = point.x + this.dragFix.x * scaleFix + "px" this.draggingShadow.style.top = point.y + this.dragFix.y * scaleFix + "px" this.draggingShadow.style.transform = "scale(${this.shadowScale})" if (!this.draggingShadow.parentElement) { document.body.appendChild(this.draggingShadow) } } public setDraggingStyle() { if (this.savedElement) { this.restoreDraggingStyle() } this.savedElement = this.dragManager.behavior.draggingElement this.savedStyle = { opacity: this.savedElement.style.opacity, transition: this.savedElement.style.transition } this.savedElement.style.opacity = "0.15" this.savedElement.style.transition = "all 200ms" } public clear() { if (this.draggingShadow && this.draggingShadow.parentElement) { this.draggingShadow.parentElement.removeChild(this.draggingShadow) } this.draggingShadow = null } public restoreDraggingStyle() { if (this.savedElement) { this.savedElement.style.opacity = this.savedStyle.opacity this.savedElement.style.transition = this.savedStyle.transition } this.savedElement = null } } export interface Protocol { type: string, data: any } export class Session { public protocols: Protocol[] = [] public protocol: Protocol public isConsumed: boolean = false constructor(public readonly dragManager: DragManager) { Object.defineProperty(this, "protocol", { get: () => { return this.protocols[0] } }) } public add(p: Protocol) { this.protocols.push(p) } public consume() { this.isConsumed = true } } export interface DraggableElement extends HTMLElement { dragBehavior?: "auto" | "none" dragSupport?: "disabled" | "enabled" } export const instance: DragManager = new DragManager() }