UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

469 lines (407 loc) 11.5 kB
import { bindAll, isFunction, isUndefined, result } from 'underscore'; import { Position } from '../common'; import { getPointerEvent, isEscKey, off, on } from './dom'; type DraggerPosition = Position & { end?: boolean }; type PositionXY = keyof Omit<DraggerPosition, 'end'>; type Guide = { x: number; y: number; lock?: number; target?: Guide; active?: boolean; }; interface DraggerOptions { /** * Element on which the drag will be executed. By default, the document will be used */ container?: HTMLElement; /** * Callback on drag start. * @example * onStart(ev, dragger) { * console.log('pointer start', dragger.startPointer, 'position start', dragger.startPosition); * } */ onStart?: (ev: Event, dragger: Dragger) => void; /** * Callback on drag. * @example * onDrag(ev, dragger) { * console.log('pointer', dragger.currentPointer, 'position', dragger.position, 'delta', dragger.delta); * } */ onDrag?: (ev: Event, dragger: Dragger) => void; /** * Callback on drag end. * @example * onEnd(ev, dragger) { * console.log('pointer', dragger.currentPointer, 'position', dragger.position, 'delta', dragger.delta); * } */ onEnd?: (ev: Event, dragger: Dragger, opts: { cancelled: boolean }) => void; /** * Indicate a callback where to pass an object with new coordinates */ setPosition?: (position: DraggerPosition) => void; /** * Indicate a callback where to get initial coordinates. * @example * getPosition: () => { * // ... * return { x: 10, y: 100 } * } */ getPosition?: () => DraggerPosition; /** * Indicate a callback where to get pointer coordinates. */ getPointerPosition?: (ev: Event) => DraggerPosition; /** * Static guides to be snapped. */ guidesStatic?: () => Guide[]; /** * Target guides that will snap to static one. */ guidesTarget?: () => Guide[]; /** * Offset before snap to guides. * @default 5 */ snapOffset?: number; /** * Document on which listen to pointer events. */ doc?: Document; /** * Scale result points, can also be a function. * @default 1 */ scale?: number | (() => number); } const resetPos = () => ({ x: 0, y: 0 }); const xyArr: PositionXY[] = ['x', 'y']; export default class Dragger { opts: DraggerOptions; startPointer: DraggerPosition; delta: DraggerPosition; lastScroll: DraggerPosition; lastScrollDiff: DraggerPosition; startPosition: DraggerPosition; globScrollDiff: DraggerPosition; currentPointer: DraggerPosition; position: DraggerPosition; el?: HTMLElement; guidesStatic: Guide[]; guidesTarget: Guide[]; lockedAxis?: any; docs: Document[]; trgX?: Guide; trgY?: Guide; /** * Init the dragger * @param {Object} opts */ constructor(opts: DraggerOptions = {}) { this.opts = { snapOffset: 5, scale: 1, }; bindAll(this, 'drag', 'stop', 'keyHandle', 'handleScroll'); this.setOptions(opts); this.delta = resetPos(); this.lastScroll = resetPos(); this.lastScrollDiff = resetPos(); this.startPointer = resetPos(); this.startPosition = resetPos(); this.globScrollDiff = resetPos(); this.currentPointer = resetPos(); this.position = resetPos(); this.guidesStatic = []; this.guidesTarget = []; this.docs = []; return this; } /** * Update options * @param {Object} options */ setOptions(opts: Partial<DraggerOptions> = {}) { this.opts = { ...this.opts, ...opts, }; } toggleDrag(enable?: boolean) { const docs = this.getDocumentEl(); const container = this.getContainerEl(); const win = this.getWindowEl(); const method = enable ? 'on' : 'off'; const methods = { on, off }; methods[method](container, 'mousemove dragover', this.drag); methods[method](docs, 'mouseup dragend touchend', this.stop); methods[method](docs, 'keydown', this.keyHandle); methods[method](win, 'scroll', this.handleScroll); } handleScroll() { const { lastScroll, delta } = this; const actualScroll = this.getScrollInfo(); const scrollDiff = { x: actualScroll.x - lastScroll!.x, y: actualScroll.y - lastScroll!.y, }; this.move(delta.x + scrollDiff.x, delta.y + scrollDiff.y); this.lastScrollDiff = scrollDiff; } /** * Start dragging * @param {Event} e */ start(ev: Event) { const { opts } = this; const { onStart } = opts; this.toggleDrag(true); this.startPointer = this.getPointerPos(ev); this.guidesStatic = result(opts, 'guidesStatic') || []; this.guidesTarget = result(opts, 'guidesTarget') || []; isFunction(onStart) && onStart(ev, this); this.startPosition = this.getStartPosition(); this.lastScrollDiff = resetPos(); this.globScrollDiff = resetPos(); this.drag(ev); } /** * Drag event * @param {Event} event */ drag(ev: Event) { const { opts, lastScrollDiff, globScrollDiff } = this; const { onDrag } = opts; const { startPointer } = this; const currentPos = this.getPointerPos(ev); const glDiff = { x: globScrollDiff.x + lastScrollDiff.x, y: globScrollDiff.y + lastScrollDiff.y, }; this.globScrollDiff = glDiff; const delta = { x: currentPos.x - startPointer.x + glDiff.x, y: currentPos.y - startPointer.y + glDiff.y, }; this.lastScrollDiff = resetPos(); let { lockedAxis } = this; // @ts-ignore Lock one axis if (ev.shiftKey) { lockedAxis = !lockedAxis && this.detectAxisLock(delta.x, delta.y); } else { lockedAxis = null; } if (lockedAxis === 'x') { delta.x = startPointer.x; } else if (lockedAxis === 'y') { delta.y = startPointer.y; } const moveDelta = (delta: DraggerPosition) => { xyArr.forEach(co => (delta[co] = delta[co] * result(opts, 'scale'))); this.delta = delta; this.move(delta.x, delta.y); isFunction(onDrag) && onDrag(ev, this); }; const deltaPre = { ...delta }; this.currentPointer = currentPos; this.lockedAxis = lockedAxis; this.lastScroll = this.getScrollInfo(); moveDelta(delta); if (this.guidesTarget.length) { const { newDelta, trgX, trgY } = this.snapGuides(deltaPre); (trgX || trgY) && moveDelta(newDelta); } // @ts-ignore In case the mouse button was released outside of the window ev.which === 0 && this.stop(ev); } /** * Check if the delta hits some guide */ snapGuides(delta: DraggerPosition) { const newDelta = delta; let { trgX, trgY } = this; this.guidesTarget.forEach(trg => { // Skip the guide if its locked axis already exists if ((trg.x && this.trgX) || (trg.y && this.trgY)) return; trg.active = false; this.guidesStatic.forEach(stat => { if ((trg.y && stat.x) || (trg.x && stat.y)) return; const isY = trg.y && stat.y; const axs = isY ? 'y' : 'x'; const trgPoint = trg[axs]; const statPoint = stat[axs]; const deltaPoint = delta[axs]; const trgGuide = isY ? trgY : trgX; if (this.isPointIn(trgPoint, statPoint)) { if (isUndefined(trgGuide)) { const trgValue = deltaPoint - (trgPoint - statPoint); this.setGuideLock(trg, trgValue, stat); } } }); }); trgX = this.trgX; trgY = this.trgY; xyArr.forEach(co => { const axis = co.toUpperCase(); // @ts-ignore let trg = this[`trg${axis}`]; if (trg && !this.isPointIn(delta[co], trg.lock)) { this.setGuideLock(trg, null, null); trg = null; } if (trg && !isUndefined(trg.lock)) { newDelta[co] = trg.lock; } }); return { newDelta, trgX: this.trgX, trgY: this.trgY, }; } isPointIn(src: number, trg: number, { offset }: { offset?: number } = {}) { const ofst = offset || this.opts.snapOffset || 0; return (src >= trg && src <= trg + ofst) || (src <= trg && src >= trg - ofst); } setGuideLock(guide: Guide, value: any, target: Guide|null) { const axis = !isUndefined(guide.x) ? 'X' : 'Y'; const trgName = `trg${axis}`; if (value !== null) { guide.active = true; guide.lock = value; guide.target = target ?? undefined; // @ts-ignore this[trgName] = guide; } else { delete guide.active; delete guide.lock; delete guide.target; // @ts-ignore delete this[trgName]; } return guide; } /** * Stop dragging */ stop(ev: Event, opts: { cancel?: boolean } = {}) { const { delta } = this; const cancelled = !!opts.cancel; const x = cancelled ? 0 : delta.x; const y = cancelled ? 0 : delta.y; this.toggleDrag(); this.lockedAxis = null; this.move(x, y, true); const { onEnd } = this.opts; isFunction(onEnd) && onEnd(ev, this, { cancelled }); } keyHandle(ev: Event) { if (isEscKey(ev as KeyboardEvent)) { this.stop(ev, { cancel: true }); } } /** * Move the element * @param {integer} x * @param {integer} y */ move(x: number, y: number, end?: boolean) { const { el, opts } = this; const pos = this.startPosition; if (!pos) return; const { setPosition } = opts; const xPos = pos.x + x; const yPos = pos.y + y; this.position = { x: xPos, y: yPos, end, }; isFunction(setPosition) && setPosition(this.position); if (el) { el.style.left = `${xPos}px`; el.style.top = `${yPos}px`; } } getContainerEl() { const { container } = this.opts; return container ? [container] : this.getDocumentEl(); } getWindowEl() { const cont = this.getContainerEl(); return cont.map(item => { const doc = item.ownerDocument || item; // @ts-ignore return doc.defaultView || doc.parentWindow; }); } /** * Returns documents */ getDocumentEl(el?: HTMLElement): Document[] { const { doc } = this.opts; el = el || this.el; if (!this.docs.length) { const docs = [document]; el && docs.push(el.ownerDocument); doc && docs.push(doc); this.docs = docs; } return this.docs; } /** * Get mouse coordinates * @param {Event} event * @return {Object} */ getPointerPos(ev: Event) { const getPos = this.opts.getPointerPosition; const pEv = getPointerEvent(ev); return getPos ? getPos(ev) : { x: pEv.clientX, y: pEv.clientY, }; } getStartPosition() { const { el, opts } = this; const getPos = opts.getPosition; let result = resetPos(); if (isFunction(getPos)) { result = getPos(); } else if (el) { result = { x: parseFloat(el.style.left), y: parseFloat(el.style.top), }; } return result; } getScrollInfo() { const { doc } = this.opts; const body = doc && doc.body; return { y: body ? body.scrollTop : 0, x: body ? body.scrollLeft : 0, }; } detectAxisLock(x: number, y: number) { const relX = x; const relY = y; const absX = Math.abs(relX); const absY = Math.abs(relY); // Vertical or Horizontal lock if (relY >= absX || relY <= -absX) { return 'x'; } else if (relX > absY || relX < -absY) { return 'y'; } } }