@print-one/grapesjs
Version:
Free and Open Source Web Builder Framework
1,338 lines (1,182 loc) • 39.1 kB
text/typescript
import { bindAll, each, isArray, isFunction, isString, result } from 'underscore';
import { BlockProperties } from '../block_manager/model/Block';
import CanvasModule from '../canvas';
import { CanvasSpotBuiltInTypes } from '../canvas/model/CanvasSpot';
import { $, Collection, Model, View } from '../common';
import EditorModel from '../editor/model/Editor';
import { getPointerEvent, isTextNode, off, on } from './dom';
import { getElement, getModel, matches } from './mixins';
type DropContent = BlockProperties['content'];
interface Dim {
top: number;
left: number;
height: number;
width: number;
offsets: ReturnType<CanvasModule['getElementOffsets']>;
dir?: boolean;
el?: HTMLElement;
indexEl?: number;
}
interface Pos {
index: number;
indexEl: number;
method: string;
}
export interface SorterOptions {
borderOffset?: number;
container?: HTMLElement;
containerSel?: string;
itemSel?: string;
draggable?: boolean | string[];
nested?: boolean;
pfx?: string;
ppfx?: string;
freezeClass?: string;
onStart?: Function;
onEndMove?: Function;
customTarget?: Function;
onEnd?: Function;
onMove?: Function;
direction?: 'v' | 'h' | 'a';
relative?: boolean;
ignoreViewChildren?: boolean;
placer?: HTMLElement;
document?: Document;
wmargin?: number;
offsetTop?: number;
offsetLeft?: number;
em?: EditorModel;
canvasRelative?: boolean;
avoidSelectOnEnd?: boolean;
scale?: number;
}
const noop = () => {};
const targetSpotType = CanvasSpotBuiltInTypes.Target;
const spotTarget = {
id: 'sorter-target',
type: targetSpotType,
};
export default class Sorter extends View {
opt!: SorterOptions;
elT!: number;
elL!: number;
borderOffset!: number;
containerSel!: string;
itemSel!: string;
draggable!: SorterOptions['draggable'];
nested!: boolean;
pfx!: string;
ppfx?: string;
freezeClass?: string;
onStart!: Function;
onEndMove?: Function;
customTarget?: Function;
onEnd?: Function;
onMoveClb?: Function;
direction!: 'v' | 'h' | 'a';
relative!: boolean;
ignoreViewChildren!: boolean;
plh?: HTMLElement;
document!: Document;
wmargin!: number;
offTop!: number;
offLeft!: number;
dropContent?: DropContent;
em?: EditorModel;
dragHelper?: HTMLElement;
canvasRelative!: boolean;
selectOnEnd!: boolean;
scale?: number;
activeTextModel?: Model;
dropModel?: Model;
target?: HTMLElement;
prevTarget?: HTMLElement;
sourceEl?: HTMLElement;
moved?: boolean;
srcModel?: Model;
targetModel?: Model;
rX?: number;
rY?: number;
eventMove?: MouseEvent;
prevTargetDim?: Dim;
cacheDimsP?: Dim[];
cacheDims?: Dim[];
targetP?: HTMLElement;
targetPrev?: HTMLElement;
lastPos?: Pos;
lastDims?: Dim[];
$plh?: any;
toMove?: Model | Model[];
/** @ts-ignore */
initialize(opt: SorterOptions = {}) {
this.opt = opt || {};
bindAll(this, 'startSort', 'onMove', 'endMove', 'rollback', 'updateOffset', 'moveDragHelper');
var o = opt || {};
this.elT = 0;
this.elL = 0;
this.borderOffset = o.borderOffset || 10;
var el = o.container;
this.el = typeof el === 'string' ? document.querySelector(el)! : el!;
this.$el = $(this.el); // TODO check if necessary
this.containerSel = o.containerSel || 'div';
this.itemSel = o.itemSel || 'div';
this.draggable = o.draggable || true;
this.nested = !!o.nested;
this.pfx = o.pfx || '';
this.ppfx = o.ppfx || '';
this.freezeClass = o.freezeClass || this.pfx + 'freezed';
this.onStart = o.onStart || noop;
this.onEndMove = o.onEndMove;
this.customTarget = o.customTarget;
this.onEnd = o.onEnd;
this.direction = o.direction || 'v'; // v (vertical), h (horizontal), a (auto)
this.onMoveClb = o.onMove;
this.relative = o.relative || false;
this.ignoreViewChildren = !!o.ignoreViewChildren;
this.plh = o.placer;
// Frame offset
this.wmargin = o.wmargin || 0;
this.offTop = o.offsetTop || 0;
this.offLeft = o.offsetLeft || 0;
this.document = o.document || document;
this.em = o.em;
this.canvasRelative = !!o.canvasRelative;
this.selectOnEnd = !o.avoidSelectOnEnd;
this.scale = o.scale;
if (this.em && this.em.on) {
this.em.on('change:canvasOffset', this.updateOffset);
this.updateOffset();
}
}
getScale() {
return result(this, 'scale') || 1;
}
getContainerEl(elem?: HTMLElement) {
if (elem) this.el = elem;
if (!this.el) {
var el = this.opt.container;
this.el = typeof el === 'string' ? document.querySelector(el)! : el!;
this.$el = $(this.el); // TODO check if necessary
}
return this.el;
}
getDocuments(el?: HTMLElement) {
const em = this.em;
const elDoc = el ? el.ownerDocument : em?.Canvas.getBody().ownerDocument;
const docs = [document];
elDoc && docs.push(elDoc);
return docs;
}
/**
* Triggered when the offset of the editro is changed
*/
updateOffset() {
const offset = this.em?.get('canvasOffset') || {};
this.offTop = offset.top;
this.offLeft = offset.left;
}
/**
* Set content to drop
* @param {String|Object} content
*/
setDropContent(content: DropContent) {
delete this.dropModel;
this.dropContent = content;
}
updateTextViewCursorPosition(e: any) {
const { em } = this;
if (!em) return;
const Canvas = em.Canvas;
const targetDoc = Canvas.getDocument();
let range = null;
if (targetDoc.caretRangeFromPoint) {
// Chrome
const poiner = getPointerEvent(e);
range = targetDoc.caretRangeFromPoint(poiner.clientX, poiner.clientY);
} else if (e.rangeParent) {
// Firefox
range = targetDoc.createRange();
range.setStart(e.rangeParent, e.rangeOffset);
}
const sel = Canvas.getWindow().getSelection();
Canvas.getFrameEl().focus();
sel?.removeAllRanges();
range && sel?.addRange(range);
this.setContentEditable(this.activeTextModel, true);
}
setContentEditable(model?: Model, mode?: boolean) {
if (model) {
// @ts-ignore
const el = model.getEl();
if (el.contentEditable != mode) el.contentEditable = mode;
}
}
/**
* Toggle cursor while sorting
* @param {Boolean} active
*/
toggleSortCursor(active?: boolean) {
const { em } = this;
const cv = em?.Canvas;
// Avoid updating body className as it causes a huge repaint
// Noticeable with "fast" drag of blocks
cv && (active ? cv.startAutoscroll() : cv.stopAutoscroll());
}
/**
* Set drag helper
* @param {HTMLElement} el
* @param {Event} event
*/
setDragHelper(el: HTMLElement, event: Event) {
const ev = event || '';
const clonedEl = el.cloneNode(true) as HTMLElement;
const rect = el.getBoundingClientRect();
const computed = getComputedStyle(el);
let style = '';
for (var i = 0; i < computed.length; i++) {
const prop = computed[i];
style += `${prop}:${computed.getPropertyValue(prop)};`;
}
document.body.appendChild(clonedEl);
clonedEl.className += ` ${this.pfx}bdrag`;
clonedEl.setAttribute('style', style);
this.dragHelper = clonedEl;
clonedEl.style.width = `${rect.width}px`;
clonedEl.style.height = `${rect.height}px`;
ev && this.moveDragHelper(ev);
// Listen mouse move events
if (this.em) {
const $doc = $(this.em.Canvas.getBody().ownerDocument);
$doc.off('mousemove', this.moveDragHelper).on('mousemove', this.moveDragHelper);
}
$(document).off('mousemove', this.moveDragHelper).on('mousemove', this.moveDragHelper);
}
/**
* Update the position of the helper
* @param {Event} e
*/
moveDragHelper(e: any) {
const doc = (e.target as HTMLElement).ownerDocument;
if (!this.dragHelper || !doc) {
return;
}
let posY = e.pageY;
let posX = e.pageX;
let addTop = 0;
let addLeft = 0;
// @ts-ignore
const window = doc.defaultView || (doc.parentWindow as Window);
const frame = window.frameElement;
const dragHelperStyle = this.dragHelper.style;
// If frame is present that means mouse has moved over the editor's canvas,
// which is rendered inside the iframe and the mouse move event comes from
// the iframe, not the parent window. Mouse position relative to the frame's
// parent window needs to account for the frame's position relative to the
// parent window.
if (frame) {
const frameRect = frame.getBoundingClientRect();
addTop = frameRect.top + document.documentElement.scrollTop;
addLeft = frameRect.left + document.documentElement.scrollLeft;
posY = e.clientY;
posX = e.clientX;
}
dragHelperStyle.top = posY + addTop + 'px';
dragHelperStyle.left = posX + addLeft + 'px';
}
/**
* Returns true if the element matches with selector
* @param {Element} el
* @param {String} selector
* @return {Boolean}
*/
matches(el: HTMLElement, selector: string) {
return matches.call(el, selector);
}
/**
* Closest parent
* @param {Element} el
* @param {String} selector
* @return {Element|null}
*/
closest(el: HTMLElement, selector: string): HTMLElement | undefined {
if (!el) return;
let elem = el.parentNode;
while (elem && elem.nodeType === 1) {
if (this.matches(elem as HTMLElement, selector)) return elem as HTMLElement;
elem = elem.parentNode;
}
}
/**
* Get the offset of the element
* @param {HTMLElement} el
* @return {Object}
*/
offset(el: HTMLElement) {
const rect = el.getBoundingClientRect();
return {
top: rect.top + document.body.scrollTop,
left: rect.left + document.body.scrollLeft,
};
}
/**
* Create placeholder
* @return {HTMLElement}
*/
createPlaceholder() {
const { pfx } = this;
const el = document.createElement('div');
const ins = document.createElement('div');
el.className = pfx + 'placeholder';
el.style.display = 'none';
el.style.pointerEvents = 'none';
ins.className = pfx + 'placeholder-int';
el.appendChild(ins);
return el;
}
/**
* Picking component to move
* @param {HTMLElement} src
* */
startSort(src?: HTMLElement, opts: { container?: HTMLElement } = {}) {
const { em, itemSel, containerSel, plh } = this;
const container = this.getContainerEl(opts.container);
const docs = this.getDocuments(src);
let srcModel;
delete this.dropModel;
delete this.target;
delete this.prevTarget;
this.moved = false;
// Check if the start element is a valid one, if not, try the closest valid one
if (src && !this.matches(src, `${itemSel}, ${containerSel}`)) {
src = this.closest(src, itemSel)!;
}
this.sourceEl = src;
// Create placeholder if doesn't exist yet
if (!plh) {
this.plh = this.createPlaceholder();
container.appendChild(this.plh);
}
if (src) {
srcModel = this.getSourceModel(src);
srcModel?.set && srcModel.set('status', 'freezed');
this.srcModel = srcModel;
}
on(container, 'mousemove dragover', this.onMove as any);
on(docs, 'mouseup dragend touchend', this.endMove);
on(docs, 'keydown', this.rollback);
this.onStart({
sorter: this,
target: srcModel,
// @ts-ignore
parent: srcModel && srcModel.parent?.(),
// @ts-ignore
index: srcModel && srcModel.index?.(),
});
// Avoid strange effects on dragging
em?.clearSelection();
this.toggleSortCursor(true);
em?.trigger('sorter:drag:start', src, srcModel);
}
/**
* Get the model from HTMLElement target
* @return {Model|null}
*/
getTargetModel(el: HTMLElement) {
const elem = el || this.target;
return $(elem).data('model');
}
/**
* Get the model of the current source element (element to drag)
* @return {Model}
*/
getSourceModel(source?: HTMLElement, { target, avoidChildren = 1 }: any = {}): Model {
const { em, sourceEl } = this;
const src = source || sourceEl;
let { dropModel, dropContent } = this;
const isTextable = (src: any) =>
src && target && src.opt && src.opt.avoidChildren && this.isTextableActive(src, target);
if (dropContent && em) {
if (isTextable(dropModel)) {
dropModel = undefined;
}
if (!dropModel) {
const comps = em.Components.getComponents();
const opts = {
avoidChildren,
avoidStore: 1,
avoidUpdateStyle: 1,
};
const tempModel = comps.add(dropContent, { ...opts, temporary: true });
// @ts-ignore
dropModel = comps.remove(tempModel, opts as any);
dropModel = dropModel instanceof Array ? dropModel[0] : dropModel;
this.dropModel = dropModel;
if (isTextable(dropModel)) {
return this.getSourceModel(src, { target, avoidChildren: 0 });
}
}
return dropModel!;
}
return src && $(src).data('model');
}
/**
* Highlight target
* @param {Model|null} model
*/
selectTargetModel(model?: Model, source?: Model) {
if (model instanceof Collection) {
return;
}
// Prevents loops in Firefox
// https://github.com/GrapesJS/grapesjs/issues/2911
if (source && source === model) return;
const { targetModel } = this;
// Reset the previous model but not if it's the same as the source
// https://github.com/GrapesJS/grapesjs/issues/2478#issuecomment-570314736
if (targetModel && targetModel !== this.srcModel) {
targetModel.set('status', '');
}
if (model?.set) {
const cv = this.em!.Canvas;
const { Select, Hover, Spacing } = CanvasSpotBuiltInTypes;
[Select, Hover, Spacing].forEach(type => cv.removeSpots({ type }));
cv.addSpot({ ...spotTarget, component: model as any });
model.set('status', 'selected-parent');
this.targetModel = model;
}
}
/**
* During move
* @param {Event} e
* */
onMove(e: MouseEvent) {
const ev = e;
const { em, onMoveClb, plh, customTarget } = this;
this.moved = true;
// Turn placeholder visibile
const dsp = plh!.style.display;
if (!dsp || dsp === 'none') plh!.style.display = 'block';
// Cache all necessary positions
var eO = this.offset(this.el);
this.elT = this.wmargin ? Math.abs(eO.top) : eO.top;
this.elL = this.wmargin ? Math.abs(eO.left) : eO.left;
var rY = e.pageY - this.elT + this.el.scrollTop;
var rX = e.pageX - this.elL + this.el.scrollLeft;
if (this.canvasRelative && em) {
const mousePos = em.Canvas.getMouseRelativeCanvas(e, { noScroll: 1 });
rX = mousePos.x;
rY = mousePos.y;
}
this.rX = rX;
this.rY = rY;
this.eventMove = e;
//var targetNew = this.getTargetFromEl(e.target);
const sourceModel = this.getSourceModel();
const targetEl = customTarget ? customTarget({ sorter: this, event: e }) : e.target;
const dims = this.dimsFromTarget(targetEl as HTMLElement, rX, rY);
const target = this.target;
const targetModel = target && this.getTargetModel(target);
this.selectTargetModel(targetModel, sourceModel);
if (!targetModel) plh!.style.display = 'none';
if (!target) return;
this.lastDims = dims;
const pos = this.findPosition(dims, rX, rY);
if (this.isTextableActive(sourceModel, targetModel)) {
this.activeTextModel = targetModel;
plh!.style.display = 'none';
this.lastPos = pos;
this.updateTextViewCursorPosition(ev);
} else {
this.disableTextable();
delete this.activeTextModel;
// If there is a significant changes with the pointer
if (!this.lastPos || this.lastPos.index != pos.index || this.lastPos.method != pos.method) {
this.movePlaceholder(this.plh!, dims, pos, this.prevTargetDim);
if (!this.$plh) this.$plh = $(this.plh!);
// With canvasRelative the offset is calculated automatically for
// each element
if (!this.canvasRelative) {
if (this.offTop) this.$plh.css('top', '+=' + this.offTop + 'px');
if (this.offLeft) this.$plh.css('left', '+=' + this.offLeft + 'px');
}
this.lastPos = pos;
}
}
isFunction(onMoveClb) &&
onMoveClb({
event: e,
target: sourceModel,
parent: targetModel,
index: pos.index + (pos.method == 'after' ? 1 : 0),
});
em &&
em.trigger('sorter:drag', {
target,
targetModel,
sourceModel,
dims,
pos,
x: rX,
y: rY,
});
}
isTextableActive(src: any, trg: any) {
return src?.get?.('textable') && trg?.isInstanceOf('text');
}
disableTextable() {
const { activeTextModel } = this;
// @ts-ignore
activeTextModel?.getView().disableEditing();
this.setContentEditable(activeTextModel, false);
}
/**
* Returns true if the elements is in flow, so is not in flow where
* for example the component is with float:left
* @param {HTMLElement} el
* @param {HTMLElement} parent
* @return {Boolean}
* @private
* */
isInFlow(el: HTMLElement, parent?: HTMLElement) {
if (!el) return false;
parent = parent || document.body;
var ch = -1,
h;
var elem = el;
h = elem.offsetHeight;
if (/*h < ch || */ !this.styleInFlow(elem, parent)) return false;
else return true;
}
/**
* Check if el has style to be in flow
* @param {HTMLElement} el
* @param {HTMLElement} parent
* @return {Boolean}
* @private
*/
styleInFlow(el: HTMLElement, parent: HTMLElement) {
if (isTextNode(el)) return;
const style = el.style || {};
const $el = $(el);
const $parent = parent && $(parent);
if (style.overflow && style.overflow !== 'visible') return;
const propFloat = $el.css('float');
if (propFloat && propFloat !== 'none') return;
if ($parent && $parent.css('display') == 'flex' && $parent.css('flex-direction') !== 'column') return;
switch (style.position) {
case 'static':
case 'relative':
case '':
break;
default:
return;
}
switch (el.tagName) {
case 'TR':
case 'TBODY':
case 'THEAD':
case 'TFOOT':
return true;
}
switch ($el.css('display')) {
case 'block':
case 'list-item':
case 'table':
case 'flex':
case 'grid':
return true;
}
return;
}
/**
* Check if the target is valid with the actual source
* @param {HTMLElement} trg
* @return {Boolean}
*/
validTarget(trg: HTMLElement, src?: HTMLElement) {
const trgModel = this.getTargetModel(trg);
const srcModel = this.getSourceModel(src, { target: trgModel });
// @ts-ignore
src = srcModel && srcModel.view && srcModel.view.el;
trg = trgModel && trgModel.view && trgModel.view.el;
let result = {
valid: true,
src,
srcModel,
trg,
trgModel,
draggable: false,
droppable: false,
dragInfo: '',
dropInfo: '',
};
if (!src || !trg) {
result.valid = false;
return result;
}
// Check if the source is draggable in target
let draggable = srcModel.get('draggable');
if (isFunction(draggable)) {
const res = draggable(srcModel, trgModel);
result.dragInfo = res;
result.draggable = res;
draggable = res;
} else {
draggable = draggable instanceof Array ? draggable.join(', ') : draggable;
result.dragInfo = draggable;
draggable = isString(draggable) ? this.matches(trg, draggable) : draggable;
result.draggable = draggable;
}
// Check if the target could accept the source
let droppable = trgModel.get('droppable');
if (isFunction(droppable)) {
const res = droppable(srcModel, trgModel);
result.droppable = res;
result.dropInfo = res;
droppable = res;
} else {
droppable = droppable instanceof Collection ? 1 : droppable;
droppable = droppable instanceof Array ? droppable.join(', ') : droppable;
result.dropInfo = droppable;
droppable = isString(droppable) ? this.matches(src, droppable) : droppable;
droppable = draggable && this.isTextableActive(srcModel, trgModel) ? 1 : droppable;
result.droppable = droppable;
}
if (!droppable || !draggable) {
result.valid = false;
}
return result;
}
/**
* Get dimensions of nodes relative to the coordinates
* @param {HTMLElement} target
* @param {number} rX Relative X position
* @param {number} rY Relative Y position
* @return {Array<Array>}
*/
dimsFromTarget(target: HTMLElement, rX = 0, rY = 0): Dim[] {
const em = this.em;
let dims: Dim[] = [];
if (!target) {
return dims;
}
// Select the first valuable target
if (!this.matches(target, `${this.itemSel}, ${this.containerSel}`)) {
target = this.closest(target, this.itemSel)!;
}
// If draggable is an array the target will be one of those
if (this.draggable instanceof Array) {
target = this.closest(target, this.draggable.join(','))!;
}
if (!target) {
return dims;
}
// Check if the target is different from the previous one
if (this.prevTarget && this.prevTarget != target) {
delete this.prevTarget;
}
// New target found
if (!this.prevTarget) {
this.targetP = this.closest(target, this.containerSel);
// Check if the source is valid with the target
let validResult = this.validTarget(target);
em && em.trigger('sorter:drag:validation', validResult);
if (!validResult.valid && this.targetP) {
return this.dimsFromTarget(this.targetP, rX, rY);
}
this.prevTarget = target;
this.prevTargetDim = this.getDim(target);
this.cacheDimsP = this.getChildrenDim(this.targetP!);
this.cacheDims = this.getChildrenDim(target);
}
// If the target is the previous one will return the cached dims
if (this.prevTarget == target) dims = this.cacheDims!;
// Target when I will drop element to sort
this.target = this.prevTarget;
// Generally, on any new target the poiner enters inside its area and
// triggers nearBorders(), so have to take care of this
if (this.nearBorders(this.prevTargetDim!, rX, rY) || (!this.nested && !this.cacheDims!.length)) {
const targetParent = this.targetP;
if (targetParent && this.validTarget(targetParent).valid) {
dims = this.cacheDimsP!;
this.target = targetParent;
}
}
delete this.lastPos;
return dims;
}
/**
* Get valid target from element
* This method should replace dimsFromTarget()
* @param {HTMLElement} el
* @return {HTMLElement}
*/
getTargetFromEl(el: HTMLElement): HTMLElement {
let target = el;
let targetParent;
let targetPrev = this.targetPrev;
const em = this.em;
const containerSel = this.containerSel;
const itemSel = this.itemSel;
// Select the first valuable target
if (!this.matches(target, `${itemSel}, ${containerSel}`)) {
target = this.closest(target, itemSel)!;
}
// If draggable is an array the target will be one of those
// TODO check if this options is used somewhere
if (this.draggable instanceof Array) {
target = this.closest(target, this.draggable.join(','))!;
}
// Check if the target is different from the previous one
if (targetPrev && targetPrev != target) {
delete this.targetPrev;
}
// New target found
if (!this.targetPrev) {
targetParent = this.closest(target, containerSel);
// If the current target is not valid (src/trg reasons) try with
// the parent one (if exists)
const validResult = this.validTarget(target);
em && em.trigger('sorter:drag:validation', validResult);
if (!validResult.valid && targetParent) {
return this.getTargetFromEl(targetParent);
}
this.targetPrev = target;
}
// Generally, on any new target the poiner enters inside its area and
// triggers nearBorders(), so have to take care of this
if (this.nearElBorders(target)) {
targetParent = this.closest(target, containerSel);
if (targetParent && this.validTarget(targetParent).valid) {
target = targetParent;
}
}
return target;
}
/**
* Check if the current pointer is neare to element borders
* @return {Boolen}
*/
nearElBorders(el: HTMLElement) {
const off = 10;
const rect = el.getBoundingClientRect();
const body = el.ownerDocument.body;
const { x, y } = this.getCurrentPos();
const top = rect.top + body.scrollTop;
const left = rect.left + body.scrollLeft;
const width = rect.width;
const height = rect.height;
if (
y < top + off || // near top edge
y > top + height - off || // near bottom edge
x < left + off || // near left edge
x > left + width - off // near right edge
) {
return 1;
}
}
getCurrentPos() {
const ev = this.eventMove;
const x = ev?.pageX || 0;
const y = ev?.pageY || 0;
return { x, y };
}
/**
* Returns dimensions and positions about the element
* @param {HTMLElement} el
* @return {Array<number>}
*/
getDim(el: HTMLElement): Dim {
const { em, canvasRelative } = this;
const canvas = em?.Canvas;
const offsets = canvas ? canvas.getElementOffsets(el) : {};
let top, left, height, width;
if (canvasRelative && em) {
const pos = canvas!.getElementPos(el, { noScroll: 1 })!;
top = pos.top; // - offsets.marginTop;
left = pos.left; // - offsets.marginLeft;
height = pos.height; // + offsets.marginTop + offsets.marginBottom;
width = pos.width; // + offsets.marginLeft + offsets.marginRight;
} else {
var o = this.offset(el);
top = this.relative ? el.offsetTop : o.top - (this.wmargin ? -1 : 1) * this.elT;
left = this.relative ? el.offsetLeft : o.left - (this.wmargin ? -1 : 1) * this.elL;
height = el.offsetHeight;
width = el.offsetWidth;
}
return { top, left, height, width, offsets };
}
/**
* Get children dimensions
* @param {HTMLELement} el Element root
* @return {Array}
* */
getChildrenDim(trg: HTMLElement) {
const dims: Dim[] = [];
if (!trg) return dims;
// Get children based on getChildrenContainer
const trgModel = this.getTargetModel(trg);
if (trgModel && trgModel.view && !this.ignoreViewChildren) {
const view = trgModel.getCurrentView ? trgModel.getCurrentView() : trgModel.view;
trg = view.getChildrenContainer();
}
each(trg.children, (ele, i) => {
const el = ele as HTMLElement;
const model = getModel(el, $);
const elIndex = model && model.index ? model.index() : i;
if (!isTextNode(el) && !this.matches(el, this.itemSel)) {
return;
}
const dim = this.getDim(el);
let dir = this.direction;
let dirValue: boolean;
if (dir == 'v') dirValue = true;
else if (dir == 'h') dirValue = false;
else dirValue = this.isInFlow(el, trg);
dim.dir = dirValue;
dim.el = el;
dim.indexEl = elIndex;
dims.push(dim);
});
return dims;
}
/**
* Check if the coordinates are near to the borders
* @param {Array<number>} dim
* @param {number} rX Relative X position
* @param {number} rY Relative Y position
* @return {Boolean}
* */
nearBorders(dim: Dim, rX: number, rY: number) {
let result = false;
const off = this.borderOffset;
const x = rX || 0;
const y = rY || 0;
const t = dim.top;
const l = dim.left;
const h = dim.height;
const w = dim.width;
if (t + off > y || y > t + h - off || l + off > x || x > l + w - off) result = true;
return result;
}
/**
* Find the position based on passed dimensions and coordinates
* @param {Array<Array>} dims Dimensions of nodes to parse
* @param {number} posX X coordindate
* @param {number} posY Y coordindate
* @return {Object}
* */
findPosition(dims: Dim[], posX: number, posY: number): Pos {
const result: Pos = { index: 0, indexEl: 0, method: 'before' };
let leftLimit = 0;
let xLimit = 0;
let dimRight = 0;
let yLimit = 0;
let xCenter = 0;
let yCenter = 0;
let dimDown = 0;
let dim: Dim;
// Each dim is: Top, Left, Height, Width
for (var i = 0, len = dims.length; i < len; i++) {
dim = dims[i];
const { top, left, height, width } = dim;
// Right position of the element. Left + Width
dimRight = left + width;
// Bottom position of the element. Top + Height
dimDown = top + height;
// X center position of the element. Left + (Width / 2)
xCenter = left + width / 2;
// Y center position of the element. Top + (Height / 2)
yCenter = top + height / 2;
// Skip if over the limits
if (
(xLimit && left > xLimit) ||
(yLimit && yCenter >= yLimit) || // >= avoid issue with clearfixes
(leftLimit && dimRight < leftLimit)
)
continue;
result.index = i;
result.indexEl = dim.indexEl!;
// If it's not in flow (like 'float' element)
if (!dim.dir) {
if (posY < dimDown) yLimit = dimDown;
//If x lefter than center
if (posX < xCenter) {
xLimit = xCenter;
result.method = 'before';
} else {
leftLimit = xCenter;
result.method = 'after';
}
} else {
// If y upper than center
if (posY < yCenter) {
result.method = 'before';
break;
} else result.method = 'after'; // After last element
}
}
return result;
}
/**
* Updates the position of the placeholder
* @param {HTMLElement} phl
* @param {Array<Array>} dims
* @param {Object} pos Position object
* @param {Array<number>} trgDim target dimensions ([top, left, height, width])
* */
movePlaceholder(plh: HTMLElement, dims: Dim[], pos: Pos, trgDim?: Dim) {
let marg = 0;
let t = 0;
let l = 0;
let w = '';
let h = '';
let un = 'px';
let margI = 5;
let method = pos.method;
const elDim = dims[pos.index];
// Placeholder orientation
plh.classList.remove('vertical');
plh.classList.add('horizontal');
if (elDim) {
// If it's not in flow (like 'float' element)
const { top, left, height, width } = elDim;
if (!elDim.dir) {
w = 'auto';
h = height - marg * 2 + un;
t = top + marg;
l = method == 'before' ? left - marg : left + width - marg;
plh.classList.remove('horizontal');
plh.classList.add('vertical');
} else {
w = width + un;
h = 'auto';
t = method == 'before' ? top - marg : top + height - marg;
l = left;
}
} else {
// Placeholder inside the component
if (!this.nested) {
plh.style.display = 'none';
return;
}
if (trgDim) {
const offset = trgDim.offsets || {};
const pT = offset.paddingTop || margI;
const pL = offset.paddingLeft || margI;
t = trgDim.top + pT;
l = trgDim.left + pL;
w = parseInt(`${trgDim.width}`) - pL * 2 + un;
h = 'auto';
}
}
plh.style.top = t + un;
plh.style.left = l + un;
if (w) plh.style.width = w;
if (h) plh.style.height = h;
}
/**
* Build an array of all the parents, including the component itself
* @return {Model|null}
*/
parents(model: any): any[] {
return model ? [model].concat(this.parents(model.parent())) : [];
}
/**
* Sort according to the position in the dom
* @param {Object} obj1 contains {model, parents}
* @param {Object} obj2 contains {model, parents}
*/
sort(obj1: any, obj2: any) {
// common ancesters
const ancesters = obj1.parents.filter((p: any) => obj2.parents.includes(p));
const ancester = ancesters[0];
if (!ancester) {
// this is never supposed to happen
return obj2.model.index() - obj1.model.index();
}
// find siblings in the common ancester
// the sibling is the element inside the ancester
const s1 = obj1.parents[obj1.parents.indexOf(ancester) - 1];
const s2 = obj2.parents[obj2.parents.indexOf(ancester) - 1];
// order according to the position in the DOM
return s2.index() - s1.index();
}
/**
* Leave item
* @param event
*
* @return void
* */
endMove() {
const src = this.sourceEl;
const moved = [];
const docs = this.getDocuments();
const container = this.getContainerEl();
const onEndMove = this.onEndMove;
const onEnd = this.onEnd;
const { target, lastPos } = this;
let srcModel;
off(container, 'mousemove dragover', this.onMove as any);
off(docs, 'mouseup dragend touchend', this.endMove);
off(docs, 'keydown', this.rollback);
this.plh!.style.display = 'none';
if (src) {
srcModel = this.getSourceModel();
if (this.selectOnEnd && srcModel && srcModel.set) {
srcModel.set('status', '');
srcModel.set('status', 'selected');
}
}
if (this.moved && target) {
const toMove = this.toMove;
const toMoveArr = isArray(toMove) ? toMove : toMove ? [toMove] : [src];
let domPositionOffset = 0;
if (toMoveArr.length === 1) {
// do not sort the array in this case
// there are cases for the sorter where toMoveArr is [undefined]
// which allows the drop from blocks, native D&D and sort of layers in Style Manager
moved.push(this.move(target, toMoveArr[0]!, lastPos!));
} else {
toMoveArr
// add the model's parents
.map(model => ({
model,
parents: this.parents(model),
}))
// sort based on elements positions in the dom
.sort(this.sort)
// move each component to the new parent and position
.forEach(({ model }) => {
// @ts-ignore store state before move
const index = model.index();
// @ts-ignore
const parent = model.parent().getEl();
// move the component to the desired position
moved.push(
this.move(target, model!, {
...lastPos!,
indexEl: lastPos!.indexEl - domPositionOffset,
index: lastPos!.index - domPositionOffset,
})
);
// when the element is dragged to the same parent and after its position
// it will be removed from the children list
// in that case we need to adjust the following elements target position
if (parent === target && index <= lastPos!.index) {
// the next elements will be inserted 1 element before this one
domPositionOffset++;
}
});
}
}
if (this.plh) this.plh.style.display = 'none';
const dragHelper = this.dragHelper;
if (dragHelper) {
dragHelper.parentNode!.removeChild(dragHelper);
delete this.dragHelper;
}
this.disableTextable();
this.selectTargetModel();
this.toggleSortCursor();
this.em?.Canvas.removeSpots(spotTarget);
delete this.toMove;
delete this.eventMove;
delete this.dropModel;
if (isFunction(onEndMove)) {
const data = {
target: srcModel,
// @ts-ignore
parent: srcModel && srcModel.parent(),
// @ts-ignore
index: srcModel && srcModel.index(),
};
moved.length ? moved.forEach(m => onEndMove(m, this, data)) : onEndMove(null, this, { ...data, cancelled: 1 });
}
isFunction(onEnd) && onEnd({ sorter: this });
}
/**
* Move component to new position
* @param {HTMLElement} dst Destination target
* @param {HTMLElement} src Element to move
* @param {Object} pos Object with position coordinates
* */
move(dst: HTMLElement, src: HTMLElement | Model, pos: Pos) {
const { em, dropContent } = this;
const srcEl = getElement(src as HTMLElement);
const warns = [];
const index = pos.method === 'after' ? pos.indexEl + 1 : pos.indexEl;
const validResult = this.validTarget(dst, srcEl);
const targetCollection = $(dst).data('collection');
const { trgModel, srcModel, draggable } = validResult;
const droppable = trgModel instanceof Collection ? 1 : validResult.droppable;
let modelToDrop, created;
if (targetCollection && droppable && draggable) {
const opts: any = { at: index, action: 'move-component' };
const isTextable = this.isTextableActive(srcModel, trgModel);
if (!dropContent) {
const srcIndex = srcModel.collection.indexOf(srcModel);
const sameCollection = targetCollection === srcModel.collection;
const sameIndex = srcIndex === index || srcIndex === index - 1;
const canRemove = !sameCollection || !sameIndex || isTextable;
if (canRemove) {
modelToDrop = srcModel.collection.remove(srcModel, {
temporary: true,
} as any);
if (sameCollection && index > srcIndex) {
opts.at = index - 1;
}
}
} else {
// @ts-ignore
modelToDrop = isFunction(dropContent) ? dropContent() : dropContent;
opts.avoidUpdateStyle = true;
opts.action = 'add-component';
}
if (modelToDrop) {
if (isTextable) {
delete opts.at;
created = trgModel.getView().insertComponent(modelToDrop, opts);
} else {
created = targetCollection.add(modelToDrop, opts);
}
}
delete this.dropContent;
delete this.prevTarget; // This will recalculate children dimensions
} else if (em) {
const dropInfo = validResult.dropInfo || trgModel?.get('droppable');
const dragInfo = validResult.dragInfo || srcModel?.get('draggable');
!targetCollection && warns.push('Target collection not found');
!droppable && dropInfo && warns.push(`Target is not droppable, accepts [${dropInfo}]`);
!draggable && dragInfo && warns.push(`Component not draggable, acceptable by [${dragInfo}]`);
em.logWarning('Invalid target position', {
errors: warns,
model: srcModel,
context: 'sorter',
target: trgModel,
});
}
em?.trigger('sorter:drag:end', {
targetCollection,
modelToDrop,
warns,
validResult,
dst,
srcEl,
});
return created;
}
/**
* Rollback to previous situation
* @param {Event}
* @param {Bool} Indicates if rollback in anycase
* */
rollback(e: any) {
off(this.getDocuments(), 'keydown', this.rollback);
const key = e.which || e.keyCode;
if (key == 27) {
this.moved = false;
this.endMove();
}
}
}