mirror of https://github.com/artf/grapesjs.git
nocodeframeworkdrag-and-dropsite-buildersite-generatortemplate-builderui-builderweb-builderweb-builder-frameworkwebsite-builderno-codepage-builder
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
488 lines
12 KiB
488 lines
12 KiB
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;
|
|
active?: boolean;
|
|
};
|
|
|
|
export 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;
|
|
|
|
/**
|
|
* Snapping value for the x-y axis. If you pass a value of 0, the snapping will be disabled for that axis.
|
|
* @example { snap: { x: 10, y: 5 } }
|
|
*/
|
|
snap?: { x?: number; y?: 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 = {
|
|
snap: { x: 5, y: 5 },
|
|
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;
|
|
|
|
// Handle axis locking when Shift key is pressed
|
|
const isShiftKeyPressed = 'shiftKey' in ev && ev.shiftKey;
|
|
if (isShiftKeyPressed) {
|
|
const { trgX, trgY } = this.snapGuides(delta);
|
|
|
|
// If snapped to a vertical guide, lock vertical movement (allow only horizontal movement)
|
|
if (trgX) lockedAxis = 'y';
|
|
// If snapped to a horizontal guide, lock horizontal movement (allow only vertical movement)
|
|
else if (trgY) lockedAxis = 'x';
|
|
// If no snapping occurs, detect axis lock based on movement direction
|
|
else if (!lockedAxis) lockedAxis = this.detectAxisLock(delta.x, delta.y);
|
|
|
|
// Lock the axis based on the detected axis lock
|
|
if (lockedAxis === 'x') delta.y = 0;
|
|
// Lock the axis based on the detected axis lock
|
|
else if (lockedAxis === 'y') delta.x = 0;
|
|
} else {
|
|
// Reset axis locking when Shift key is released
|
|
lockedAxis = null;
|
|
}
|
|
|
|
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 axis = isY ? 'y' : 'x';
|
|
const trgPoint = trg[axis];
|
|
const statPoint = stat[axis];
|
|
const deltaPoint = delta[axis];
|
|
const trgGuide = isY ? trgY : trgX;
|
|
|
|
if (this.isPointIn(trgPoint, statPoint, { axis })) {
|
|
if (isUndefined(trgGuide)) {
|
|
const trgValue = deltaPoint - (trgPoint - statPoint);
|
|
this.setGuideLock(trg, trgValue);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
trgX = this.trgX;
|
|
trgY = this.trgY;
|
|
|
|
xyArr.forEach((axis) => {
|
|
const axisName = axis.toUpperCase();
|
|
// @ts-ignore
|
|
let trg = this[`trg${axisName}`];
|
|
|
|
if (trg && !this.isPointIn(delta[axis], trg.lock, { axis })) {
|
|
this.setGuideLock(trg, null);
|
|
trg = null;
|
|
}
|
|
|
|
if (trg && !isUndefined(trg.lock)) {
|
|
newDelta[axis] = trg.lock;
|
|
}
|
|
});
|
|
|
|
return {
|
|
newDelta,
|
|
trgX: this.trgX,
|
|
trgY: this.trgY,
|
|
};
|
|
}
|
|
|
|
isPointIn(src: number, trg: number, { offset, axis }: { offset?: number; axis?: PositionXY } = {}) {
|
|
const { snap = {}, snapOffset = 0 } = this.opts;
|
|
const axisOffset = axis === 'x' ? snap.x : axis === 'y' ? snap.y : undefined;
|
|
|
|
// If snap.x or snap.y is explicitly 0, disable snapping for that axis
|
|
const effectiveOffset = axisOffset === 0 ? 0 : (offset ?? axisOffset ?? snapOffset);
|
|
|
|
// If effectiveOffset is 0, snapping is disabled for this axis
|
|
if (effectiveOffset === 0) return false;
|
|
|
|
return (src >= trg && src <= trg + effectiveOffset) || (src <= trg && src >= trg - effectiveOffset);
|
|
}
|
|
|
|
setGuideLock(guide: Guide, value: any) {
|
|
const axis = !isUndefined(guide.x) ? 'X' : 'Y';
|
|
const trgName = `trg${axis}`;
|
|
|
|
if (value !== null) {
|
|
guide.active = true;
|
|
guide.lock = value;
|
|
// @ts-ignore
|
|
this[trgName] = guide;
|
|
} else {
|
|
delete guide.active;
|
|
delete guide.lock;
|
|
// @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';
|
|
}
|
|
}
|
|
}
|
|
|