diff --git a/demo/two-h5.html b/demo/two-h5.html new file mode 100644 index 000000000..b4f3c8fe9 --- /dev/null +++ b/demo/two-h5.html @@ -0,0 +1,124 @@ + + + + + + + Two grids demo + + + + + + + + + + +
+

Two grids demo

+ +
+
+ +
+
+
+
+
+
+ +
+ + +
+
+ + + + diff --git a/src/dragdrop/dd-base-impl.ts b/src/dragdrop/dd-base-impl.ts new file mode 100644 index 000000000..34b271559 --- /dev/null +++ b/src/dragdrop/dd-base-impl.ts @@ -0,0 +1,42 @@ +// dd-base-impl.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +export type EventCallback = (event: Event) => boolean|void; +export abstract class DDBaseImplement { + disabled = false; + private eventRegister: { + [eventName: string]: EventCallback; + } = {}; + on(event: string, callback: EventCallback): void { + this.eventRegister[event] = callback; + } + off(event: string) { + delete this.eventRegister[event]; + } + enable(): void { + this.disabled = false; + } + disable(): void { + this.disabled = true; + } + destroy() { + this.eventRegister = undefined; + } + triggerEvent(eventName: string, event: Event): boolean|void { + if (this.disabled) { return; } + if (!this.eventRegister) {return; } // used when destroy before triggerEvent fire + if (this.eventRegister[eventName]) { + return this.eventRegister[eventName](event); + } + } +} + +export interface HTMLElementExtendOpt { + el: HTMLElement; + option: T; + updateOption(T): void; +} diff --git a/src/dragdrop/dd-draggable.ts b/src/dragdrop/dd-draggable.ts new file mode 100644 index 000000000..5805ee2d1 --- /dev/null +++ b/src/dragdrop/dd-draggable.ts @@ -0,0 +1,323 @@ +// dd-draggable.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDManager } from './dd-manager'; +import { DDUtils } from './dd-utils'; +import { DDBaseImplement, HTMLElementExtendOpt } from './dd-base-impl'; + +export interface DDDraggableOpt { + appendTo?: string | HTMLElement; + containment?: string | HTMLElement; // TODO: not implemented yet + handle?: string; + revert?: string | boolean | unknown; // TODO: not implemented yet + scroll?: boolean; // nature support by HTML5 drag drop, can't be switch to off actually + helper?: string | ((event: Event) => HTMLElement); + basePosition?: 'fixed' | 'absolute'; + start?: (event?, ui?) => void; + stop?: (event?, ui?) => void; + drag?: (event?, ui?) => void; +}; +export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt { + static basePosition: 'fixed' | 'absolute' = 'absolute'; + static dragEventListenerOption = DDUtils.isEventSupportPassiveOption ? { capture: true, passive: true } : true; + static originStyleProp = ['transition', 'pointerEvents', 'position', + 'left', 'top', 'opacity', 'zIndex', 'width', 'height', 'willChange']; + el: HTMLElement; + helper: HTMLElement; + option: DDDraggableOpt; + dragOffset: { + left: number; + top: number; + width: number; + height: number; + offsetLeft: number; + offsetTop: number; + }; + dragElementOriginStyle: Array; + dragFollowTimer: number; + mouseDownElement: HTMLElement; + dragging = false; + paintTimer: number; + parentOriginStylePosition: string; + helperContainment: HTMLElement; + + constructor(el: HTMLElement, option: DDDraggableOpt) { + super(); + this.el = el; + this.option = option || {}; + this.init(); + } + + on(event: 'drag' | 'dragstart' | 'dragstop', callback: (event: DragEvent) => void): void { + super.on(event, callback); + } + + off(event: 'drag' | 'dragstart' | 'dragstop') { + super.off(event); + } + + enable() { + super.enable(); + this.el.draggable = true; + this.el.classList.remove('ui-draggable-disabled'); + } + + disable() { + super.disable(); + this.el.draggable = false; + this.el.classList.add('ui-draggable-disabled'); + } + + updateOption(opts) { + Object.keys(opts).forEach(key => { + const value = opts[key]; + this.option[key] = value; + }); + } + + protected init() { + this.el.draggable = true; + this.el.classList.add('ui-draggable'); + this.el.addEventListener('mousedown', this.mouseDown); + this.el.addEventListener('dragstart', this.dragStart); + } + + protected mouseDown = (event: MouseEvent) => { + this.mouseDownElement = event.target as HTMLElement; + } + + protected dragStart = (event: DragEvent) => { + if (this.option.handle && !( + this.mouseDownElement + && this.mouseDownElement.matches( + `${this.option.handle}, ${this.option.handle} > *` + ) + )) { + event.preventDefault(); + return; + } + DDManager.dragElement = this; + this.helper = this.createHelper(event); + this.setupHelperContainmentStyle(); + this.dragOffset = this.getDragOffset(event, this.el, this.helperContainment); + const ev = DDUtils.initEvent(event, { target: this.el, type: 'dragstart' }); + if (this.helper !== this.el) { + this.setupDragFollowNodeNNotifyStart(ev); + } else { + this.dragFollowTimer = window.setTimeout(() => { + this.dragFollowTimer = undefined; + this.setupDragFollowNodeNNotifyStart(ev); + }, 0); + } + this.cancelDragGhost(event); + } + + protected setupDragFollowNodeNNotifyStart(ev) { + this.setupHelperStyle(); + document.addEventListener('dragover', this.drag, DDDraggable.dragEventListenerOption); + this.el.addEventListener('dragend', this.dragEnd); + if (this.option.start) { + this.option.start(ev, this.ui()); + } + this.dragging = true; + this.helper.classList.add('ui-draggable-dragging'); + this.triggerEvent('dragstart', ev); + } + + protected drag = (event: DragEvent) => { + this.dragFollow(event); + const ev = DDUtils.initEvent(event, { target: this.el, type: 'drag' }); + if (this.option.drag) { + this.option.drag(ev, this.ui()); + } + this.triggerEvent('drag', ev); + } + + protected dragEnd = (event: DragEvent) => { + if (this.dragFollowTimer) { + clearTimeout(this.dragFollowTimer); + this.dragFollowTimer = undefined; + return; + } else { + if (this.paintTimer) { + cancelAnimationFrame(this.paintTimer); + } + document.removeEventListener('dragover', this.drag, DDDraggable.dragEventListenerOption); + this.el.removeEventListener('dragend', this.dragEnd); + } + this.dragging = false; + this.helper.classList.remove('ui-draggable-dragging'); + this.helperContainment.style.position = this.parentOriginStylePosition || null; + if (this.helper === this.el) { + this.removeHelperStyle(); + } else { + this.helper.remove(); + } + const ev = DDUtils.initEvent(event, { target: this.el, type: 'dragstop' }); + if (this.option.stop) { + this.option.stop(ev, this.ui()); + } + this.triggerEvent('dragstop', ev); + DDManager.dragElement = undefined; + this.helper = undefined; + this.mouseDownElement = undefined; + } + + private createHelper(event: DragEvent) { + const helperIsFunction = (typeof this.option.helper) === 'function'; + const helper = (helperIsFunction + ? (this.option.helper as ((event: Event) => HTMLElement)).apply(this.el, [event]) + : (this.option.helper === "clone" ? DDUtils.clone(this.el) : this.el) + ) as HTMLElement; + if (!document.body.contains(helper)) { + DDUtils.appendTo(helper, (this.option.appendTo === "parent" + ? this.el.parentNode + : this.option.appendTo)); + } + if (helper === this.el) { + this.dragElementOriginStyle = DDDraggable.originStyleProp.map(prop => this.el.style[prop]); + } + return helper; + } + + private setupHelperStyle() { + this.helper.style.pointerEvents = 'none'; + this.helper.style.width = this.dragOffset.width + 'px'; + this.helper.style.height = this.dragOffset.height + 'px'; + this.helper.style['willChange'] = 'left, top'; + this.helper.style.transition = 'none'; // show up instantly + this.helper.style.position = this.option.basePosition || DDDraggable.basePosition; + this.helper.style.zIndex = '1000'; + setTimeout(() => { + if (this.helper) { + this.helper.style.transition = null; // recover animation + } + }, 0); + } + + private removeHelperStyle() { + DDDraggable.originStyleProp.forEach(prop => { + this.helper.style[prop] = this.dragElementOriginStyle[prop] || null; + }); + this.dragElementOriginStyle = undefined; + } + + private dragFollow = (event: DragEvent) => { + if (this.paintTimer) { + cancelAnimationFrame(this.paintTimer); + } + this.paintTimer = requestAnimationFrame(() => { + this.paintTimer = undefined; + const offset = this.dragOffset; + let containmentRect = { left: 0, top: 0 }; + if (this.helper.style.position === 'absolute') { + const { left, top } = this.helperContainment.getBoundingClientRect(); + containmentRect = { left, top }; + } + this.helper.style.left = event.clientX + offset.offsetLeft - containmentRect.left + 'px'; + this.helper.style.top = event.clientY + offset.offsetTop - containmentRect.top + 'px'; + }); + } + + private setupHelperContainmentStyle() { + this.helperContainment = this.helper.parentElement; + if (this.option.basePosition !== 'fixed') { + this.parentOriginStylePosition = this.helperContainment.style.position; + if (window.getComputedStyle(this.helperContainment).position.match(/static/)) { + this.helperContainment.style.position = 'relative'; + } + } + } + + private cancelDragGhost(e: DragEvent) { + if (e.dataTransfer != null) { + e.dataTransfer.setData('text', ''); + } + e.dataTransfer.effectAllowed = 'move'; + if ('function' === typeof DataTransfer.prototype.setDragImage) { + e.dataTransfer.setDragImage(new Image(), 0, 0); + } else { + // ie + (e.target as HTMLElement).style.display = 'none'; + setTimeout(() => { + (e.target as HTMLElement).style.display = ''; + }); + e.stopPropagation(); + return; + } + e.stopPropagation(); + } + + private getDragOffset(event: DragEvent, el: HTMLElement, attachedParent: HTMLElement) { + // in case ancestor has transform/perspective css properties that change the viewpoint + const getViewPointFromParent = (parent) => { + if (!parent) { return null; } + const testEl = document.createElement('div'); + DDUtils.addElStyles(testEl, { + opacity: '0', + position: 'fixed', + top: 0 + 'px', + left: 0 + 'px', + width: '1px', + height: '1px', + zIndex: '-999999', + }); + parent.appendChild(testEl); + const testElPosition = testEl.getBoundingClientRect(); + parent.removeChild(testEl); + return { + offsetX: testElPosition.left, + offsetY: testElPosition.top + }; + } + const targetOffset = el.getBoundingClientRect(); + const mousePositionXY = { + x: event.clientX, + y: event.clientY + }; + const transformOffset = getViewPointFromParent(attachedParent); + return { + left: targetOffset.left, + top: targetOffset.top, + offsetLeft: - mousePositionXY.x + targetOffset.left - transformOffset.offsetX, + offsetTop: - mousePositionXY.y + targetOffset.top - transformOffset.offsetY, + width: targetOffset.width, + height: targetOffset.height + }; + } + destroy() { + if (this.dragging) { + // Destroy while dragging should remove dragend listener and manually trigger + // dragend, otherwise dragEnd can't perform dragstop because eventRegistry is + // destroyed. + this.dragEnd({} as DragEvent); + } + this.el.draggable = false; + this.el.classList.remove('ui-draggable'); + this.el.removeEventListener('dragstart', this.dragStart); + this.el = undefined; + this.helper = undefined; + this.option = undefined; + super.destroy(); + } + + ui = () => { + const containmentEl = this.el.parentElement; + const containmentRect = containmentEl.getBoundingClientRect(); + const offset = this.helper.getBoundingClientRect(); + return { + helper: [this.helper], //The object arr representing the helper that's being dragged. + position: { + top: offset.top - containmentRect.top, + left: offset.left - containmentRect.left + }, //Current CSS position of the helper as { top, left } object + offset: { top: offset.top, left: offset.left } // Current offset position of the helper as { top, left } object. + }; + } +} + + diff --git a/src/dragdrop/dd-droppable.ts b/src/dragdrop/dd-droppable.ts new file mode 100644 index 000000000..45670ba1d --- /dev/null +++ b/src/dragdrop/dd-droppable.ts @@ -0,0 +1,162 @@ +// dd-droppable.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDDraggable } from './dd-draggable'; +import { DDManager } from './dd-manager'; +import { DDBaseImplement, HTMLElementExtendOpt } from './dd-base-impl'; +import { DDUtils } from './dd-utils'; + +export interface DDDroppableOpt { + accept?: string | ((el: HTMLElement) => boolean); + drop?: (event: DragEvent, ui) => void; + over?: (event: DragEvent, ui) => void; + out?: (event: DragEvent, ui) => void; +}; +export class DDDroppable extends DDBaseImplement implements HTMLElementExtendOpt { + accept: (el: HTMLElement) => boolean; + el: HTMLElement; + option: DDDroppableOpt; + private acceptable: boolean = null; + private style; + constructor(el: HTMLElement, opts: DDDroppableOpt) { + super(); + this.el = el; + this.option = opts || {}; + this.init(); + } + on(event: 'drop' | 'dropover' | 'dropout', callback: (event: DragEvent) => void): void { + super.on(event, callback); + } + off(event: 'drop' | 'dropover' | 'dropout') { + super.off(event); + } + enable() { + if (!this.disabled) { return; } + super.enable(); + this.el.classList.remove('ui-droppable-disabled'); + this.el.addEventListener('dragenter', this.dragEnter); + } + disable() { + if (this.disabled) { return; } + super.disable(); + this.el.classList.add('ui-droppable-disabled'); + this.el.removeEventListener('dragenter', this.dragEnter); + } + updateOption(opts) { + Object.keys(opts).forEach(key => { + const value = opts[key]; + this.option[key] = value; + }); + this.setupAccept(); + } + + protected init() { + this.el.classList.add('ui-droppable'); + this.el.addEventListener('dragenter', this.dragEnter); + + this.setupAccept(); + this.createStyleSheet(); + } + + protected dragEnter = (event: DragEvent) => { + this.el.removeEventListener('dragenter', this.dragEnter); + this.acceptable = this.canDrop(); + if (this.acceptable) { + event.preventDefault(); + const ev = DDUtils.initEvent(event, { target: this.el, type: 'dropover' }); + if (this.option.over) { + this.option.over(ev, this.ui(DDManager.dragElement)) + } + this.triggerEvent('dropover', ev); + this.el.addEventListener('dragover', this.dragOver); + this.el.addEventListener('drop', this.drop); + } + this.el.classList.add('ui-droppable-over'); + this.el.addEventListener('dragleave', this.dragLeave); + + } + protected dragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + } + protected dragLeave = (event: DragEvent) => { + if (this.el.contains(event.relatedTarget as HTMLElement)) { return; }; + this.el.removeEventListener('dragleave', this.dragLeave); + this.el.classList.remove('ui-droppable-over'); + if (this.acceptable) { + event.preventDefault(); + this.el.removeEventListener('dragover', this.dragOver); + this.el.removeEventListener('drop', this.drop); + const ev = DDUtils.initEvent(event, { target: this.el, type: 'dropout' }); + if (this.option.out) { + this.option.out(ev, this.ui(DDManager.dragElement)) + } + this.triggerEvent('dropout', ev); + } + this.el.addEventListener('dragenter', this.dragEnter); + } + + protected drop = (event: DragEvent) => { + if (this.acceptable) { + event.preventDefault(); + const ev = DDUtils.initEvent(event, { target: this.el, type: 'drop' }); + if (this.option.drop) { + this.option.drop(ev, this.ui(DDManager.dragElement)) + } + this.triggerEvent('drop', ev); + this.dragLeave({ + ...ev, + relatedTarget: null, + preventDefault: () => { + // do nothing + } + }); + } + } + private canDrop() { + return DDManager.dragElement && (!this.accept || this.accept(DDManager.dragElement.el)); + } + private setupAccept() { + if (this.option.accept && typeof this.option.accept === 'string') { + this.accept = (el: HTMLElement) => { + return el.matches(this.option.accept as string) + } + } else { + this.accept = this.option.accept as ((el: HTMLElement) => boolean); + } + } + + private createStyleSheet() { + const content = `.ui-droppable.ui-droppable-over > *:not(.ui-droppable) {pointer-events: none;}`; + this.style = document.createElement('style'); + this.style.innerText = content; + this.el.appendChild(this.style); + } + private removeStyleSheet() { + this.el.removeChild(this.style); + } + + destroy() { + this.el.classList.remove('ui-droppable'); + if (this.disabled) { + this.el.classList.remove('ui-droppable-disabled'); + this.el.removeEventListener('dragenter', this.dragEnter); + this.el.removeEventListener('dragover', this.dragOver); + this.el.removeEventListener('drop', this.drop); + this.el.removeEventListener('dragleave', this.dragLeave); + } + super.destroy(); + } + + ui(DDDraggable: DDDraggable) { + return { + draggable: DDDraggable.el, + ...DDDraggable.ui() + }; + } +} + diff --git a/src/dragdrop/dd-element.ts b/src/dragdrop/dd-element.ts new file mode 100644 index 000000000..36eb41f85 --- /dev/null +++ b/src/dragdrop/dd-element.ts @@ -0,0 +1,93 @@ +// dd-elements.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDResizable, DDResizableOpt } from './dd-resizable'; +import { GridItemHTMLElement } from './../types'; +import { DDDraggable, DDDraggableOpt } from './dd-draggable'; +import { DDDroppable, DDDroppableOpt } from './dd-droppable'; +export interface DDElementHost extends GridItemHTMLElement { + ddElement?: DDElement; +} +export class DDElement { + static init(el) { + el.ddElement = new DDElement(el); + return el.ddElement; + } + el: DDElementHost; + ddDraggable?: DDDraggable; + ddDroppable?: DDDroppable; + ddResizable?: DDResizable; + constructor(el: DDElementHost) { + this.el = el; + } + on(eventName: string, callback: (event: MouseEvent) => void) { + if (this.ddDraggable && ['drag', 'dragstart', 'dragstop'].indexOf(eventName) > -1) { + this.ddDraggable.on(eventName as 'drag' | 'dragstart' | 'dragstop', callback); + return; + } + if (this.ddDroppable && ['drop', 'dropover', 'dropout'].indexOf(eventName) > -1) { + this.ddDroppable.on(eventName as 'drop' | 'dropover' | 'dropout', callback); + return; + } + if (this.ddResizable && ['resizestart', 'resize', 'resizestop'].indexOf(eventName) > -1) { + this.ddResizable.on(eventName as 'resizestart' | 'resize' | 'resizestop', callback); + return; + } + return; + } + off(eventName: string) { + if (this.ddDraggable && ['drag', 'dragstart', 'dragstop'].indexOf(eventName) > -1) { + this.ddDraggable.off(eventName as 'drag' | 'dragstart' | 'dragstop'); + return; + } + if (this.ddDroppable && ['drop', 'dropover', 'dropout'].indexOf(eventName) > -1) { + this.ddDroppable.off(eventName as 'drop' | 'dropover' | 'dropout'); + return; + } + if (this.ddResizable && ['resizestart', 'resize', 'resizestop'].indexOf(eventName) > -1) { + this.ddResizable.off(eventName as 'resizestart' | 'resize' | 'resizestop'); + return; + } + return; + } + setupDraggable(opts: DDDraggableOpt) { + if (!this.ddDraggable) { + this.ddDraggable = new DDDraggable(this.el, opts); + } else { + this.ddDraggable.updateOption(opts); + } + } + setupResizable(opts: DDResizableOpt) { + if (!this.ddResizable) { + this.ddResizable = new DDResizable(this.el, opts); + } else { + this.ddResizable.updateOption(opts); + } + } + cleanDraggable() { + if (!this.ddDraggable) { return; } + this.ddDraggable.destroy(); + this.ddDraggable = undefined; + } + setupDroppable(opts: DDDroppableOpt) { + if (!this.ddDroppable) { + this.ddDroppable = new DDDroppable(this.el, opts); + } else { + this.ddDroppable.updateOption(opts); + } + } + cleanDroppable() { + if (!this.ddDroppable) { return; } + this.ddDroppable.destroy(); + this.ddDroppable = undefined; + } + cleanResizable() { + if (!this.cleanResizable) { return; } + this.ddResizable.destroy(); + this.ddResizable = undefined; + } +} diff --git a/src/dragdrop/dd-manager.ts b/src/dragdrop/dd-manager.ts new file mode 100644 index 000000000..702bd93a8 --- /dev/null +++ b/src/dragdrop/dd-manager.ts @@ -0,0 +1,11 @@ +// dd-manager.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDDraggable } from './dd-draggable'; +export class DDManager { + static dragElement: DDDraggable; +} diff --git a/src/dragdrop/dd-resizable-handle.ts b/src/dragdrop/dd-resizable-handle.ts new file mode 100644 index 000000000..60fa65b6a --- /dev/null +++ b/src/dragdrop/dd-resizable-handle.ts @@ -0,0 +1,106 @@ +// dd-resizable-handle.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +export interface DDResizableHandleOpt { + start?: (event) => void; + move?: (event) => void; + stop?: (event) => void; +} +export class DDResizableHandle { + static prefix = 'ui-resizable-'; + el: HTMLElement; + host: HTMLElement; + option: DDResizableHandleOpt; + dir: string; + private mouseMoving = false; + private started = false; + private mouseDownEvent: MouseEvent; + constructor(host: HTMLElement, direction: string, option: DDResizableHandleOpt) { + this.host = host; + this.dir = direction; + this.option = option; + this.init(); + } + + init() { + const el = document.createElement('div'); + el.classList.add('ui-resizable-handle'); + el.classList.add(`${DDResizableHandle.prefix}${this.dir}`); + el.style.zIndex = '100'; + el.style.userSelect = 'none'; + this.el = el; + this.host.appendChild(this.el); + this.el.addEventListener('mousedown', this.mouseDown); + } + + protected mouseDown = (event: MouseEvent) => { + this.mouseDownEvent = event; + setTimeout(() => { + document.addEventListener('mousemove', this.mouseMove, true); + document.addEventListener('mouseup', this.mouseUp); + setTimeout(() => { + if (!this.mouseMoving) { + document.removeEventListener('mousemove', this.mouseMove, true); + document.removeEventListener('mouseup', this.mouseUp); + this.mouseDownEvent = undefined; + } + }, 300); + }, 100); + } + + protected mouseMove = (event: MouseEvent) => { + if (!this.started && !this.mouseMoving) { + if (this.hasMoved(event, this.mouseDownEvent)) { + this.mouseMoving = true; + this.triggerEvent('start', this.mouseDownEvent); + this.started = true; + } + } + if (this.started) { + this.triggerEvent('move', event); + } + } + + protected mouseUp = (event: MouseEvent) => { + if (this.mouseMoving) { + this.triggerEvent('stop', event); + } + document.removeEventListener('mousemove', this.mouseMove, true); + document.removeEventListener('mouseup', this.mouseUp); + this.mouseMoving = false; + this.started = false; + this.mouseDownEvent = undefined; + } + + private hasMoved(event: MouseEvent, oEvent: MouseEvent) { + const { clientX, clientY } = event; + const { clientX: oClientX, clientY: oClientY } = oEvent; + return ( + Math.abs(clientX - oClientX) > 1 + || Math.abs(clientY - oClientY) > 1 + ); + } + + show() { + this.el.style.display = 'block'; + } + + hide() { + this.el.style.display = 'none'; + } + + destroy() { + this.host.removeChild(this.el); + } + + triggerEvent(name: string, event: MouseEvent) { + if (this.option[name]) { + this.option[name](event); + } + } + +} diff --git a/src/dragdrop/dd-resizable.ts b/src/dragdrop/dd-resizable.ts new file mode 100644 index 000000000..d13a619e3 --- /dev/null +++ b/src/dragdrop/dd-resizable.ts @@ -0,0 +1,283 @@ +// dd-resizable.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDResizableHandle } from './dd-resizable-handle'; +import { DDBaseImplement, HTMLElementExtendOpt } from './dd-base-impl'; +import { DDUtils } from './dd-utils'; +export interface DDResizableOpt { + autoHide?: boolean; + handles?: string; + maxHeight?: number; + maxWidth?: number; + minHeight?: number; + minWidth?: number; + basePosition?: 'fixed' | 'absolute'; + start?: (event: MouseEvent, ui) => void; + stop?: (event: MouseEvent, ui) => void; + resize?: (event: MouseEvent, ui) => void; +} +export class DDResizable extends DDBaseImplement implements HTMLElementExtendOpt { + static originStyleProp = ['width', 'height', 'position', 'left', 'top', 'opacity', 'zIndex']; + el: HTMLElement; + option: DDResizableOpt; + handlers: DDResizableHandle[]; + helper: HTMLElement; + originalRect; + temporalRect; + private startEvent: MouseEvent; + private elOriginStyle; + private parentOriginStylePosition; + constructor(el: HTMLElement, opts: DDResizableOpt) { + super(); + this.el = el; + this.option = opts || {}; + this.init(); + } + on(event: 'resizestart' | 'resize' | 'resizestop', callback: (event: DragEvent) => void): void { + super.on(event, callback); + } + off(event: 'resizestart' | 'resize' | 'resizestop') { + super.off(event); + } + enable() { + if (!this.disabled) { return; } + super.enable(); + this.el.classList.remove('ui-resizable-disabled'); + } + disable() { + if (this.disabled) { return; } + super.disable(); + this.el.classList.add('ui-resizable-disabled'); + } + updateOption(opts: DDResizableOpt) { + let updateHandles = false; + let updateAutoHide = false; + if (opts.handles !== this.option.handles) { + updateHandles = true; + } + if (opts.autoHide !== this.option.autoHide) { + updateAutoHide = true; + } + Object.keys(opts).forEach(key => { + const value = opts[key]; + this.option[key] = value; + }); + if (updateHandles) { + this.removeHandlers(); + this.setupHandlers(); + } + if (updateAutoHide) { + this.setupAutoHide(); + } + } + + protected init() { + this.el.classList.add('ui-resizable'); + this.setupAutoHide(); + this.setupHandlers(); + } + + protected setupAutoHide() { + if (this.option.autoHide) { + this.el.classList.add('ui-resizable-autohide'); + // use mouseover/mouseout instead of mouseenter mouseleave to get better performance; + this.el.addEventListener('mouseover', this.showHandlers); + this.el.addEventListener('mouseout', this.hideHandlers); + } else { + this.el.classList.remove('ui-resizable-autohide'); + this.el.removeEventListener('mouseover', this.showHandlers); + this.el.removeEventListener('mouseout', this.hideHandlers); + } + } + + protected showHandlers = () => { + this.el.classList.remove('ui-resizable-autohide'); + } + + protected hideHandlers = () => { + this.el.classList.add('ui-resizable-autohide'); + } + + protected setupHandlers() { + let handlerDirection = this.option.handles || 'e,s,se'; + if (handlerDirection === 'all') { + handlerDirection = 'n,e,s,w,se,sw,ne,nw'; + } + this.handlers = handlerDirection.split(',') + .map(dir => dir.trim()) + .map(dir => new DDResizableHandle(this.el, dir, { + start: (event: MouseEvent) => { + this.resizeStart(event); + }, + stop: (event: MouseEvent) => { + this.resizeStop(event); + }, + move: (event: MouseEvent) => { + this.resizing(event, dir); + } + })); + } + + protected resizeStart(event: MouseEvent) { + this.originalRect = this.el.getBoundingClientRect(); + this.startEvent = event; + this.setupHelper(); + this.applyChange(); + const ev = DDUtils.initEvent(event, { type: 'resizestart', target: this.el }); + if (this.option.start) { + this.option.start(ev, this.ui()); + } + this.el.classList.add('ui-resizable-resizing'); + this.triggerEvent('resizestart', ev); + } + + protected resizing(event: MouseEvent, dir: string) { + this.temporalRect = this.getChange(event, dir); + this.applyChange(); + const ev = DDUtils.initEvent(event, { type: 'resize', target: this.el }); + if (this.option.resize) { + this.option.resize(ev, this.ui()); + } + this.triggerEvent('resize', ev); + } + + protected resizeStop(event: MouseEvent) { + const ev = DDUtils.initEvent(event, { type: 'resizestop', target: this.el }); + if (this.option.stop) { + this.option.stop(ev, this.ui()); + } + this.el.classList.remove('ui-resizable-resizing'); + this.triggerEvent('resizestop', ev); + this.cleanHelper(); + this.startEvent = undefined; + this.originalRect = undefined; + this.temporalRect = undefined; + } + + private setupHelper() { + this.elOriginStyle = DDResizable.originStyleProp.map(prop => this.el.style[prop]); + this.parentOriginStylePosition = this.el.parentElement.style.position; + if (window.getComputedStyle(this.el.parentElement).position.match(/static/)) { + this.el.parentElement.style.position = 'relative'; + } + this.el.style.position = this.option.basePosition || 'absolute'; // or 'fixed' + this.el.style.opacity = '0.8'; + this.el.style.zIndex = '1000'; + } + private cleanHelper() { + DDResizable.originStyleProp.forEach(prop => { + this.el.style[prop] = this.elOriginStyle[prop] || null; + }); + this.el.parentElement.style.position = this.parentOriginStylePosition || null; + } + private getChange(event: MouseEvent, dir: string) { + const oEvent = this.startEvent; + const newRect = { + width: this.originalRect.width, + height: this.originalRect.height, + left: this.originalRect.left, + top: this.originalRect.top + }; + const offsetH = event.clientX - oEvent.clientX; + const offsetV = event.clientY - oEvent.clientY; + + if (dir.indexOf('e') > -1) { + newRect.width += event.clientX - oEvent.clientX; + } + if (dir.indexOf('s') > -1) { + newRect.height += event.clientY - oEvent.clientY; + } + if (dir.indexOf('w') > -1) { + newRect.width -= offsetH; + newRect.left += offsetH; + } + if (dir.indexOf('n') > -1) { + newRect.height -= offsetV; + newRect.top += offsetV + } + const reshape = this.getReShapeSize(newRect.width, newRect.height); + if (newRect.width !== reshape.width) { + if (dir.indexOf('w') > -1) { + newRect.left += reshape.width - newRect.width; + } + newRect.width = reshape.width; + } + if (newRect.height !== reshape.height) { + if (dir.indexOf('n') > -1) { + newRect.top += reshape.height - newRect.height; + } + newRect.height = reshape.height; + } + return newRect; + } + + private getReShapeSize(oWidth, oHeight) { + const maxWidth = this.option.maxWidth || oWidth; + const minWidth = this.option.minWidth || oWidth; + const maxHeight = this.option.maxHeight || oHeight; + const minHeight = this.option.minHeight || oHeight; + const width = Math.min(maxWidth, Math.max(minWidth, oWidth)); + const height = Math.min(maxHeight, Math.max(minHeight, oHeight)); + return { width, height }; + } + + private applyChange() { + let containmentRect = { left: 0, top: 0, width: 0, height: 0 }; + if (this.el.style.position === 'absolute') { + const containmentEl = this.el.parentElement; + const { left, top } = containmentEl.getBoundingClientRect(); + containmentRect = { left, top, width: 0, height: 0 }; + } + Object.keys(this.temporalRect || this.originalRect).forEach(key => { + const value = this.temporalRect[key]; + this.el.style[key] = value - containmentRect[key] + 'px'; + }); + } + + protected removeHandlers() { + this.handlers.forEach(handle => handle.destroy()); + this.handlers = undefined; + } + + destroy() { + this.removeHandlers(); + if (this.option.autoHide) { + this.el.removeEventListener('mouseover', this.showHandlers); + this.el.removeEventListener('mouseout', this.hideHandlers); + } + this.el.classList.remove('ui-resizable'); + this.el = undefined; + super.destroy(); + } + + ui = () => { + const containmentEl = this.el.parentElement; + const containmentRect = containmentEl.getBoundingClientRect(); + const rect = this.temporalRect || this.originalRect; + return { + element: [this.el], // The object representing the element to be resized + helper: [], // TODO: not support yet - The object representing the helper that's being resized + originalElement: [this.el],// we don't wrap here, so simplify as this.el //The object representing the original element before it is wrapped + originalPosition: { + left: this.originalRect.left - containmentRect.left, + top: this.originalRect.top - containmentRect.top + }, // The position represented as { left, top } before the resizable is resized + originalSize: { + width: this.originalRect.width, + height: this.originalRect.height + },// The size represented as { width, height } before the resizable is resized + position: { + left: rect.left - containmentRect.left, + top: rect.top - containmentRect.top + }, // The current position represented as { left, top } + size: { + width: rect.width, + height: rect.height + } // The current size represented as { width, height } + }; + } +} diff --git a/src/dragdrop/dd-utils.ts b/src/dragdrop/dd-utils.ts new file mode 100644 index 000000000..039674ae7 --- /dev/null +++ b/src/dragdrop/dd-utils.ts @@ -0,0 +1,91 @@ +// dd-utils.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +export class DDUtils { + static isEventSupportPassiveOption = ((() => { + let supportsPassive = false; + let passiveTest = () => { + // do nothing + }; + document.addEventListener('test', passiveTest, { + get passive() { + supportsPassive = true; + return true; + } + }); + document.removeEventListener('test', passiveTest); + return supportsPassive; + })()); + + static clone(el: HTMLElement): HTMLElement { + const node = el.cloneNode(true) as HTMLElement; + node.removeAttribute('id'); + return node; + } + + static appendTo(el: HTMLElement, parent: string | HTMLElement | Node) { + let parentNode: HTMLElement; + if (typeof parent === 'string') { + parentNode = document.querySelector(parent as string); + } else { + parentNode = parent as HTMLElement; + } + if (parentNode) { + parentNode.append(el); + } + } + static setPositionRelative(el) { + if (!(/^(?:r|a|f)/).test(window.getComputedStyle(el).position)) { + el.style.position = "relative"; + } + } + + static addElStyles(el: HTMLElement, styles: { [prop: string]: string | string[] }) { + if (styles instanceof Object) { + for (const s in styles) { + if (styles.hasOwnProperty(s)) { + if (Array.isArray(styles[s])) { + // support fallback value + (styles[s] as string[]).forEach(val => { + el.style[s] = val; + }); + } else { + el.style[s] = styles[s]; + } + } + } + } + } + static copyProps(dst, src, props) { + for (let i = 0; i < props.length; i++) { + const p = props[i]; + dst[p] = src[p]; + } + } + + static initEvent(e: DragEvent | MouseEvent, info: { type: string; target?: EventTarget }) { + const kbdProps = 'altKey,ctrlKey,metaKey,shiftKey'.split(','); + const ptProps = 'pageX,pageY,clientX,clientY,screenX,screenY'.split(','); + const evt = { type: info.type }; + const obj = { + button: 0, + which: 0, + buttons: 1, + bubbles: true, + cancelable: true, + originEvent: e, + target: info.target ? info.target : e.target + } + if (e instanceof DragEvent) { + Object.assign(obj, { dataTransfer: e.dataTransfer }); + } + DDUtils.copyProps(evt, e, kbdProps); + DDUtils.copyProps(evt, e, ptProps); + DDUtils.copyProps(evt, obj, Object.keys(obj)); + return evt as unknown as T; + } +} diff --git a/src/dragdrop/gridstack-dd-native.ts b/src/dragdrop/gridstack-dd-native.ts new file mode 100644 index 000000000..413feb020 --- /dev/null +++ b/src/dragdrop/gridstack-dd-native.ts @@ -0,0 +1,138 @@ +// gridstack-dd-native.ts 2.2.0-dev @preserve + +/** + * https://gridstackjs.com/ + * (c) 2020 rhlin, Alain Dumesny + * gridstack.js may be freely distributed under the MIT license. +*/ +import { DDManager } from './dd-manager'; +import { DDElement } from './dd-element'; + +import { GridStack, GridStackElement } from '../gridstack'; +import { GridStackDD, DDOpts, DDKey, DDDropOpt, DDCallback, DDValue } from '../gridstack-dd'; +import { GridItemHTMLElement, DDDragInOpt } from '../types'; + +/** + * HTML 5 Native DragDrop based drag'n'drop plugin. + */ +export class GridStackDDNative extends GridStackDD { + public constructor(grid: GridStack) { + super(grid); + } + + public resizable(el: GridItemHTMLElement, opts: DDOpts, key?: DDKey, value?: DDValue): GridStackDDNative { + let dEl = this.getGridStackDDElement(el); + if (opts === 'disable' || opts === 'enable') { + dEl.ddResizable[opts](); + } else if (opts === 'destroy') { + if (dEl.ddResizable) { + dEl.cleanResizable(); + } + } else if (opts === 'option') { + dEl.setupResizable({ [key]: value }); + } else { + let handles = dEl.el.getAttribute('gs-resize-handles') ? dEl.el.getAttribute('gs-resize-handles') : this.grid.opts.resizable.handles; + dEl.setupResizable({ + ...this.grid.opts.resizable, + ...{ handles: handles }, + ...{ + start: opts.start, + stop: opts.stop, + resize: opts.resize + } + }); + } + return this; + } + + public draggable(el: GridItemHTMLElement, opts: DDOpts, key?: DDKey, value?: DDValue): GridStackDDNative { + const dEl = this.getGridStackDDElement(el); + if (opts === 'disable' || opts === 'enable') { + dEl.ddDraggable && dEl.ddDraggable[opts](); + } else if (opts === 'destroy') { + if (dEl.ddDraggable) { // error to call destroy if not there + dEl.cleanDraggable(); + } + } else if (opts === 'option') { + dEl.setupDraggable({ [key]: value }); + } else { + dEl.setupDraggable({ + ...this.grid.opts.draggable, + ...{ + containment: (this.grid.opts._isNested && !this.grid.opts.dragOut) + ? this.grid.el.parentElement + : (this.grid.opts.draggable.containment || null), + start: opts.start, + stop: opts.stop, + drag: opts.drag + } + }); + } + return this; + } + + public dragIn(el: GridStackElement, opts: DDDragInOpt): GridStackDDNative { + let dEl = this.getGridStackDDElement(el); + dEl.setupDraggable(opts); + return this; + } + + public droppable(el: GridItemHTMLElement, opts: DDOpts | DDDropOpt, key?: DDKey, value?: DDValue): GridStackDDNative { + let dEl = this.getGridStackDDElement(el); + if (typeof opts.accept === 'function' && !opts._accept) { + opts._accept = opts.accept; + opts.accept = (el) => opts._accept(el); + } + if (opts === 'disable' || opts === 'enable') { + dEl.ddDroppable && dEl.ddDroppable[opts](); + } else if (opts === 'destroy') { + if (dEl.ddDroppable) { // error to call destroy if not there + dEl.cleanDroppable(); + } + } else if (opts === 'option') { + dEl.setupDroppable({ [key]: value }); + } else { + dEl.setupDroppable(opts); + } + return this; + } + + public isDroppable(el: GridItemHTMLElement): boolean { + const dEl = this.getGridStackDDElement(el); + return !!(dEl.ddDroppable); + } + + public isDraggable(el: GridStackElement): boolean { + const dEl = this.getGridStackDDElement(el); + return !!(dEl.ddDraggable); + } + + public on(el: GridItemHTMLElement, name: string, callback: DDCallback): GridStackDDNative { + let dEl = this.getGridStackDDElement(el); + dEl.on(name, (event: Event) => { + callback( + event, + DDManager.dragElement ? DDManager.dragElement.el : event.target as GridItemHTMLElement, + DDManager.dragElement ? DDManager.dragElement.helper : null) + }); + return this; + } + + public off(el: GridItemHTMLElement, name: string): GridStackDD { + let dEl = this.getGridStackDDElement(el); + dEl.off(name); + return this; + } + private getGridStackDDElement(el: GridStackElement): DDElement { + let dEl; + if (typeof el === 'string') { + dEl = document.querySelector(el as string); + } else { + dEl = el; + } + return dEl.ddElement ? dEl.ddElement: DDElement.init(dEl); + } +} + +// finally register ourself +GridStackDD.registerPlugin(GridStackDDNative); diff --git a/src/gridstack.ts b/src/gridstack.ts index d1f67de11..f884b7453 100644 --- a/src/gridstack.ts +++ b/src/gridstack.ts @@ -17,9 +17,6 @@ export * from './utils'; export * from './gridstack-engine'; export * from './gridstack-dd'; -// TEMPORARY import the jquery-ui drag&drop since we don't have alternative yet and don't expect users to create their own yet -export * from './jq/gridstack-dd-jqueryui'; - export type GridStackElement = string | HTMLElement | GridItemHTMLElement; export interface GridHTMLElement extends HTMLElement { diff --git a/src/index-h5.ts b/src/index-h5.ts new file mode 100644 index 000000000..1000242c2 --- /dev/null +++ b/src/index-h5.ts @@ -0,0 +1,13 @@ +// index.html5.ts 2.2.0-dev - everything you need for a Grid that uses HTML5 native drag&drop (work in progress) @preserve + +// import './gridstack-poly.js'; + +export * from './types'; +export * from './utils'; +export * from './gridstack-engine'; +export * from './gridstack-dd'; +export * from './gridstack'; + +export * from './dragdrop/gridstack-dd-native'; + +// declare module 'gridstack'; for umd ? diff --git a/src/index-jq.ts b/src/index-jq.ts new file mode 100644 index 000000000..83ff13e2a --- /dev/null +++ b/src/index-jq.ts @@ -0,0 +1,13 @@ +// index.jq.ts 2.2.0-dev - everything you need for a Grid that uses Jquery-ui drag&drop (original, full feature) @preserve + +// import './gridstack-poly.js'; + +export * from './types'; +export * from './utils'; +export * from './gridstack-engine'; +export * from './gridstack-dd'; +export * from './gridstack'; + +export * from './jq/gridstack-dd-jqueryui'; + +// declare module 'gridstack'; for umd ? diff --git a/src/index-static.ts b/src/index-static.ts new file mode 100644 index 000000000..322d9e1ab --- /dev/null +++ b/src/index-static.ts @@ -0,0 +1,11 @@ +// index.static.ts 2.2.0-dev - everything you need for a static Grid (non draggable) @preserve + +// import './gridstack-poly.js'; + +export * from './types'; +export * from './utils'; +export * from './gridstack-engine'; +export * from './gridstack-dd'; +export * from './gridstack'; + +// declare module 'gridstack'; for umd ? diff --git a/src/utils.ts b/src/utils.ts index a34d2d42c..4eecb6010 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -187,7 +187,7 @@ export class Utils { return Utils.closestByClass(el, name); } - /** @internal */ + /** delay calling the given function by certain amount of time */ static throttle(callback: () => void, delay: number): () => void { let isWaiting = false; diff --git a/webpack.config.js b/webpack.config.js index e59de811f..742882531 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,10 +2,9 @@ const path = require('path'); module.exports = { entry: { - 'gridstack.all': './src/gridstack.ts', - //'gridstack.all': './src/index-jq.ts', - //'gridstack.html5': './src/index-html5.ts', - //'gridstack.static': './src/index-static.ts' + 'gridstack.all': './src/index-jq.ts', + 'gridstack.h5': './src/index-h5.ts', + 'gridstack.static': './src/index-static.ts' }, mode: 'production', // production vs development devtool: 'source-map',