UNPKG

nestablejs

Version:

NestableJS is a javascript library for creating drag & drop heirarchical lists.

1,063 lines (817 loc) 36.5 kB
import DOM from "./utils/DOM.js"; import Emitter from "./utils/Emitter.js"; export default class Nestable extends Emitter { constructor(list, options) { super(); this.defaultConfig = { threshold: 40, animation: 0, collapseButtonContent: "–", expandButtonContent: "+", includeContent: false, maxDepth: 3, showPlaceholderOnMove: false, nodes: { list: "ol", item: "li" }, classes: { list: "nst-list", item: "nst-item", content: "nst-content", parent: "nst-parent", dragging: "nst-dragging", handle: "nst-handle", placeholder: "nst-placeholder", container: "nst-container", button: "nst-button", collapsed: "nst-collapsed", disabled: "nst-disabled", error: "nst-error", moving: "nst-moving", }, }; this.config = Object.assign({}, this.defaultConfig, options); if (options) { if (options.nodes) { this.config.nodes = Object.assign({}, this.defaultConfig.nodes, options.nodes); } if (options.classes) { this.config.classes = Object.assign({}, this.defaultConfig.classes, options.classes); } } this.parent = typeof list === "string" ? DOM.select(list) : list; if (!this.parent) { return console.error(`Node (${list}) not found.`); } if (this.parent._nestable) { return console.error("There is already a Nestable instance active on this node."); } this.initialised = false; this.disabled = true; this.last = { x: 0, y: 0 }; this.init(); } init(options) { if (!this.initialised) { this.touch = "ontouchstart" in window || (window.DocumentTouch && document instanceof DocumentTouch); if (options) { this.config = Object.assign({}, this.defaultConfig, options); } this.dragDepth = 0; this.parent.classList.add(this.config.classes.list); this.parent.classList.add(this.config.classes.parent); const items = DOM.children(this.parent, this.config.nodes.item); for (const item of items) { this._nest(item); } this.placeholder = document.createElement(this.config.nodes.item); this.placeholder.classList.add(this.config.classes.placeholder); this._getData(); this.parent._nestable = this; if (!window._nestableInstances) { window._nestableInstances = 1; this.id = 1; } else { window._nestableInstances += 1; this.id = window._nestableInstances } this.enable(); this._getData(); setTimeout(() => { this.emit("init"); }, 10); this.initialised = true; if (this.config.data) { const req = new XMLHttpRequest(); req.responseType = 'json'; req.open('GET', this.config.data, true); req.onload = () => { this.load(req); }; req.send(null); } } } load(data) { this.removeAll(); if ("response" in data) { data = data.response; } const nest = (item) => { const el = document.createElement(this.config.nodes.item); el.textContent = item.content; if (item.children) { const list = document.createElement(this.config.nodes.list); el.appendChild(list); for (const child of item.children) { list.appendChild(nest(child)); } } return el; }; for (const item of data) { this._nest(this.parent.appendChild(nest(item))) } this.emit("loaded"); } destroy() { if (this.initialised) { this.initialised = false; this.disable(); this.parent.classList.remove(this.config.classes.list); this.parent.classList.remove(this.config.classes.parent); delete(this.parent._nestable); if (window._nestableInstances) { window._nestableInstances -= 1; } const destroyItem = (item) => { item.classList.remove(this.config.classes.item); item.classList.remove(this.config.classes.collapsed); const listEl = item.querySelector(this.config.nodes.list); const contentEl = item.querySelector(`.${this.config.classes.content}`); const handleEl = item.querySelector(`.${this.config.classes.handle}`); const buttonEl = item.querySelector(`.${this.config.classes.button}`); // default handle is also the content container const defaultHandle = contentEl.classList.contains(this.config.classes.handle); const div = document.createDocumentFragment(); for (var i = contentEl.childNodes.length - 1; i >= 0; i--) { div.insertBefore(contentEl.childNodes[i], div.firstChild); } item.insertBefore(div, contentEl) item.removeChild(contentEl); if (listEl) { listEl.classList.remove(this.config.classes.list); item.removeChild(buttonEl); const items = DOM.children(listEl, this.config.nodes.item); for (const item of items) { destroyItem(item); } } }; const items = DOM.children(this.parent, this.config.nodes.item); for (const item of items) { destroyItem(item); } this.emit("destroy", this.parent); } } bind() { this.events = { start: this._onMouseDown.bind(this), move: this._onMouseMove.bind(this), end: this._onMouseUp.bind(this), }; if (this.touch) { this.parent.addEventListener("touchstart", this.events.start, false); document.addEventListener("touchmove", this.events.move, false); document.addEventListener("touchend", this.events.end, false); document.addEventListener("touchcancel", this.events.end, false); } else { this.parent.addEventListener("mousedown", this.events.start, false); document.addEventListener("mousemove", this.events.move, false); document.addEventListener("mouseup", this.events.end, false); } } unbind() { this.parent.removeEventListener("mousedown", this.events.start); document.removeEventListener("mousemove", this.events.move); document.removeEventListener("mouseup", this.events.end); } enable() { if (this.disabled) { this.bind(); this.parent.classList.remove(this.config.classes.disabled); this.disabled = false; } } disable() { if (!this.disabled) { this.unbind(); this.parent.classList.add(this.config.classes.disabled); this.disabled = true; } } serialise() { this.serialize(); } serialize() { return this._getData("data"); } collapseAll() { const items = DOM.selectAll(`.${this.config.classes.item}`, this.parent); for (const item of items) { if (!item.classList.contains(this.config.classes.collapsed)) { const btn = item.querySelector(`.${this.config.classes.button}`); if (btn) { this._collapseList(item, btn); } } } } expandAll() { const items = DOM.selectAll(`.${this.config.classes.item}`, this.parent); for (const item of items) { if (item.classList.contains(this.config.classes.collapsed)) { const btn = item.querySelector(`.${this.config.classes.button}`); if (btn) { this._expandList(item, btn); } } } } add(element, parent) { if (!parent) { parent = this.parent; } this._nest(element); if (parent !== this.parent) { const listEl = DOM.select(this.config.nodes.list, parent); if (!listEl) { parent = this._makeParent(parent); } else { parent = listEl; } } parent.appendChild(element); this.update(); } remove(element, removeChildElements = true) { const parentEl = element.closest(this.config.nodes.list); if (!removeChildElements) { const childList = element.querySelector(`.${this.config.classes.list}`); if (childList) { const childElements = DOM.children(childList, this.config.nodes.item); if (childElements.length) { const frag = document.createDocumentFragment(); for (var i = childElements.length - 1; i >= 0; i--) { const childElement = childElements[i]; frag.insertBefore(childElement, frag.firstElementChild); } parentEl.replaceChild(frag, element); } } } else { parentEl.removeChild(element); } this.update(); } removeAll() { const nodes = this.parent.children; for (var i = nodes.length - 1; i >= 0; i--) { this.parent.removeChild(nodes[i]); } } update() { this._getData("nodes"); this.emit("update"); } _nest(el) { const handle = el.querySelector(`.${this.config.classes.handle}`); const content = document.createElement("div"); content.classList.add(this.config.classes.content); const nodes = el.childNodes; if (!handle) { content.classList.add(this.config.classes.handle); for (var i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; if (node.nodeName.toLowerCase() !== this.config.nodes.list) { content.insertBefore(node, content.firstChild); } } } else { for (var i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; if (node !== handle && node.nodeName.toLowerCase() !== this.config.nodes.list) { content.insertBefore(node, content.firstChild); } } } el.classList.add(this.config.classes.item); const list = el.querySelector(this.config.nodes.list); if (list) { el.insertBefore(content, list); const parent = this._makeParent(el); const items = DOM.children(parent, this.config.nodes.item); if (el.classList.contains(this.config.classes.collapsed)) { this._collapseList(el); } for (const i of items) { this._nest(i); } } else { el.appendChild(content); } } _isDisabled(item, type = "disabled") { if (item === null) { return false; } // item has the [data-nestable-disabled] attribute if ("nestableDisabled" in item.dataset) { if (type === "disabled" && (!item.dataset.nestableDisabled.length || item.dataset.nestableDisabled === "disabled") || type === "dragging" && item.dataset.nestableDisabled === "dragging" || type === "nesting" && item.dataset.nestableDisabled === "nesting" ) { return true; } } if (item.classList.contains(this.config.classes.disabled)) { return true; } const listEls = DOM.parents(item, `.${this.config.classes.disabled}`); if (listEls.length) { return true; } return false; } /** * Get mouse / touch event * @return {Object} */ _getEvent(e) { if (this.touch) { if (e.type === "touchend") { return e.changedTouches[0]; } return e.touches[0]; } return e; } _onMouseDown(e) { const evt = this._getEvent(e); const button = e.target.closest(`.${this.config.classes.button}`); const item = e.target.closest(`.${this.config.classes.item}`); if (button) { return this._toggleList(item, button); } const handle = e.target.closest(`.${this.config.classes.handle}`); if (!handle) { return false; } if (item) { if (this._isDisabled(item) || this._isDisabled(item.parentNode.closest(`.${this.config.classes.item}`))) { return false; } if (this._isDisabled(item, "dragging")) { this.emit("error.dragging.disabled", item); return false; } e.preventDefault(); this.parent.classList.add(this.config.classes.moving); item.classList.add(this.config.classes.dragging); const rect = DOM.rect(item); this.origin = { x: evt.pageX, y: evt.pageY, original: { x: evt.pageX, y: evt.pageY, }, }; this.hierarchy = { movedNode: item, originalParent: item.parentNode, originalParentItem: item.parentNode.closest(`.${this.config.classes.item}`) } this.active = { maxDepth: false, collapsedParent: false, disabledParent: false, confinedParent: false, node: item, rect: rect, parent: false, axis: false, }; // item has the [data-nestable-parent] attribute if ("nestableParent" in item.dataset) { const parent = document.getElementById(item.dataset.nestableParent); if (parent) { this.active.parent = parent; } } // item has the [data-nestable-axis] attribute if ("nestableAxis" in item.dataset) { const axis = item.dataset.nestableAxis; if (axis === "x") { this.active.axis = "x"; } else if (axis === "y") { this.active.axis = "y"; } } this.placeholder.style.height = `${rect.height}px`; // this.placeholder.style.width = `${rect.width}px`; if (this.config.showPlaceholderOnMove) { this.placeholder.style.opacity = 0; } if (!this.container) { this.container = document.createElement(this.config.nodes.list); this.container.classList.add(this.config.classes.list); this.container.classList.add(this.config.classes.container); this.container.id = `nestable_${this.id}`; } this.container.style.left = `${rect.left}px`; this.container.style.top = `${rect.top}px`; this.container.style.height = `${rect.height}px`; this.container.style.width = `${rect.width}px`; item.parentNode.insertBefore(this.placeholder, item); document.body.appendChild(this.container); this.container.appendChild(item); this.newParent = false; this.dragDepth = 0; // total depth of dragging item const items = DOM.selectAll(this.config.nodes.item, item); for (let i = 0; i < items.length; i++) { const depth = DOM.parents(items[i], this.config.nodes.list).length - 1; if (depth > this.dragDepth) { this.dragDepth = depth; } } this.emit("start", this.active); } } _onMouseMove(e) { if (this.active) { if (this.config.showPlaceholderOnMove) { this.placeholder.style.opacity = 1; } e = this._getEvent(e); let x = e.pageX - this.origin.x; let y = e.pageY - this.origin.y; if (e.pageY > this.last.y) { this.last.dirY = 1; } else if (e.pageY < this.last.y) { this.last.dirY = -1; } if (e.pageX > this.last.x) { this.last.dirX = 1; } else if (e.pageX < this.last.x) { this.last.dirX = -1; } let movement = false; if (Math.abs(x) > Math.abs(y)) { movement = "x"; } else if (Math.abs(x) < Math.abs(y)) { movement = "y"; } var scrollTop = (window.pageYOffset !== undefined) ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop; const elements = document.elementsFromPoint(e.pageX, e.pageY - scrollTop); if (movement === "x" && this.active.axis !== "y") { if (this.last.dirX > 0 && x > this.config.threshold) { // moving right const prevEl = this.placeholder.previousElementSibling; if (prevEl) { if (prevEl.classList.contains(this.config.classes.collapsed)) { if (!this.active.collapsedParent) { this.emit("error.collapsed", this.active.node, prevEl); this.active.collapsedParent = true; } } else { const disabled = this._isDisabled(prevEl); const cantNest = this._isDisabled(prevEl, "nesting"); if (!disabled) { const depth = DOM.parents(this.placeholder, this.config.nodes.list).length; let allowNesting = depth + this.dragDepth <= this.config.maxDepth; let parentEl = prevEl.querySelector(this.config.nodes.list); if (cantNest) { if (!this.active.nestDisabled) { this.emit("error.nesting.disabled", this.active.node); this.active.nestDisabled = true; } } else { if (allowNesting) { this.active.maxDepth = false; const oldParent = this.placeholder.closest(`.${this.config.classes.list}`); if (!parentEl) { parentEl = this._makeParent(prevEl); } this._moveElement(this.placeholder, { parent: parentEl, type: "appendChild", }); this.emit("nest", parentEl, oldParent); this.origin.x = e.pageX; } else { if (!this.active.maxDepth) { this.emit("error.maxdepth", this.active.node, this.config.maxDepth); this.active.maxDepth = true; } } } } else { if (!this.active.disabledParent) { this.emit("error.disabled"); this.active.disabledParent = true; } } } } } else if (this.last.dirX < 0 && x < -this.config.threshold) { // moving left this.active.maxDepth = false; this.active.nestDisabled = false; this.active.disabledParent = false; this.active.collapsedParent = false; // this.active.confinedParent = false; const listEl = this.placeholder.closest(this.config.nodes.list); const parentEl = listEl.closest(this.config.nodes.item); if (parentEl && ((listEl.childElementCount > 1 && this.placeholder !== listEl.firstElementChild) || listEl.childElementCount < 2 && this.placeholder === listEl.firstElementChild)) { const nextEl = parentEl.nextElementSibling; const oldParent = this.placeholder.closest(`.${this.config.classes.list}`); if (nextEl) { const list = nextEl.closest(this.config.nodes.list); this._moveElement(this.placeholder, { parent: list, type: "insertBefore", sibling: nextEl }); this.origin.x = e.pageX; } else { this._moveElement(this.placeholder, { parent: parentEl.closest(this.config.nodes.list), type: "appendChild", }); this.origin.x = e.pageX; } this.emit("unnest", parentEl, oldParent); } } } else { // check if we're over a valid item for (const element of elements) { const moveY = element !== this.active.node && !this.active.node.contains(element) && element.classList.contains(this.config.classes.content) && this.active.axis !== "x"; if (moveY) { const item = element.closest(`.${this.config.classes.item}`); if (item) { if (movement === "y") { const childListEl = item.querySelector(this.config.nodes.list); if (childListEl && !item.classList.contains(this.config.classes.collapsed)) { // item is parent if (this.last.dirY > 0) { // moving item down this._moveElement(this.placeholder, { parent: item.lastElementChild, type: "insertBefore", sibling: item.lastElementChild.firstElementChild, animatable: item.querySelector(`.${this.config.classes.content}`) }); } else if (this.last.dirY < 0) { // moving item up this._moveElement(this.placeholder, { parent: item.parentNode, type: "insertBefore", sibling: item, animatable: item.querySelector(`.${this.config.classes.content}`) }); } this.emit("reorder"); } else { // item is not a parent if (this.last.dirY > 0) { // moving item down const nextEl = item.nextElementSibling; if (nextEl) { // item has an item below it this._moveElement(this.placeholder, { parent: item.parentNode, type: "insertBefore", sibling: nextEl, animatable: item.querySelector(`.${this.config.classes.content}`) }); } else { // item is last in list this._moveElement(this.placeholder, { parent: item.closest(this.config.nodes.list), type: "appendChild", animatable: item.querySelector(`.${this.config.classes.content}`) }); } } else if (this.last.dirY < 0) { // moving item up this._moveElement(this.placeholder, { parent: item.parentNode, type: "insertBefore", sibling: item, animatable: item.querySelector(`.${this.config.classes.content}`) }); } this.emit("reorder"); } } } const parentEl = item.closest(`.${this.config.classes.parent}`); if (parentEl) { if (parentEl !== this.parent) { if (parentEl._nestable) { this.newParent = parentEl; } } } } } } this.placeholder.classList.toggle(this.config.classes.error, this.active.disabledParent || this.active.maxDepth || this.active.collapsedParent || this.active.confinedParent); let mx = e.pageX - this.origin.original.x; let my = e.pageY - this.origin.original.y; // item movement is confined if (this.active.axis) { if (this.active.axis === "x") { my = 0; } else if (this.active.axis === "y") { mx = 0; } } this.container.style.transform = `translate3d(${mx}px, ${my}px, 0)`; this.lastParent = this.placeholder.parentNode; this.hierarchy.newParent = this.lastParent; this.hierarchy.newParentItem = this.lastParent.closest(`.${this.config.classes.item}`); this.emit("move", this.active); } this.last = { x: e.pageX, y: e.pageY }; } _moveElement(el, type) { let ppos = false; let ipos = false; // prevent moving if item has disabled parents if (this._isDisabled(type.parent) || this._isDisabled(type.parent.closest(`.${this.config.classes.item}`))) { return false; } // prevent moving if item is confined to parent with data-nestable-parent if (this.active.parent) { if (!DOM.parents(type.parent, `#${this.active.parent.id}`).includes(this.active.parent)) { if (!this.active.confinedParent) { this.emit("error.confined", el, this.active.parent, type.parent); this.active.confinedParent = true; } return false; } } let listEl = el.closest(this.config.nodes.list); // if animation is enabled, we need to get the original position of the element first if (this.config.animation > 0) { ppos = DOM.rect(this.placeholder); if (type.animatable) { ipos = DOM.rect(type.animatable) } } if (type.type === "insertBefore") { type.parent.insertBefore(el, type.sibling); } else if (type.type === "appendChild") { type.parent.appendChild(el); } if (!listEl.childElementCount) { this._unmakeParent(listEl.parentNode); } this.emit("order.change", this.active.node, type.parent, listEl); // animate the elements if (this.config.animation > 0) { this._animateElement(this.placeholder, ppos); if (type.animatable && ipos) { this._animateElement(type.animatable, ipos); } } } _animateElement(el, obj) { // Animate an element's change in position // caused by a change in the DOM order let css = el.style; // Get the node's positon AFTER the change let r = DOM.rect(el); // Calculate the difference in position let x = obj.left - r.left; let y = obj.top - r.top; // Move the node to it's original position before the DOM change css.transform = `translate3d(${x}px, ${y}px, 0px)`; // css.zIndex = 10000; // Trigger a repaint so the next bit works this._repaint(el); // Reset the transform, but add a transition so it's smooth css.transform = `translate3d(0px, 0px, 0px)`; css.transition = `transform ${this.config.animation}ms`; // Reset the style setTimeout(function() { // console.log("foo") // css.zIndex = ""; css.transform = ""; css.transition = ""; }, this.config.animation); } _repaint(el) { return el.offsetHeight; } _onMouseUp(e) { if (this.active) { if (this.config.showPlaceholderOnMove) { this.placeholder.style.opacity = 0; } e = this._getEvent(e); const prect = DOM.rect(this.active.node); // this.active.node.removeAttribute("style"); this.container.removeAttribute("style"); this.parent.classList.remove(this.config.classes.moving); this.placeholder.parentNode.replaceChild(this.active.node, this.placeholder); this._animateElement(this.active.node, prect); this.placeholder.classList.remove(this.config.classes.error); this.active.node.classList.remove(this.config.classes.dragging); this.active = false; document.body.removeChild(this.container); this._getData(); if (this.newParent) { this.hierarchy.newInstance = this.newParent._nestable; this.newParent._nestable._getData(); } this.hierarchy.hierarchy = this.data this.emit("stop", this.hierarchy); this.update(); } } _toggleList(item, btn) { if (!item.classList.contains(this.config.classes.collapsed)) { this._collapseList(item, btn); } else { this._expandList(item, btn); } } _collapseList(item, btn) { if (!btn) { btn = item.querySelector(`.${this.config.classes.button}`) } btn.textContent = this.config.expandButtonContent; item.classList.add(this.config.classes.collapsed); this.emit("list.collapse", item); } _expandList(item, btn) { if (!btn) { btn = item.querySelector(`.${this.config.classes.button}`) } btn.textContent = this.config.collapseButtonContent; item.classList.remove(this.config.classes.collapsed); this.emit("list.expand", item); } _makeParent(el) { let parentEl = el.querySelector(this.config.nodes.list); if (!parentEl) { parentEl = document.createElement(this.config.nodes.list); parentEl.classList.add(this.config.classes.list); el.appendChild(parentEl); } else { parentEl.classList.add(this.config.classes.list); } const button = document.createElement("button"); button.classList.add(this.config.classes.button); button.type = "button"; button.textContent = this.config.collapseButtonContent; el.insertBefore(button, el.firstElementChild); return parentEl; } _unmakeParent(el) { const list = el.querySelector(this.config.nodes.list); const btn = el.querySelector("button"); if (list) { el.removeChild(list); } if (btn) { el.removeChild(btn); } return } _getData(type = "nodes") { let data = []; const step = (level) => { const array = []; const items = DOM.children(level, this.config.nodes.item); items.forEach((li) => { const item = {}; if (type === "nodes") { item.node = li; } else { item.data = Object.assign({}, li.dataset); if (this.config.includeContent) { const content = li.querySelector(`.${this.config.classes.content}`); if (content) { item.content = content.innerHTML; } } } const sub = li.querySelector(this.config.nodes.list); if (sub) { item.children = step(sub); } array.push(item); }); return array; }; data = step(this.parent); if (type === "nodes") { this.data = data; } return data; } }