'use strict';
window.GaiaContainer = (function(exports) {
/**
* The time, in ms, to wait for an animation to start in response to a state
* change, before removing the associated style class.
*/
const STATE_CHANGE_TIMEOUT = 100;
/**
* The time, in ms, to wait before initiating a drag-and-drop from a
* long-press.
*/
const DEFAULT_DND_TIMEOUT = 300;
/**
* The distance, in CSS pixels, in which a touch or mouse point can deviate
* before it being discarded for initiation of a drag-and-drop action.
*/
const DND_THRESHOLD = 5;
/**
* The minimum time between sending move events during drag-and-drop.
*/
const DND_MOVE_THROTTLE = 50;
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
this._template = template.content.cloneNode(true);
var shadow = this.createShadowRoot();
shadow.appendChild(this._template);
this._frozen = false;
this._pendingStateChanges = [];
this._children = [];
this._dnd = {
// Whether drag-and-drop is enabled
enabled: false,
// The time, in ms, to wait before initiating a drag-and-drop from a
// long-press
delay: DEFAULT_DND_TIMEOUT,
// Timeout used to initiate drag-and-drop actions
timeout: null,
// The child that was tapped/clicked
child: null,
// Whether a drag is active
active: false,
// The start point of the drag action
start: { pageX: 0, pageY: 0, clientX: 0, clientY: 0 },
// The last point of the drag action
last: { pageX: 0, pageY: 0, clientX: 0, clientY: 0, timeStamp: 0 },
// Timeout used to send drag-move events
moveTimeout: null,
// The last time a move event was fired
lastMoveEventTime: 0,
// Whether to capture the next click event
clickCapture: false
};
var dndObserverCallback = (mutations) => {
if (this._dnd.enabled !== this.dragAndDrop) {
this._dnd.enabled = this.dragAndDrop;
if (this._dnd.enabled) {
this.addEventListener('touchstart', this);
this.addEventListener('touchmove', this);
this.addEventListener('touchcancel', this);
this.addEventListener('touchend', this);
this.addEventListener('mousedown', this);
this.addEventListener('mousemove', this);
this.addEventListener('mouseup', this);
this.addEventListener('click', this, true);
this.addEventListener('contextmenu', this, true);
} else {
this.cancelDrag();
this.removeEventListener('touchstart', this);
this.removeEventListener('touchmove', this);
this.removeEventListener('touchcancel', this);
this.removeEventListener('touchend', this);
this.removeEventListener('mousedown', this);
this.removeEventListener('mousemove', this);
this.removeEventListener('mouseup', this);
this.removeEventListener('click', this, true);
this.removeEventListener('contextmenu', this, true);
}
}
};
this.dndObserver = new MutationObserver(dndObserverCallback);
this.dndObserver.observe(this,
{ attributes: true, attributeFilter: ['drag-and-drop'] });
dndObserverCallback(null);
};
Object.defineProperty(proto, 'children', {
get: function() {
return this._children.map((child) => {
return child.element;
});
},
enumerable: true
});
Object.defineProperty(proto, 'firstChild', {
get: function() {
return this._children.length ? this._children[0].element : null;
},
enumerable: true
});
Object.defineProperty(proto, 'lastChild', {
get: function() {
var length = this._children.length;
return length ? this._children[length - 1].element : null;
},
enumerable: true
});
Object.defineProperty(proto, 'dragAndDrop', {
get: function() {
return this.getAttribute('drag-and-drop') !== null;
},
enumerable: true
});
Object.defineProperty(proto, 'dragAndDropTimeout', {
get: function() {
return this._dnd.delay;
},
set: function(timeout) {
if (timeout >= 0) {
this._dnd.delay = timeout;
} else {
this._dnd.delay = DEFAULT_DND_TIMEOUT;
}
},
enumerable: true
});
proto.realAppendChild = proto.appendChild;
proto.appendChild = function(element, callback) {
this.insertBefore(element, null, callback);
};
proto.realRemoveChild = proto.removeChild;
proto.removeChild = function(element, callback) {
var children = this._children;
var childToRemove = null;
for (var child of children) {
if (child.element === element) {
childToRemove = child;
break;
}
}
if (childToRemove === null) {
throw 'removeChild called on unknown child';
}
this.changeState(childToRemove, 'removed', () => {
this.realRemoveChild(childToRemove.container);
this.realRemoveChild(childToRemove.master);
// Find the child again. We need to do this in case the container was
// manipulated between removing the child and this callback being reached.
for (var i = 0, iLen = children.length; i < iLen; i++) {
if (children[i] === childToRemove) {
children.splice(i, 1);
break;
}
}
if (callback) {
callback();
}
this.synchronise();
});
};
proto.realReplaceChild = proto.replaceChild;
proto.replaceChild = function(newElement, oldElement, callback) {
if (!newElement || !oldElement) {
throw 'replaceChild called with null arguments';
}
// Unparent the newElement if necessary (with support for gaia-container)
if (newElement.parentNode) {
newElement.parentNode.removeChild(newElement, () => {
this.replaceChild(newElement, oldElement, callback);
});
if (newElement.parentNode) {
return;
}
}
// Remove the old child and add the new one, but don't run the removed/
// added state changes.
var children = this._children;
for (var i = 0, iLen = children.length; i < iLen; i++) {
var oldChild = children[i];
if (oldChild.element === oldElement) {
var newChild = new GaiaContainerChild(newElement);
this.realInsertBefore(newChild.container, oldChild.container);
this.realInsertBefore(newChild.master, oldChild.master);
this.realRemoveChild(oldChild.container);
this.realRemoveChild(oldChild.master);
this.children.splice(i, 1, newChild);
this.synchronise();
if (callback) {
callback();
}
return;
}
}
throw 'removeChild called on unknown child';
};
/**
* Reorders the given element to appear before referenceElement.
*/
proto.reorderChild = function(element, referenceElement, callback) {
if (!element) {
throw 'reorderChild called with null element';
}
var children = this._children;
var child = null;
var childIndex = null;
var referenceChild = null;
var referenceChildIndex = null;
for (var i = 0, iLen = children.length; i < iLen; i++) {
if (children[i].element === element) {
child = children[i];
childIndex = i;
} else if (children[i].element === referenceElement) {
referenceChild = children[i];
referenceChildIndex = i;
}
if (child && (referenceChild || !referenceElement)) {
this.realRemoveChild(child.container);
this.realRemoveChild(child.master);
children.splice(childIndex, 1);
if (referenceChild) {
this.realInsertBefore(child.container, referenceChild.container);
this.realInsertBefore(child.master, referenceChild.master);
} else {
(children.length === 0) ?
this.realAppendChild(child.container) :
this.realInsertBefore(child.container, children[0].master);
this.realAppendChild(child.master);
}
referenceChild ?
children.splice(
referenceChildIndex - (childIndex < referenceChildIndex) ? 1 : 0,
0, child) :
children.splice(children.length, 0, child);
this.synchronise();
if (callback) {
callback();
}
return;
}
}
throw child ? 'reorderChild called on unknown reference element' :
'reorderChild called on unknown child';
};
proto.realInsertBefore = proto.insertBefore;
proto.insertBefore = function(element, reference, callback) {
var children = this._children;
var childToInsert = new GaiaContainerChild(element);
var referenceIndex = -1;
if (reference !== null) {
for (var i = 0, iLen = children.length; i < iLen; i++) {
if (children[i].element === reference) {
referenceIndex = i;
break;
}
}
if (referenceIndex === -1) {
throw 'insertBefore called on unknown child';
}
}
if (referenceIndex === -1) {
(children.length === 0) ?
this.realAppendChild(childToInsert.container) :
this.realInsertBefore(childToInsert.container, children[0].master);
this.realAppendChild(childToInsert.master);
children.push(childToInsert);
} else {
this.realInsertBefore(childToInsert.container,
children[referenceIndex].container);
this.realInsertBefore(childToInsert.master,
children[referenceIndex].master);
children.splice(referenceIndex, 0, childToInsert);
}
this.changeState(childToInsert, 'added', callback);
this.synchronise();
};
/**
* Used to execute a state-change of a child that may possibly be animated.
* @state will be added to the child's class-list. If an animation starts that
* has the same name, that animation will complete and @callback will be
* called. Otherwise, the class will be removed and @callback called on the
* next frame.
*/
proto.changeState = function(child, state, callback) {
// Check that the child is still attached to this parent (can happen if
// the child is removed while frozen).
if (child.container.parentNode !== this) {
return;
}
// Check for a redundant state change.
if (child.container.classList.contains(state)) {
return;
}
// Queue up state change if we're frozen.
if (this._frozen) {
this._pendingStateChanges.push(
this.changeState.bind(this, child, state, callback));
return;
}
var animStart = (e) => {
if (!e.animationName.endsWith(state)) {
return;
}
child.container.removeEventListener('animationstart', animStart);
window.clearTimeout(child[state]);
delete child[state];
var self = this;
child.container.addEventListener('animationend', function animEnd() {
child.container.removeEventListener('animationend', animEnd);
child.container.classList.remove(state);
if (callback) {
callback();
}
});
};
child.container.addEventListener('animationstart', animStart);
child.container.classList.add(state);
child[state] = window.setTimeout(() => {
delete child[state];
child.container.removeEventListener('animationstart', animStart);
child.container.classList.remove(state);
if (callback) {
callback();
}
}, STATE_CHANGE_TIMEOUT);
};
proto.getChildOffsetRect = function(element) {
var children = this._children;
for (var i = 0, iLen = children.length; i < iLen; i++) {
var child = children[i];
if (child.element === element) {
var top = child._lastMasterTop;
var left = child._lastMasterLeft;
var width = child._lastElementWidth;
var height = child._lastElementHeight;
return {
top: top,
left: left,
width: width,
height: height,
right: left + width,
bottom: top + height
};
}
}
throw 'getChildOffsetRect called on unknown child';
};
proto.getChildFromPoint = function(x, y) {
var children = this._children;
for (var parent = this.parentElement; parent;
parent = parent.parentElement) {
x += parent.scrollLeft - parent.offsetLeft;
y += parent.scrollTop - parent.offsetTop;
}
for (var i = 0, iLen = children.length; i < iLen; i++) {
var child = children[i];
if (x >= child._lastMasterLeft &&
y >= child._lastMasterTop &&
x < child._lastMasterLeft + child._lastElementWidth &&
y < child._lastMasterTop + child._lastElementHeight) {
return child.element;
}
}
return null;
};
proto.cancelDrag = function() {
if (this._dnd.timeout !== null) {
clearTimeout(this._dnd.timeout);
this._dnd.timeout = null;
}
if (this._dnd.moveTimeout !== null) {
clearTimeout(this._dnd.moveTimeout);
this._dnd.moveTimeout = null;
}
if (this._dnd.active) {
this._dnd.child.container.classList.remove('dragging');
this._dnd.child.container.style.position = 'absolute';
this._dnd.child.container.style.top = '0';
this._dnd.child.container.style.left = '0';
this._dnd.child.markDirty();
this._dnd.child = null;
this._dnd.active = false;
this.synchronise();
this._dnd.clickCapture = true;
this.dispatchEvent(new CustomEvent('drag-finish'));
}
};
proto.startDrag = function() {
if (!this.dispatchEvent(new CustomEvent('drag-start',
{ cancelable: true,
detail: { target: this._dnd.child.element,
pageX: this._dnd.start.pageX,
pageY: this._dnd.start.pageY,
clientX: this._dnd.start.clientX,
clientY: this._dnd.start.clientY } }))) {
return;
}
this._dnd.active = true;
this._dnd.child.container.classList.add('dragging');
this._dnd.child.container.style.position = 'fixed';
var rect = this.getBoundingClientRect();
this._dnd.child.container.style.top = rect.top + 'px';
this._dnd.child.container.style.left = rect.left + 'px';
};
proto.continueDrag = function() {
if (!this._dnd.active) {
return;
}
var left = this._dnd.child.master.offsetLeft +
(this._dnd.last.pageX - this._dnd.start.pageX);
var top = this._dnd.child.master.offsetTop +
(this._dnd.last.pageY - this._dnd.start.pageY);
this._dnd.child.container.style.transform =
'translate(' + left + 'px, ' + top + 'px)';
if (this._dnd.moveTimeout === null) {
var delay = Math.max(0, DND_MOVE_THROTTLE -
(this._dnd.last.timeStamp - this._dnd.lastMoveEventTime));
this._dnd.moveTimeout = setTimeout(() => {
this._dnd.moveTimeout = null;
this._dnd.lastMoveEventTime = this._dnd.last.timeStamp;
this.dispatchEvent(new CustomEvent('drag-move',
{ detail: { target: this._dnd.child.element,
pageX: this._dnd.last.pageX,
pageY: this._dnd.last.pageY,
clientX: this._dnd.last.clientX,
clientY: this._dnd.last.clientY } }));
}, delay);
}
};
proto.endDrag = function(event) {
if (this._dnd.active) {
var dropTarget = this.getChildFromPoint(this._dnd.last.clientX,
this._dnd.last.clientY);
if (this.dispatchEvent(new CustomEvent('drag-end',
{ cancelable: true,
detail: { target: this._dnd.child.element,
dropTarget: dropTarget,
pageX: this._dnd.last.pageX,
pageY: this._dnd.last.pageY,
clientX: this._dnd.last.clientX,
clientY: this._dnd.last.clientY }}))) {
var children = this._children;
if (dropTarget && dropTarget !== this._dnd.child.element) {
var dropChild = null;
var dropIndex = -1;
var childIndex = -1;
var insertBefore = true;
for (var i = 0, iLen = children.length; i < iLen; i++) {
if (children[i] === this._dnd.child) {
childIndex = i;
if (!dropChild) {
insertBefore = false;
}
}
if (children[i].element === dropTarget) {
dropChild = children[i];
dropIndex = i;
}
if (dropIndex >= 0 && childIndex >= 0) {
break;
}
}
if (dropIndex >= 0 && childIndex >= 0) {
// Default action, rearrange the dragged child to before or after
// the child underneath the touch/mouse point.
this.realRemoveChild(this._dnd.child.container);
this.realRemoveChild(this._dnd.child.master);
this.realInsertBefore(this._dnd.child.container,
insertBefore ? dropChild.container :
dropChild.container.nextSibling);
this.realInsertBefore(this._dnd.child.master,
insertBefore ? dropChild.master :
dropChild.master.nextSibling);
children.splice(dropIndex, 0,
children.splice(childIndex, 1)[0]);
this.dispatchEvent(new CustomEvent('drag-rearrange'));
}
}
}
} else if (this._dnd.timeout !== null) {
var handled = !this.dispatchEvent(new CustomEvent('activate',
{ cancelable : true, detail: { target: this._dnd.child.element } }));
if (handled) {
event.stopImmediatePropagation();
event.preventDefault();
}
}
this.cancelDrag();
};
proto.handleEvent = function(event) {
switch (event.type) {
case 'touchstart':
case 'mousedown':
if (this._dnd.active || this._dnd.timeout) {
this.cancelDrag();
break;
}
if (event instanceof MouseEvent) {
this._dnd.start.pageX = event.pageX;
this._dnd.start.pageY = event.pageY;
this._dnd.start.clientX = event.pageX;
this._dnd.start.clientY = event.pageY;
} else {
this._dnd.start.pageX = event.touches[0].pageX;
this._dnd.start.pageY = event.touches[0].pageY;
this._dnd.start.clientX = event.touches[0].clientX;
this._dnd.start.clientY = event.touches[0].clientY;
}
this._dnd.last.pageX = this._dnd.start.pageX;
this._dnd.last.pageY = this._dnd.start.pageY;
this._dnd.last.clientX = this._dnd.start.clientX;
this._dnd.last.clientY = this._dnd.start.clientY;
this._dnd.last.timeStamp = event.timeStamp;
var target = event.target;
for (; target.parentNode !== this; target = target.parentNode) {
if (target === this || !target.parentNode) {
return;
}
}
// Find the child
var children = this._children;
for (var child of children) {
if (child.container === target) {
this._dnd.child = child;
break;
}
}
if (!this._dnd.child) {
return;
}
if (this._dnd.delay > 0) {
this._dnd.timeout = setTimeout(() => {
this._dnd.timeout = null;
this.startDrag();
}, this._dnd.delay);
} else {
this.startDrag();
}
break;
case 'touchmove':
case 'mousemove':
var pageX, pageY, clientX, clientY;
if (event instanceof MouseEvent) {
pageX = event.pageX;
pageY = event.pageY;
clientX = event.clientX;
clientY = event.clientY;
} else {
pageX = event.touches[0].pageX;
pageY = event.touches[0].pageY;
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
}
if (this._dnd.timeout) {
if (Math.abs(pageX - this._dnd.start.pageX) > DND_THRESHOLD ||
Math.abs(pageY - this._dnd.start.pageY) > DND_THRESHOLD) {
clearTimeout(this._dnd.timeout);
this._dnd.timeout = null;
}
} else if (this._dnd.active) {
event.preventDefault();
this._dnd.last.pageX = pageX;
this._dnd.last.pageY = pageY;
this._dnd.last.clientX = clientX;
this._dnd.last.clientY = clientY;
this._dnd.last.timeStamp = event.timeStamp;
this.continueDrag();
}
break;
case 'touchcancel':
this.cancelDrag();
break;
case 'touchend':
case 'mouseup':
if (this._dnd.active) {
event.preventDefault();
event.stopImmediatePropagation();
}
this.endDrag(event);
break;
case 'click':
if (this._dnd.clickCapture) {
this._dnd.clickCapture = false;
event.preventDefault();
event.stopImmediatePropagation();
}
break;
case 'contextmenu':
if (this._dnd.active || this._dnd.timeout) {
event.stopImmediatePropagation();
event.preventDefault();
}
break;
}
};
/**
* Temporarily disables element position synchronisation. Useful when adding
* multiple elements to the container at the same time, or in quick
* succession.
*/
proto.freeze = function() {
this._frozen = true;
};
/**
* Enables element position synchronisation after freeze() has been called.
*/
proto.thaw = function() {
if (this._frozen) {
this._frozen = false;
for (var callback of this._pendingStateChanges) {
callback();
}
this._pendingStateChanges = [];
this.synchronise();
}
};
/**
* Synchronise positions between the managed container and all children.
* This is called automatically when adding/inserting or removing children,
* but must be called manually if the managed container is manipulated
* outside of these methods (for example, if style properties change, or
* if it's resized).
*/
proto.synchronise = function() {
if (this._frozen) {
return;
}
var child;
for (child of this._children) {
if (!this._dnd.active || child !== this._dnd.child) {
child.synchroniseMaster();
}
}
for (child of this._children) {
if (!this._dnd.active || child !== this._dnd.child) {
child.synchroniseContainer();
}
}
};
var template = document.createElement('template');
template.innerHTML =
`` +
``;
function GaiaContainerChild(element) {
this._element = element;
this.markDirty();
}
GaiaContainerChild.prototype = {
get element() {
return this._element;
},
/**
* The element that will contain the child element and control its position.
*/
get container() {
if (!this._container) {
// Create container
var container = document.createElement('div');
container.classList.add('gaia-container-child');
container.style.position = 'absolute';
container.style.top = '0';
container.style.left = '0';
container.appendChild(this.element);
this._container = container;
}
return this._container;
},
/**
* The element that will be added to the container that will
* control the element's transform.
*/
get master() {
if (!this._master) {
// Create master
var master = document.createElement('div');
master.style.visibility = 'hidden';
this._master = master;
}
return this._master;
},
/**
* Clears any cached style properties. To be used if elements are
* manipulated outside of the methods of this object.
*/
markDirty() {
this._lastElementWidth = null;
this._lastElementHeight = null;
this._lastElementDisplay = null;
this._lastElementOrder = null;
this._lastMasterTop = null;
this._lastMasterLeft = null;
},
/**
* Synchronise the size of the master with the managed child element.
*/
synchroniseMaster() {
var master = this.master;
var element = this.element;
var style = window.getComputedStyle(element);
var display = style.display;
var order = style.order;
var width = element.offsetWidth;
var height = element.offsetHeight;
if (this._lastElementWidth !== width ||
this._lastElementHeight !== height ||
this._lastElementDisplay !== display ||
this._lastElementOrder !== order) {
this._lastElementWidth = width;
this._lastElementHeight = height;
this._lastElementDisplay = display;
this._lastElementOrder = order;
master.style.width = width + 'px';
master.style.height = height + 'px';
master.style.display = display;
master.style.order = order;
}
},
/**
* Synchronise the container's transform with the position of the master.
*/
synchroniseContainer() {
var master = this.master;
var container = this.container;
var top = master.offsetTop;
var left = master.offsetLeft;
if (this._lastMasterTop !== top ||
this._lastMasterLeft !== left) {
this._lastMasterTop = top;
this._lastMasterLeft = left;
container.style.transform = 'translate(' + left + 'px, ' + top + 'px)';
}
}
};
exports.GaiaContainerChild = GaiaContainerChild;
return document.registerElement('gaia-container', { prototype: proto });
})(window);