diff --git a/Gruntfile.js b/Gruntfile.js index 4897fb4..64cfc70 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -27,7 +27,7 @@ module.exports = function (grunt) { } }, - shell: { + exec: { 'meteor-test': { command: 'meteor/runtests.sh' }, @@ -42,15 +42,12 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-version'); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-shell'); + grunt.loadNpmTasks('grunt-exec'); // Meteor tasks - grunt.registerTask('meteor-test', 'shell:meteor-test'); - grunt.registerTask('meteor-publish', 'shell:meteor-publish'); - // ideally we'd run tests before publishing, but the chances of tests breaking (given that - // Meteor is orthogonal to the library) are so small that it's not worth the maintainer's time - // grunt.regsterTask('meteor', ['shell:meteor-test', 'shell:meteor-publish']); - grunt.registerTask('meteor', 'shell:meteor-publish'); + grunt.registerTask('meteor-test', 'exec:meteor-test'); + grunt.registerTask('meteor-publish', 'exec:meteor-publish'); + grunt.registerTask('meteor', ['meteor-test', 'meteor-publish']); grunt.registerTask('tests', ['jshint']); grunt.registerTask('default', ['tests', 'version', 'uglify']); diff --git a/README.md b/README.md index 9a328e8..c84cfc5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Demo: http://rubaxa.github.io/Sortable/ * Supports drag handles *and selectable text* (better than voidberg's html5sortable) * Smart auto-scrolling * Built using native HTML5 drag and drop API - * Supports [AngularJS](#ng) and and any CSS library, e.g. [Bootstrap](#bs) + * Supports [Meteor](meteor/README.md) and [AngularJS](#ng) + * Supports any CSS library, e.g. [Bootstrap](#bs) * Simple API * No jQuery @@ -387,11 +388,29 @@ Demo: http://jsbin.com/luxero/2/edit?html,js,output ``` + --- +### Static methods & properties + + + +##### Sortable.create(el:`HTMLElement`[, options:`Object`]):`Sortable` +Create new instance. + + +--- + + +##### Sortable.active:`Sortable` +Link to the active instance. + + +--- + -### Sortable.utils +##### Sortable.utils * on(el`:HTMLElement`, event`:String`, fn`:Function`) — attach an event handler function * off(el`:HTMLElement`, event`:String`, fn`:Function`) — remove an event handler * css(el`:HTMLElement`)`:Object` — get the values of all the CSS properties @@ -400,6 +419,7 @@ Demo: http://jsbin.com/luxero/2/edit?html,js,output * css(el`:HTMLElement`, props`:Object`) — set more CSS properties * find(ctx`:HTMLElement`, tagName`:String`[, iterator`:Function`])`:Array` — get elements by tag name * bind(ctx`:Mixed`, fn`:Function`)`:Function` — Takes a function and returns a new one that will always have a particular context +* is(el`:HTMLElement`, selector`:String`)`:Boolean` — check the current matched set of elements against a selector * closest(el`:HTMLElement`, selector`:String`[, ctx`:HTMLElement`])`:HTMLElement|Null` — for each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree * toggleClass(el`:HTMLElement`, name`:String`, state`:Boolean`) — add or remove one classes from each element diff --git a/Sortable.js b/Sortable.js index 0f87f82..1db6bb3 100644 --- a/Sortable.js +++ b/Sortable.js @@ -104,9 +104,7 @@ setData: function (dataTransfer, dragEl) { dataTransfer.setData('Text', dragEl.textContent); } - }, - - group = options.group; + }; // Set default options @@ -115,6 +113,8 @@ } + var group = options.group; + if (!group || typeof group != 'object') { group = options.group = { name: group }; } @@ -165,20 +165,27 @@ constructor: Sortable, - _applyEffects: function () { + _dragStarted: function () { + // Apply effect _toggleClass(dragEl, this.options.ghostClass, true); + + Sortable.active = this; + + // Drag start event + _dispatchEvent(rootEl, 'start', dragEl, rootEl, startIndex); }, _onTapStart: function (/**Event|TouchEvent*/evt) { - var touch = evt.touches && evt.touches[0], + var type = evt.type, + touch = evt.touches && evt.touches[0], target = (touch || evt).target, originalTarget = target, options = this.options, el = this.el, filter = options.filter; - if (evt.type === 'mousedown' && evt.button !== 0 || options.disabled) { + if (type === 'mousedown' && evt.button !== 0 || options.disabled) { return; // only left button or enabled } @@ -215,14 +222,11 @@ } } - // IE 9 Support - if (target && evt.type == 'selectstart') { - if (target.tagName != 'A' && target.tagName != 'IMG') { - target.dragDrop(); - } - } - + // Prepare `dragstart` if (target && !dragEl && (target.parentNode === el)) { + // IE 9 Support + (type === 'selectstart') && target.dragDrop(); + tapEvt = evt; rootEl = this.el; @@ -269,17 +273,11 @@ } - // Drag start event - _dispatchEvent(rootEl, 'start', dragEl, rootEl, startIndex); - - if (activeGroup.pull == 'clone') { cloneEl = dragEl.cloneNode(true); _css(cloneEl, 'display', 'none'); rootEl.insertBefore(cloneEl, dragEl); } - - Sortable.active = this; } }, @@ -288,19 +286,29 @@ _css(ghostEl, 'display', 'none'); var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY), - parent = target.parentNode, + parent = target && target.parentNode, groupName = this.options.group.name, i = touchDragOverListeners.length; - if (parent && (' ' + parent[expando] + ' ').indexOf(groupName) > -1) { - while (i--) { - touchDragOverListeners[i]({ - clientX: touchEvt.clientX, - clientY: touchEvt.clientY, - target: target, - rootEl: parent - }); + if (parent) { + do { + if ((' ' + parent[expando] + ' ').indexOf(groupName) > -1) { + while (i--) { + touchDragOverListeners[i]({ + clientX: touchEvt.clientX, + clientY: touchEvt.clientY, + target: target, + rootEl: parent + }); + } + + break; + } + + target = parent; // store last element } + /* jshint boss:true */ + while (parent = parent.parentNode); } _css(ghostEl, 'display', ''); @@ -370,8 +378,6 @@ _on(document, 'drop', this); } - setTimeout(this._applyEffects, 0); - scrollEl = options.scroll; if (scrollEl === true) { @@ -386,6 +392,8 @@ /* jshint boss:true */ } while (scrollEl = scrollEl.parentNode); } + + setTimeout(this._dragStarted, 0); }, _onDrag: _throttle(function (/**Event*/evt) { @@ -601,7 +609,7 @@ _disableDraggable(dragEl); _toggleClass(dragEl, this.options.ghostClass, false); - if (!rootEl.contains(dragEl)) { + if (rootEl !== dragEl.parentNode) { // drag from one list and drop into another _dispatchEvent(dragEl.parentNode, 'sort', dragEl, rootEl, startIndex, newIndex); _dispatchEvent(rootEl, 'sort', dragEl, rootEl, startIndex, newIndex); @@ -621,7 +629,7 @@ } // Drag end event - _dispatchEvent(rootEl, 'end', dragEl, rootEl, startIndex, newIndex); + Sortable.active && _dispatchEvent(rootEl, 'end', dragEl, rootEl, startIndex, newIndex); } // Set NULL @@ -641,7 +649,7 @@ Sortable.active = null; // Save sorting - this.save() + this.save(); } }, @@ -975,7 +983,7 @@ }; - Sortable.version = '0.7.3'; + Sortable.version = '1.0.0'; /** diff --git a/Sortable.min.js b/Sortable.min.js index 93fe93b..098e280 100644 --- a/Sortable.min.js +++ b/Sortable.min.js @@ -1,2 +1,2 @@ -/*! Sortable 0.7.3 - MIT | git://github.com/rubaxa/Sortable.git */ -!function(a){"use strict";"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&"undefined"!=typeof module.exports?module.exports=a():"undefined"!=typeof Package?Sortable=a():window.Sortable=a()}(function(){"use strict";function a(a,b){this.el=a,this.options=b=b||{};var d,e={group:Math.random(),sort:!0,disabled:!1,store:null,handle:null,scroll:!0,scrollSensitivity:30,scrollSpeed:10,draggable:/[uo]l/i.test(a.nodeName)?"li":">*",ghostClass:"sortable-ghost",ignore:"a, img",filter:null,animation:0,setData:function(a,b){a.setData("Text",b.textContent)}};for(var g in e)!(g in b)&&(b[g]=e[g]);b.group.name||(b.group={name:b.group}),d=b.group,["pull","put"].forEach(function(a){a in d||(d[a]=!0)}),K.forEach(function(d){b[d]=c(this,b[d]||L),f(a,d.substr(2).toLowerCase(),b[d])},this),a[D]=d.name+" "+(d.put.join?d.put.join(" "):"");for(var h in this)"_"===h.charAt(0)&&(this[h]=c(this,this[h]));f(a,"mousedown",this._onTapStart),f(a,"touchstart",this._onTapStart),H&&f(a,"selectstart",this._onTapStart),f(a,"dragover",this._onDragOver),f(a,"dragenter",this._onDragOver),O.push(this._onDragOver),b.store&&this.sort(b.store.get(this))}function b(a){t&&t.state!==a&&(i(t,"display",a?"none":""),!a&&t.state&&u.insertBefore(t,q),t.state=a)}function c(a,b){var c=N.call(arguments,2);return b.bind?b.bind.apply(b,[a].concat(c)):function(){return b.apply(a,c.concat(N.call(arguments)))}}function d(a,b,c){if(a){c=c||F,b=b.split(".");var d=b.shift().toUpperCase(),e=new RegExp("\\s("+b.join("|")+")\\s","g");do if(">*"===d&&a.parentNode===c||(""===d||a.nodeName==d)&&(!b.length||((" "+a.className+" ").match(e)||[]).length==b.length))return a;while(a!==c&&(a=a.parentNode))}return null}function e(a){a.dataTransfer.dropEffect="move",a.preventDefault()}function f(a,b,c){a.addEventListener(b,c,!1)}function g(a,b,c){a.removeEventListener(b,c,!1)}function h(a,b,c){if(a)if(a.classList)a.classList[c?"add":"remove"](b);else{var d=(" "+a.className+" ").replace(/\s+/g," ").replace(" "+b+" ","");a.className=d+(c?" "+b:"")}}function i(a,b,c){var d=a&&a.style;if(d){if(void 0===c)return F.defaultView&&F.defaultView.getComputedStyle?c=F.defaultView.getComputedStyle(a,""):a.currentStyle&&(c=a.currentStyle),void 0===b?c:c[b];b in d||(b="-webkit-"+b),d[b]=c+("string"==typeof c?"":"px")}}function j(a,b,c){if(a){var d=a.getElementsByTagName(b),e=0,f=d.length;if(c)for(;f>e;e++)c(d[e],e);return d}return[]}function k(a){a.draggable=!1}function l(){I=!1}function m(a,b){var c=a.lastElementChild,d=c.getBoundingClientRect();return b.clientY-(d.top+d.height)>5&&c}function n(a){for(var b=a.tagName+a.className+a.src+a.href+a.textContent,c=b.length,d=0;c--;)d+=b.charCodeAt(c);return d.toString(36)}function o(a){for(var b=0;a&&(a=a.previousElementSibling)&&"TEMPLATE"!==a.nodeName;)b++;return b}function p(a,b){var c,d;return function(){void 0===c&&(c=arguments,d=this,setTimeout(function(){1===c.length?a.call(d,c[0]):a.apply(d,c),c=void 0},b))}}var q,r,s,t,u,v,w,x,y,z,A,B,C={},D="Sortable"+(new Date).getTime(),E=window,F=E.document,G=E.parseInt,H=!!F.createElement("div").dragDrop,I=!1,J=function(a,b,c,d,e,f){var g=F.createEvent("Event");g.initEvent(b,!0,!0),g.item=c||a,g.from=d||a,g.oldIndex=e,g.newIndex=f,a.dispatchEvent(g)},K="onAdd onUpdate onRemove onStart onEnd onFilter onSort".split(" "),L=function(){},M=Math.abs,N=[].slice,O=[];return a.prototype={constructor:a,_applyEffects:function(){h(q,this.options.ghostClass,!0)},_onTapStart:function(b){var c=b.touches&&b.touches[0],e=(c||b).target,g=e,h=this.options,l=this.el,m=h.filter;if(!("mousedown"===b.type&&0!==b.button||h.disabled)){if(h.handle&&(e=d(e,h.handle,l)),e=d(e,h.draggable,l),r=o(e),"function"==typeof m){if(m.call(this,b,e,this))return J(g,"filter",e,l,r),b.preventDefault(),void 0}else if(m&&(m=m.split(",").some(function(a){return a=d(g,a.trim(),l),a?(J(a,"filter",e,l,r),!0):void 0})))return b.preventDefault(),void 0;if(e&&"selectstart"==b.type&&"A"!=e.tagName&&"IMG"!=e.tagName&&e.dragDrop(),e&&!q&&e.parentNode===l){A=b,u=this.el,q=e,w=q.nextSibling,z=this.options.group,q.draggable=!0,h.ignore.split(",").forEach(function(a){j(e,a.trim(),k)}),c&&(A={target:e,clientX:c.clientX,clientY:c.clientY},this._onDragStart(A,!0),b.preventDefault()),f(F,"mouseup",this._onDrop),f(F,"touchend",this._onDrop),f(F,"touchcancel",this._onDrop),f(q,"dragend",this),f(u,"dragstart",this._onDragStart),f(F,"dragover",this);try{F.selection?F.selection.empty():window.getSelection().removeAllRanges()}catch(n){}J(u,"start",q,u,r),"clone"==z.pull&&(t=q.cloneNode(!0),i(t,"display","none"),u.insertBefore(t,q)),a.active=this}}},_emulateDragOver:function(){if(B){i(s,"display","none");var a=F.elementFromPoint(B.clientX,B.clientY),b=a.parentNode,c=this.options.group.name,d=O.length;if(b&&(" "+b[D]+" ").indexOf(c)>-1)for(;d--;)O[d]({clientX:B.clientX,clientY:B.clientY,target:a,rootEl:b});i(s,"display","")}},_onTouchMove:function(a){if(A){var b=a.touches[0],c=b.clientX-A.clientX,d=b.clientY-A.clientY,e="translate3d("+c+"px,"+d+"px,0)";B=b,i(s,"webkitTransform",e),i(s,"mozTransform",e),i(s,"msTransform",e),i(s,"transform",e),this._onDrag(b),a.preventDefault()}},_onDragStart:function(a,b){var c=a.dataTransfer,d=this.options;if(this._offUpEvents(),b){var e,g=q.getBoundingClientRect(),h=i(q);s=q.cloneNode(!0),i(s,"top",g.top-G(h.marginTop,10)),i(s,"left",g.left-G(h.marginLeft,10)),i(s,"width",g.width),i(s,"height",g.height),i(s,"opacity","0.8"),i(s,"position","fixed"),i(s,"zIndex","100000"),u.appendChild(s),e=s.getBoundingClientRect(),i(s,"width",2*g.width-e.width),i(s,"height",2*g.height-e.height),f(F,"touchmove",this._onTouchMove),f(F,"touchend",this._onDrop),f(F,"touchcancel",this._onDrop),this._loopId=setInterval(this._emulateDragOver,150)}else c.effectAllowed="move",d.setData&&d.setData.call(this,c,q),f(F,"drop",this);if(setTimeout(this._applyEffects,0),v=d.scroll,v===!0){v=u;do if(v.offsetWidth=i-g)-(e>=g),l=(e>=j-h)-(e>=h);k||l?b=E:v&&(b=v,c=v.getBoundingClientRect(),k=(M(c.right-g)<=e)-(M(c.left-g)<=e),l=(M(c.bottom-h)<=e)-(M(c.top-h)<=e)),(C.vx!==k||C.vy!==l||C.el!==b)&&(C.el=b,C.vx=k,C.vy=l,clearInterval(C.pid),b&&(C.pid=setInterval(function(){b===E?E.scrollTo(E.scrollX+k*f,E.scrollY+l*f):(l&&(b.scrollTop+=l*f),k&&(b.scrollLeft+=k*f))},24)))}},30),_onDragOver:function(a){var c,e,f,g=this.el,h=this.options,j=h.group,k=j.put,n=z===j,o=h.sort;if(void 0!==a.stopPropagation&&a.stopPropagation(),!I&&z&&(n?o||(f=!u.contains(q)):z.pull&&k&&(z.name===j.name||k.indexOf&&~k.indexOf(z.name)))&&(void 0===a.rootEl||a.rootEl===this.el)){if(c=d(a.target,h.draggable,g),e=q.getBoundingClientRect(),f)return b(!0),t||w?u.insertBefore(q,t||w):o||u.appendChild(q),void 0;if(0===g.children.length||g.children[0]===s||g===a.target&&(c=m(g,a))){if(c){if(c.animated)return;r=c.getBoundingClientRect()}b(n),g.appendChild(q),this._animate(e,q),c&&this._animate(r,c)}else if(c&&!c.animated&&c!==q&&void 0!==c.parentNode[D]){x!==c&&(x=c,y=i(c));var p,r=c.getBoundingClientRect(),v=r.right-r.left,A=r.bottom-r.top,B=/left|right|inline/.test(y.cssFloat+y.display),C=c.offsetWidth>q.offsetWidth,E=c.offsetHeight>q.offsetHeight,F=(B?(a.clientX-r.left)/v:(a.clientY-r.top)/A)>.5,G=c.nextElementSibling;I=!0,setTimeout(l,30),b(n),p=B?c.previousElementSibling===q&&!C||F&&C:G!==q&&!E||F&&E,p&&!G?g.appendChild(q):c.parentNode.insertBefore(q,p?G:c),this._animate(e,q),this._animate(r,c)}}},_animate:function(a,b){var c=this.options.animation;if(c){var d=b.getBoundingClientRect();i(b,"transition","none"),i(b,"transform","translate3d("+(a.left-d.left)+"px,"+(a.top-d.top)+"px,0)"),b.offsetWidth,i(b,"transition","all "+c+"ms"),i(b,"transform","translate3d(0,0,0)"),clearTimeout(b.animated),b.animated=setTimeout(function(){i(b,"transition",""),b.animated=!1},c)}},_offUpEvents:function(){g(F,"mouseup",this._onDrop),g(F,"touchmove",this._onTouchMove),g(F,"touchend",this._onDrop),g(F,"touchcancel",this._onDrop)},_onDrop:function(b){var c=this.el;if(clearInterval(this._loopId),clearInterval(C.pid),g(F,"drop",this),g(F,"dragover",this),g(c,"dragstart",this._onDragStart),this._offUpEvents(),b){if(b.preventDefault(),b.stopPropagation(),s&&s.parentNode.removeChild(s),q){g(q,"dragend",this);var d=o(q);k(q),h(q,this.options.ghostClass,!1),u.contains(q)?q.nextSibling!==w&&(J(u,"update",q,u,r,d),J(u,"sort",q,u,r,d),t&&t.parentNode.removeChild(t)):(J(q.parentNode,"sort",q,u,r,d),J(u,"sort",q,u,r,d),J(q,"add",q,u,r,d),J(u,"remove",q,u,r,d)),J(u,"end",q,u,r,d)}u=q=s=w=t=A=B=x=y=z=a.active=null,this.options.store&&this.options.store.set(this)}},handleEvent:function(a){var b=a.type;"dragover"===b?(this._onDrag(a),e(a)):("drop"===b||"dragend"===b)&&this._onDrop(a)},toArray:function(){for(var a,b=[],c=this.el.children,e=0,f=c.length;f>e;e++)a=c[e],d(a,this.options.draggable,this.el)&&b.push(a.getAttribute("data-id")||n(a));return b},sort:function(a){var b={},c=this.el;this.toArray().forEach(function(a,e){var f=c.children[e];d(f,this.options.draggable,c)&&(b[a]=f)},this),a.forEach(function(a){b[a]&&(c.removeChild(b[a]),c.appendChild(b[a]))})},closest:function(a,b){return d(a,b||this.options.draggable,this.el)},option:function(a,b){var c=this.options;return void 0===b?c[a]:(c[a]=b,void 0)},destroy:function(){var a=this.el,b=this.options;K.forEach(function(c){g(a,c.substr(2).toLowerCase(),b[c])}),g(a,"mousedown",this._onTapStart),g(a,"touchstart",this._onTapStart),g(a,"selectstart",this._onTapStart),g(a,"dragover",this._onDragOver),g(a,"dragenter",this._onDragOver),Array.prototype.forEach.call(a.querySelectorAll("[draggable]"),function(a){a.removeAttribute("draggable")}),O.splice(O.indexOf(this._onDragOver),1),this._onDrop(),this.el=null}},a.utils={on:f,off:g,css:i,find:j,bind:c,is:function(a,b){return!!d(a,b,a)},throttle:p,closest:d,toggleClass:h,dispatchEvent:J,index:o},a.version="0.7.3",a.create=function(b,c){return new a(b,c)},a}); \ No newline at end of file +/*! Sortable 1.0.0 - MIT | git://github.com/rubaxa/Sortable.git */ +!function(a){"use strict";"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&"undefined"!=typeof module.exports?module.exports=a():"undefined"!=typeof Package?Sortable=a():window.Sortable=a()}(function(){"use strict";function a(a,b){this.el=a,this.options=b=b||{};var d={group:Math.random(),sort:!0,disabled:!1,store:null,handle:null,scroll:!0,scrollSensitivity:30,scrollSpeed:10,draggable:/[uo]l/i.test(a.nodeName)?"li":">*",ghostClass:"sortable-ghost",ignore:"a, img",filter:null,animation:0,setData:function(a,b){a.setData("Text",b.textContent)}},e=b.group;for(var g in d)!(g in b)&&(b[g]=d[g]);e&&"object"==typeof e||(e=b.group={name:e}),["pull","put"].forEach(function(a){a in e||(e[a]=!0)}),K.forEach(function(d){b[d]=c(this,b[d]||L),f(a,d.substr(2).toLowerCase(),b[d])},this),a[D]=e.name+" "+(e.put.join?e.put.join(" "):"");for(var h in this)"_"===h.charAt(0)&&(this[h]=c(this,this[h]));f(a,"mousedown",this._onTapStart),f(a,"touchstart",this._onTapStart),H&&f(a,"selectstart",this._onTapStart),f(a,"dragover",this._onDragOver),f(a,"dragenter",this._onDragOver),O.push(this._onDragOver),b.store&&this.sort(b.store.get(this))}function b(a){t&&t.state!==a&&(i(t,"display",a?"none":""),!a&&t.state&&u.insertBefore(t,q),t.state=a)}function c(a,b){var c=N.call(arguments,2);return b.bind?b.bind.apply(b,[a].concat(c)):function(){return b.apply(a,c.concat(N.call(arguments)))}}function d(a,b,c){if(a){c=c||F,b=b.split(".");var d=b.shift().toUpperCase(),e=new RegExp("\\s("+b.join("|")+")\\s","g");do if(">*"===d&&a.parentNode===c||(""===d||a.nodeName==d)&&(!b.length||((" "+a.className+" ").match(e)||[]).length==b.length))return a;while(a!==c&&(a=a.parentNode))}return null}function e(a){a.dataTransfer.dropEffect="move",a.preventDefault()}function f(a,b,c){a.addEventListener(b,c,!1)}function g(a,b,c){a.removeEventListener(b,c,!1)}function h(a,b,c){if(a)if(a.classList)a.classList[c?"add":"remove"](b);else{var d=(" "+a.className+" ").replace(/\s+/g," ").replace(" "+b+" ","");a.className=d+(c?" "+b:"")}}function i(a,b,c){var d=a&&a.style;if(d){if(void 0===c)return F.defaultView&&F.defaultView.getComputedStyle?c=F.defaultView.getComputedStyle(a,""):a.currentStyle&&(c=a.currentStyle),void 0===b?c:c[b];b in d||(b="-webkit-"+b),d[b]=c+("string"==typeof c?"":"px")}}function j(a,b,c){if(a){var d=a.getElementsByTagName(b),e=0,f=d.length;if(c)for(;f>e;e++)c(d[e],e);return d}return[]}function k(a){a.draggable=!1}function l(){I=!1}function m(a,b){var c=a.lastElementChild,d=c.getBoundingClientRect();return b.clientY-(d.top+d.height)>5&&c}function n(a){for(var b=a.tagName+a.className+a.src+a.href+a.textContent,c=b.length,d=0;c--;)d+=b.charCodeAt(c);return d.toString(36)}function o(a){for(var b=0;a&&(a=a.previousElementSibling)&&"TEMPLATE"!==a.nodeName;)b++;return b}function p(a,b){var c,d;return function(){void 0===c&&(c=arguments,d=this,setTimeout(function(){1===c.length?a.call(d,c[0]):a.apply(d,c),c=void 0},b))}}var q,r,s,t,u,v,w,x,y,z,A,B,C={},D="Sortable"+(new Date).getTime(),E=window,F=E.document,G=E.parseInt,H=!!F.createElement("div").dragDrop,I=!1,J=function(a,b,c,d,e,f){var g=F.createEvent("Event");g.initEvent(b,!0,!0),g.item=c||a,g.from=d||a,g.oldIndex=e,g.newIndex=f,a.dispatchEvent(g)},K="onAdd onUpdate onRemove onStart onEnd onFilter onSort".split(" "),L=function(){},M=Math.abs,N=[].slice,O=[];return a.prototype={constructor:a,_applyEffects:function(){h(q,this.options.ghostClass,!0)},_onTapStart:function(b){var c=b.touches&&b.touches[0],e=(c||b).target,g=e,h=this.options,l=this.el,m=h.filter;if(!("mousedown"===b.type&&0!==b.button||h.disabled)){if(h.handle&&(e=d(e,h.handle,l)),e=d(e,h.draggable,l),r=o(e),"function"==typeof m){if(m.call(this,b,e,this))return J(g,"filter",e,l,r),b.preventDefault(),void 0}else if(m&&(m=m.split(",").some(function(a){return a=d(g,a.trim(),l),a?(J(a,"filter",e,l,r),!0):void 0})))return b.preventDefault(),void 0;if(e&&"selectstart"==b.type&&"A"!=e.tagName&&"IMG"!=e.tagName&&e.dragDrop(),e&&!q&&e.parentNode===l){A=b,u=this.el,q=e,w=q.nextSibling,z=this.options.group,q.draggable=!0,h.ignore.split(",").forEach(function(a){j(e,a.trim(),k)}),c&&(A={target:e,clientX:c.clientX,clientY:c.clientY},this._onDragStart(A,!0),b.preventDefault()),f(F,"mouseup",this._onDrop),f(F,"touchend",this._onDrop),f(F,"touchcancel",this._onDrop),f(q,"dragend",this),f(u,"dragstart",this._onDragStart),f(F,"dragover",this);try{F.selection?F.selection.empty():window.getSelection().removeAllRanges()}catch(n){}J(u,"start",q,u,r),"clone"==z.pull&&(t=q.cloneNode(!0),i(t,"display","none"),u.insertBefore(t,q)),a.active=this}}},_emulateDragOver:function(){if(B){i(s,"display","none");var a=F.elementFromPoint(B.clientX,B.clientY),b=a.parentNode,c=this.options.group.name,d=O.length;if(b&&(" "+b[D]+" ").indexOf(c)>-1)for(;d--;)O[d]({clientX:B.clientX,clientY:B.clientY,target:a,rootEl:b});i(s,"display","")}},_onTouchMove:function(a){if(A){var b=a.touches[0],c=b.clientX-A.clientX,d=b.clientY-A.clientY,e="translate3d("+c+"px,"+d+"px,0)";B=b,i(s,"webkitTransform",e),i(s,"mozTransform",e),i(s,"msTransform",e),i(s,"transform",e),this._onDrag(b),a.preventDefault()}},_onDragStart:function(a,b){var c=a.dataTransfer,d=this.options;if(this._offUpEvents(),b){var e,g=q.getBoundingClientRect(),h=i(q);s=q.cloneNode(!0),i(s,"top",g.top-G(h.marginTop,10)),i(s,"left",g.left-G(h.marginLeft,10)),i(s,"width",g.width),i(s,"height",g.height),i(s,"opacity","0.8"),i(s,"position","fixed"),i(s,"zIndex","100000"),u.appendChild(s),e=s.getBoundingClientRect(),i(s,"width",2*g.width-e.width),i(s,"height",2*g.height-e.height),f(F,"touchmove",this._onTouchMove),f(F,"touchend",this._onDrop),f(F,"touchcancel",this._onDrop),this._loopId=setInterval(this._emulateDragOver,150)}else c.effectAllowed="move",d.setData&&d.setData.call(this,c,q),f(F,"drop",this);if(setTimeout(this._applyEffects,0),v=d.scroll,v===!0){v=u;do if(v.offsetWidth=i-g)-(e>=g),l=(e>=j-h)-(e>=h);k||l?b=E:v&&(b=v,c=v.getBoundingClientRect(),k=(M(c.right-g)<=e)-(M(c.left-g)<=e),l=(M(c.bottom-h)<=e)-(M(c.top-h)<=e)),(C.vx!==k||C.vy!==l||C.el!==b)&&(C.el=b,C.vx=k,C.vy=l,clearInterval(C.pid),b&&(C.pid=setInterval(function(){b===E?E.scrollTo(E.scrollX+k*f,E.scrollY+l*f):(l&&(b.scrollTop+=l*f),k&&(b.scrollLeft+=k*f))},24)))}},30),_onDragOver:function(a){var c,e,f,g=this.el,h=this.options,j=h.group,k=j.put,n=z===j,o=h.sort;if(void 0!==a.preventDefault&&(a.preventDefault(),a.stopPropagation()),!I&&z&&(n?o||(f=!u.contains(q)):z.pull&&k&&(z.name===j.name||k.indexOf&&~k.indexOf(z.name)))&&(void 0===a.rootEl||a.rootEl===this.el)){if(c=d(a.target,h.draggable,g),e=q.getBoundingClientRect(),f)return b(!0),t||w?u.insertBefore(q,t||w):o||u.appendChild(q),void 0;if(0===g.children.length||g.children[0]===s||g===a.target&&(c=m(g,a))){if(c){if(c.animated)return;r=c.getBoundingClientRect()}b(n),g.appendChild(q),this._animate(e,q),c&&this._animate(r,c)}else if(c&&!c.animated&&c!==q&&void 0!==c.parentNode[D]){x!==c&&(x=c,y=i(c));var p,r=c.getBoundingClientRect(),v=r.right-r.left,A=r.bottom-r.top,B=/left|right|inline/.test(y.cssFloat+y.display),C=c.offsetWidth>q.offsetWidth,E=c.offsetHeight>q.offsetHeight,F=(B?(a.clientX-r.left)/v:(a.clientY-r.top)/A)>.5,G=c.nextElementSibling;I=!0,setTimeout(l,30),b(n),p=B?c.previousElementSibling===q&&!C||F&&C:G!==q&&!E||F&&E,p&&!G?g.appendChild(q):c.parentNode.insertBefore(q,p?G:c),this._animate(e,q),this._animate(r,c)}}},_animate:function(a,b){var c=this.options.animation;if(c){var d=b.getBoundingClientRect();i(b,"transition","none"),i(b,"transform","translate3d("+(a.left-d.left)+"px,"+(a.top-d.top)+"px,0)"),b.offsetWidth,i(b,"transition","all "+c+"ms"),i(b,"transform","translate3d(0,0,0)"),clearTimeout(b.animated),b.animated=setTimeout(function(){i(b,"transition",""),b.animated=!1},c)}},_offUpEvents:function(){g(F,"mouseup",this._onDrop),g(F,"touchmove",this._onTouchMove),g(F,"touchend",this._onDrop),g(F,"touchcancel",this._onDrop)},_onDrop:function(b){var c=this.el;if(clearInterval(this._loopId),clearInterval(C.pid),g(F,"drop",this),g(F,"dragover",this),g(c,"dragstart",this._onDragStart),this._offUpEvents(),b){if(b.preventDefault(),b.stopPropagation(),s&&s.parentNode.removeChild(s),q){g(q,"dragend",this);var d=o(q);k(q),h(q,this.options.ghostClass,!1),u.contains(q)?q.nextSibling!==w&&(J(u,"update",q,u,r,d),J(u,"sort",q,u,r,d),t&&t.parentNode.removeChild(t)):(J(q.parentNode,"sort",q,u,r,d),J(u,"sort",q,u,r,d),J(q,"add",q,u,r,d),J(u,"remove",q,u,r,d)),J(u,"end",q,u,r,d)}u=q=s=w=t=A=B=x=y=z=a.active=null,this.save()}},handleEvent:function(a){var b=a.type;"dragover"===b?(this._onDrag(a),e(a)):("drop"===b||"dragend"===b)&&this._onDrop(a)},toArray:function(){for(var a,b=[],c=this.el.children,e=0,f=c.length;f>e;e++)a=c[e],d(a,this.options.draggable,this.el)&&b.push(a.getAttribute("data-id")||n(a));return b},sort:function(a){var b={},c=this.el;this.toArray().forEach(function(a,e){var f=c.children[e];d(f,this.options.draggable,c)&&(b[a]=f)},this),a.forEach(function(a){b[a]&&(c.removeChild(b[a]),c.appendChild(b[a]))})},save:function(){var a=this.options.store;a&&a.set(this)},closest:function(a,b){return d(a,b||this.options.draggable,this.el)},option:function(a,b){var c=this.options;return void 0===b?c[a]:(c[a]=b,void 0)},destroy:function(){var a=this.el,b=this.options;K.forEach(function(c){g(a,c.substr(2).toLowerCase(),b[c])}),g(a,"mousedown",this._onTapStart),g(a,"touchstart",this._onTapStart),g(a,"selectstart",this._onTapStart),g(a,"dragover",this._onDragOver),g(a,"dragenter",this._onDragOver),Array.prototype.forEach.call(a.querySelectorAll("[draggable]"),function(a){a.removeAttribute("draggable")}),O.splice(O.indexOf(this._onDragOver),1),this._onDrop(),this.el=null}},a.utils={on:f,off:g,css:i,find:j,bind:c,is:function(a,b){return!!d(a,b,a)},throttle:p,closest:d,toggleClass:h,dispatchEvent:J,index:o},a.version="1.0.0",a.create=function(b,c){return new a(b,c)},a}); \ No newline at end of file diff --git a/bower.json b/bower.json index f3aa12b..9baf297 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "Sortable", "main": "Sortable.js", - "version": "0.7.3", + "version": "1.0.0", "homepage": "http://rubaxa.github.io/Sortable/", "authors": [ "RubaXa " diff --git a/component.json b/component.json index 3287bdc..e4c83ed 100644 --- a/component.json +++ b/component.json @@ -1,7 +1,7 @@ { "name": "Sortable", "main": "Sortable.js", - "version": "0.7.3", + "version": "1.0.0", "homepage": "http://rubaxa.github.io/Sortable/", "repo": "RubaXa/Sortable", "authors": [ diff --git a/index.html b/index.html index a63a57e..0f056b6 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Sortable. No jQuery. - + diff --git a/meteor/README.md b/meteor/README.md index 49c1118..163223b 100644 --- a/meteor/README.md +++ b/meteor/README.md @@ -1,26 +1,106 @@ -Packaging [Sortable](http://rubaxa.github.io/Sortable/) for [Meteor.js](http://meteor.com). +Reactive reorderable lists with [Sortable](http://rubaxa.github.io/Sortable/), +backed by [Meteor.js](http://meteor.com) collections: +* new elements arriving in the collection will update the list as you expect +* elements removed from the collection will be removed from the list +* drag and drop between lists updates collections accordingly + +Demo: http://rubaxa-sortable.meteor.com # Meteor If you're new to Meteor, here's what the excitement is all about - [watch the first two minutes](https://www.youtube.com/watch?v=fsi0aJ9yr2o); you'll be hooked by 1:28. - That screencast is from 2012. In the meantime, Meteor has become a mature JavaScript-everywhere web development framework. Read more at [Why Meteor](http://www.meteorpedia.com/read/Why_Meteor). -# Issues +# Usage -If you encounter an issue while using this package, please CC @dandv when you file it in this repo. +Simplest invocation - order will be lost when the page is refreshed: + +```handlebars +{{sortable }} +``` + +Persist the sort order in the 'order' field of each document in the collection: + +```handlebars +{{sortable items= sortField="order"}} +``` + +Along with `items`, `sortField` is the only Meteor-specific option. If it's missing, the package will +assume there is a field called "order" in the collection, holding unique `Number`s such that every +`order` differs from that before and after it by at least 1. Basically, keep to 0, 1, 2, ... . +Try not to depend on a particular format for this field; it *is* though guaranteed that a `sort` will +produce lexicographical order, and that the order will be maintained after an arbitrary number of +reorderings, unlike with [naive solutions](http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible). + + +## Passing options to the Sortable library + + {{sortable items= option1=value1 option2=value2...}} + {{sortable items= options=myOptions}} +For available options, please refer to [the main README](../README.md#options). You can pass them directly +or under the `options` object. Direct options (`key=value`) override those in `options`. It is best +to pass presentation-related options directly, and functionality-related settings in an `options` +object, as this will enable designers to work without needing to inspect the JavaScript code: -# DONE + -* Instantiation test +Define the options in a helper for the template that calls Sortable: + +```js +Template.myTemplate.helpers({ + playerOptions: function () { + return { + group: { + name: "league", + pull: true, + put: false + }, + sort: false + }; + } +}); +``` + + +## Events + +All the original Sortable events are supported. In addition, they will receive +the data context in `event.data`. You can access `event.data.order` this way: + +```handlebars +{{sortable items=players options=playersOptions}} +``` + +```js +Template.myTemplate.helpers({ + playersOptions: function () { + return { + onSort: function(/**Event*/event) { + console.log('Moved player #%d from %d to %d', + event.data.order, event.oldIndex, event.newIndex + ); + } + }; + } +}); +``` + + +# Issues + +If you encounter an issue while using this package, please CC @dandv when you file it in this repo. # TODO -* Meteor collection backing -* Tests ensuring correct rendering with Meteor dynamic templates +* Array support +* Tests +* Misc. - see reactivize.js diff --git a/meteor/example/.meteor/.gitignore b/meteor/example/.meteor/.gitignore new file mode 100644 index 0000000..4083037 --- /dev/null +++ b/meteor/example/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/meteor/example/.meteor/packages b/meteor/example/.meteor/packages new file mode 100644 index 0000000..a33b0ed --- /dev/null +++ b/meteor/example/.meteor/packages @@ -0,0 +1,12 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-platform +autopublish +insecure +rubaxa:sortable +dburles:mongo-collection-instances +fezvrasta:bootstrap-material-design +# twbs:bootstrap diff --git a/meteor/example/.meteor/release b/meteor/example/.meteor/release new file mode 100644 index 0000000..f1b6255 --- /dev/null +++ b/meteor/example/.meteor/release @@ -0,0 +1 @@ +METEOR@1.0.1 diff --git a/meteor/example/.meteor/versions b/meteor/example/.meteor/versions new file mode 100644 index 0000000..ce300af --- /dev/null +++ b/meteor/example/.meteor/versions @@ -0,0 +1,56 @@ +application-configuration@1.0.3 +autopublish@1.0.1 +autoupdate@1.1.3 +base64@1.0.1 +binary-heap@1.0.1 +blaze-tools@1.0.1 +blaze@2.0.3 +boilerplate-generator@1.0.1 +callback-hook@1.0.1 +check@1.0.2 +ctl-helper@1.0.4 +ctl@1.0.2 +dburles:mongo-collection-instances@0.2.5 +ddp@1.0.12 +deps@1.0.5 +ejson@1.0.4 +fastclick@1.0.1 +fezvrasta:bootstrap-material-design@0.2.1 +follower-livedata@1.0.2 +geojson-utils@1.0.1 +html-tools@1.0.2 +htmljs@1.0.2 +http@1.0.8 +id-map@1.0.1 +insecure@1.0.1 +jquery@1.0.1 +json@1.0.1 +launch-screen@1.0.0 +livedata@1.0.11 +logging@1.0.5 +meteor-platform@1.2.0 +meteor@1.1.3 +minifiers@1.1.2 +minimongo@1.0.5 +mobile-status-bar@1.0.1 +mongo@1.0.9 +observe-sequence@1.0.3 +ordered-dict@1.0.1 +random@1.0.1 +reactive-dict@1.0.4 +reactive-var@1.0.3 +reload@1.1.1 +retry@1.0.1 +routepolicy@1.0.2 +rubaxa:sortable@1.0.0 +session@1.0.4 +spacebars-compiler@1.0.3 +spacebars@1.0.3 +templating@1.0.9 +tracker@1.0.3 +twbs:bootstrap@3.3.1 +ui@1.0.4 +underscore@1.0.1 +url@1.0.2 +webapp-hashing@1.0.1 +webapp@1.1.4 diff --git a/meteor/example/README.md b/meteor/example/README.md new file mode 100644 index 0000000..e18ca52 --- /dev/null +++ b/meteor/example/README.md @@ -0,0 +1,60 @@ +# RubaXa:Sortable Meteor demo + +This demo showcases the two-way integration between the reorderable list +widget [Sortable](https://github.com/RubaXa/Sortable/) and Meteor.js. Meteor +Mongo collections are updated when items are added, removed or reordered, and +the order is persisted. + +It also shows list grouping and control over what lists can give or receive +elements. You can only drag elements from the list to the left onto the list +to the right. + +## Usage + +The example uses the local package from the checkout, so it needs to wire +up some files (`package.js` and `package.json`). This is done by the handy +run script: + +### Windows + + git clone git@github.com:RubaXa/Sortable.git + cd Sortable + git checkout dev + cd meteor\example + run.bat + +### Elsewhere + + git clone git@github.com:RubaXa/Sortable.git + cd Sortable + git checkout dev + meteor/example./run.sh + +## Prior art + +### Differential + +Differential wrote [a blog post on reorderable lists with +Meteor](differential.com/blog/sortable-lists-in-meteor-using-jquery-ui) and +[jQuery UI Sortable](http://jqueryui.com/sortable/). It served as inspiration +for integrating [rubaxa:sortable](rubaxa.github.io/Sortable/), +which uses the HTML5 native drag&drop API (not without [its +limitations](https://github.com/RubaXa/Sortable/issues/106)). +The reordering method used by the Differential example can lead to data loss +though, because it calculates the new order of a dropped element as the +arithmetic mean of the elements before and after it. This [runs into limitations +of floating point precision](http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible) +in JavaScript after <50 reorderings. + +### Todos animated + +http://todos-dnd-animated.meteor.com/ ([source](https://github.com/nleush/meteor-todos-sortable-animation)) +is based on old Meteor Blaze (back then Spark) API, and won't work with current versions. +It does showcase some neat features, such as animation when collection elements +are reordered by another client. It uses jQuery UI Sortable as well, which lacks +some features vs. rubaxa:Sortable, e.g. text selection within the item. + +## TODO + +* Animation +* Indication that an item is being edited diff --git a/meteor/example/client/define-object-type.css b/meteor/example/client/define-object-type.css new file mode 100644 index 0000000..d67b4b1 --- /dev/null +++ b/meteor/example/client/define-object-type.css @@ -0,0 +1,57 @@ +.glyphicon { + vertical-align: baseline; + font-size: 80%; + margin-right: 0.5em; +} + +[class^="mdi-"], [class*=" mdi-"] { + vertical-align: baseline; + font-size: 90%; + margin-right: 0.4em; +} + +.list-pair { + display: flex; /* use the flexbox model */ + flex-direction: row; +} +.sortable { +/* font-size: 2em;*/ +} + +.sortable.source { + /*background: #9FA8DA;*/ + flex: 0 0 auto; + margin-right: 1em; + cursor: move; + cursor: -webkit-grabbing; +} + +.sortable.target { + /*background: #3F51B5;*/ + flex: 1 1 auto; + margin-left: 1em; +} + +.target .well { + +} + +.sortable-handle { + cursor: move; + cursor: -webkit-grabbing; +} +.sortable-handle.pull-right { + margin-top: 0.3em; +} + +.sortable-ghost { + opacity: 0.6; +} + +/* show the remove button on hover */ +.removable .close { + display: none; +} +.removable:hover .close { + display: block; +} diff --git a/meteor/example/client/define-object-type.html b/meteor/example/client/define-object-type.html new file mode 100644 index 0000000..d852cbc --- /dev/null +++ b/meteor/example/client/define-object-type.html @@ -0,0 +1,94 @@ + + Reactive RubaXa:Sortable for Meteor + + + + {{> navbar}} + +
+ + + {{> typeDefinition}} +
+ + + + + + + + \ No newline at end of file diff --git a/meteor/example/client/define-object-type.js b/meteor/example/client/define-object-type.js new file mode 100644 index 0000000..ad2a19e --- /dev/null +++ b/meteor/example/client/define-object-type.js @@ -0,0 +1,101 @@ +// Define an object type by dragging together attributes + +Template.typeDefinition.helpers({ + types: function () { + return Types.find({}, { sort: { order: 1 } }); + }, + typesOptions: { + sortField: 'order', // defaults to 'order' anyway + group: { + name: 'typeDefinition', + pull: 'clone', + put: false + }, + sort: false // don't allow reordering the types, just the attributes below + }, + + attributes: function () { + return Attributes.find({}, { + sort: { order: 1 }, + transform: function (doc) { + doc.icon = Types.findOne({name: doc.type}).icon; + return doc; + } + }); + }, + attributesOptions: { + group: { + name: 'typeDefinition', + put: true + }, + onAdd: function (event) { + delete event.data._id; // Generate a new id when inserting in the Attributes collection. Otherwise, if we add the same type twice, we'll get an error that the ids are not unique. + delete event.data.icon; + event.data.type = event.data.name; + event.data.name = 'Rename me (double click)' + }, + // event handler for reordering attributes + onSort: function (event) { + console.log('Item %s went from #%d to #%d', + event.data.name, event.oldIndex, event.newIndex + ); + } + } +}); + +Template.sortableItemTarget.events({ + 'dblclick .name': function (event, template) { + // Make the name editable. We should use an existing component, but it's + // in a sorry state - https://github.com/arillo/meteor-x-editable/issues/1 + var name = template.$('.name'); + var input = template.$('input'); + if (input.length) { // jQuery never returns null - http://stackoverflow.com/questions/920236/how-can-i-detect-if-a-selector-returns-null + input.show(); + } else { + input = $(''); + name.after(input); + } + name.hide(); + input.focus(); + }, + 'blur input[type=text]': function (event, template) { + // commit the change to the name, if any + var input = template.$('input'); + input.hide(); + template.$('.name').show(); + // TODO - what is the collection here? We'll hard-code for now. + // https://github.com/meteor/meteor/issues/3303 + if (this.name !== input.val() && this.name !== '') + Attributes.update(this._id, {$set: {name: input.val()}}); + }, + 'keydown input[type=text]': function (event, template) { + if (event.which === 27) { + // ESC - discard edits and keep existing value + template.$('input').val(this.name); + event.preventDefault(); + event.target.blur(); + } else if (event.which === 13) { + // ENTER + event.preventDefault(); + event.target.blur(); + } + } +}); + +// you can add events to all Sortable template instances +Template.sortable.events({ + 'click .close': function (event, template) { + // `this` is the data context set by the enclosing block helper (#each, here) + template.collection.remove(this._id); + // custom code, working on a specific collection + if (Attributes.find().count() === 0) { + Meteor.setTimeout(function () { + Attributes.insert({ + name: 'Not nice to delete the entire list! Add some attributes instead.', + type: 'String', + order: 0 + }) + }, 1000); + } + } +}); diff --git a/meteor/example/model.js b/meteor/example/model.js new file mode 100644 index 0000000..8ae8822 --- /dev/null +++ b/meteor/example/model.js @@ -0,0 +1,2 @@ +Types = new Mongo.Collection('types'); +Attributes = new Mongo.Collection('attributes'); diff --git a/meteor/example/package.json b/meteor/example/package.json new file mode 120000 index 0000000..138a42c --- /dev/null +++ b/meteor/example/package.json @@ -0,0 +1 @@ +../../package.json \ No newline at end of file diff --git a/meteor/example/packages/Sortable b/meteor/example/packages/Sortable new file mode 120000 index 0000000..1b20c9f --- /dev/null +++ b/meteor/example/packages/Sortable @@ -0,0 +1 @@ +../../../ \ No newline at end of file diff --git a/meteor/example/run.bat b/meteor/example/run.bat new file mode 100755 index 0000000..a6d845a --- /dev/null +++ b/meteor/example/run.bat @@ -0,0 +1,4 @@ +mklink ..\..\package.js "meteor/package.js" +mklink package.json "../../package.json" +meteor run +del ..\..\package.js package.json diff --git a/meteor/example/run.sh b/meteor/example/run.sh new file mode 100755 index 0000000..aab44ab --- /dev/null +++ b/meteor/example/run.sh @@ -0,0 +1,15 @@ +# sanity check: make sure we're in the root directory of the example +cd "$( dirname "$0" )" + +# delete temp files even if Ctrl+C is pressed +int_trap() { + echo "Cleaning up..." +} +trap int_trap INT + +ln -s "meteor/package.js" ../../package.js 2>/dev/null +ln -s "../../package.json" package.json 2>/dev/null + +meteor run "$@" + +rm ../../package.js package.json diff --git a/meteor/example/server/fixtures.js b/meteor/example/server/fixtures.js new file mode 100644 index 0000000..617acf9 --- /dev/null +++ b/meteor/example/server/fixtures.js @@ -0,0 +1,75 @@ +Meteor.startup(function () { + if (Types.find().count() === 0) { + [ + { + name: 'String', + icon: '' + }, + { + name: 'Text, multi-line', + icon: '' + }, + { + name: 'Category', + icon: '' + }, + { + name: 'Number', + icon: '' + }, + { + name: 'Date', + icon: '' + }, + { + name: 'Hyperlink', + icon: '' + }, + { + name: 'Image', + icon: '' + }, + { + name: 'Progress', + icon: '' + }, + { + name: 'Duration', + icon: '' + }, + { + name: 'Map address', + icon: '' + }, + { + name: 'Relationship', + icon: '' + } + ].forEach(function (type, i) { + Types.insert({ + name: type.name, + icon: type.icon, + order: i + }); + } + ); + console.log('Initialized attribute types.'); + } + + if (Attributes.find().count() === 0) { + [ + { name: 'Name', type: 'String' }, + { name: 'Created at', type: 'Date' }, + { name: 'Link', type: 'Hyperlink' }, + { name: 'Owner', type: 'Relationship' } + ].forEach(function (attribute, i) { + Attributes.insert({ + name: attribute.name, + type: attribute.type, + order: i + }); + } + ); + console.log('Created sample object type.'); + } +}); diff --git a/meteor/methods.js b/meteor/methods.js new file mode 100644 index 0000000..fe8d834 --- /dev/null +++ b/meteor/methods.js @@ -0,0 +1,20 @@ +'use strict'; + +Meteor.methods({ + /** + * Update the orderField of documents with given ids in a collection, incrementing it by incDec + * @param {String} collectionName - name of the collection to update + * @param {String[]} ids - array of document ids + * @param {String} orderField - the name of the order field, usually "order" + * @param {Number} incDec - pass 1 or -1 + */ + 'rubaxa:sortable/collection-update': function (collectionName, ids, orderField, incDec) { + check(collectionName, String); + check(ids, [String]); + check(orderField, String); + check(incDec, Number); + var selector = {_id: {$in: ids}}, modifier = {$inc: {}}; + modifier.$inc[orderField] = incDec; + Mongo.Collection.get(collectionName).update(selector, modifier, {multi: true}); + } +}); diff --git a/meteor/package.js b/meteor/package.js index 192bca0..823a0ac 100644 --- a/meteor/package.js +++ b/meteor/package.js @@ -1,30 +1,34 @@ // package metadata file for Meteor.js 'use strict'; -var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/sortable/sortable -var where = 'client'; // where to install: 'client', 'server', or ['client', 'server'] +var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/rubaxa/sortable var packageJson = JSON.parse(Npm.require("fs").readFileSync('package.json')); Package.describe({ - name: packageName, - summary: 'Sortable (official): minimalist reorderable drag-and-drop lists on modern browsers and touch devices', - version: packageJson.version, - git: 'https://github.com/RubaXa/Sortable.git' + name: packageName, + summary: 'Sortable: reactive minimalist reorderable drag-and-drop lists on modern browsers and touch devices', + version: packageJson.version, + git: 'https://github.com/RubaXa/Sortable.git', + readme: 'https://github.com/RubaXa/Sortable/blob/master/meteor/README.md' }); Package.onUse(function (api) { - api.versionsFrom('METEOR@0.9.0'); - api.export('Sortable'); - api.addFiles([ - 'Sortable.js' - ], where - ); + api.versionsFrom(['METEOR@0.9.0', 'METEOR@1.0']); + api.use('templating', 'client'); + api.use('dburles:mongo-collection-instances@0.2.5'); // to watch collections getting created + api.export('Sortable'); + api.addFiles([ + 'Sortable.js', + 'meteor/template.html', // the HTML comes first, so reactivize.js can refer to the template in it + 'meteor/reactivize.js' + ], 'client'); + api.addFiles('meteor/methods.js'); // add to both client and server }); Package.onTest(function (api) { - api.use(packageName, where); - api.use('tinytest', where); + api.use(packageName, 'client'); + api.use('tinytest', 'client'); - api.addFiles('meteor/test.js', where); + api.addFiles('meteor/test.js', 'client'); }); diff --git a/meteor/publish.sh b/meteor/publish.sh index 9d31c42..56cd665 100755 --- a/meteor/publish.sh +++ b/meteor/publish.sh @@ -1,72 +1,40 @@ #!/bin/bash -# Publish package on Meteor's Atmosphere.js +# Publish package to Meteor's repository, Atmospherejs.com -# Make sure Meteor is installed, per https://www.meteor.com/install. The curl'ed script is totally safe; takes 2 minutes to read its source and check. +# Make sure Meteor is installed, per https://www.meteor.com/install. +# The curl'ed script is totally safe; takes 2 minutes to read its source and check. type meteor >/dev/null 2>&1 || { curl https://install.meteor.com/ | sh; } # sanity check: make sure we're in the root directory of the checkout cd "$( dirname "$0" )/.." +ALL_EXIT_CODE=0 -function cleanup() { - # we copied the file as package.js, regardless of its original name - rm package.js - - # temporary build files - rm -rf ".build.$PACKAGE_NAME" versions.json -} - - -# publish separately any package*.js files we have, e.g. package.js, package-compat.js +# test any package*.js packages we may have, e.g. package.js, package-compat.js for PACKAGE_FILE in meteor/package*.js; do # Meteor expects package.js to be in the root directory of the checkout, so copy there our package file under that name, temporarily cp $PACKAGE_FILE ./package.js # publish package, creating it if it's the first time we're publishing - PACKAGE_NAME=$(grep -i name $PACKAGE_FILE | head -1 | cut -d "'" -f 2) - ATMOSPHERE_NAME=${PACKAGE_NAME/://} + PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2) echo "Publishing $PACKAGE_NAME..." - # attempt to re-publish the package - the most common operation once the initial release has been made - POTENTIAL_ERROR=$( meteor publish 2>&1 ) - - if [[ $POTENTIAL_ERROR =~ "There is no package named" ]]; then - # actually this is the first time the package is created, so pass the special --create flag and congratulate the maintainer - echo "Thank you for creating the official Meteor package for this library!" - if meteor publish --create; then - echo "Please post the following to https://github.com/raix/Meteor-community-discussions/issues/14: - ---------------------------------------------- 8< -------------------------------------------------------- - -Happy to announce that I've published the official $PACKAGE_NAME to Atmosphere. Please star! -https://atmospherejs.com/$ATMOSPHERE_NAME - ---------------------------------------------- >8 -------------------------------------------------------- - -" - else - echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14" - cleanup - exit 1 - fi + # Attempt to re-publish the package - the most common operation once the initial release has + # been made. If the package name was changed (rare), you'll have to pass the --create flag. + meteor publish "$@"; EXIT_CODE=$? + ALL_EXIT_CODE=$(( $ALL_EXIT_CODE + $EXIT_CODE )) + if (( $EXIT_CODE == 0 )); then + echo "Thanks for releasing a new version. You can see it at" + echo "https://atmospherejs.com/${PACKAGE_NAME/://}" else - if (( $? > 0 )); then - # the error wasn't that the package didn't exist, so we need to ask for help - echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14: ---------------------------------------------- 8< -------------------------------------------------------- -$POTENTIAL_ERROR ---------------------------------------------- >8 -------------------------------------------------------- -" - cleanup - exit 1 - else - echo "Thanks for releasing a new version of $PACKAGE_NAME! You can see it at -https://atmospherejs.com/$ATMOSPHERE_NAME" - fi + echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14" fi - cleanup + # rm the temporary build files and package.js + rm -rf ".build.$PACKAGE_NAME" versions.json package.js done + +exit $ALL_EXIT_CODE diff --git a/meteor/reactivize.js b/meteor/reactivize.js new file mode 100644 index 0000000..34ff501 --- /dev/null +++ b/meteor/reactivize.js @@ -0,0 +1,201 @@ +/* +Make a Sortable reactive by binding it to a Mongo.Collection. +Calls `rubaxa:sortable/collection-update` on the server to update the sortField or affected records. + +TODO: + * supply consecutive values if the `order` field doesn't have any + * .get(DOMElement) - return the Sortable object of a DOMElement + * create a new _id automatically onAdd if the event.from list had pull: 'clone' + * support arrays + * sparse arrays + * tests + * drop onto existing empty lists + * insert back into lists emptied by dropping + * performance on dragging into long list at the beginning + * handle failures on Collection operations, e.g. add callback to .insert + * when adding elements, update ranks just for the half closer to the start/end of the list + * revisit http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible + * reproduce the insidious bug where the list isn't always sorted (fiddle with dragging #1 over #2, then back, then #N before #1) + + */ + +'use strict'; + +Template.sortable.created = function () { + var templateInstance = this; + // `this` is a template instance that can store properties of our choice - http://docs.meteor.com/#/full/template_inst + if (templateInstance.setupDone) return; // paranoid: only run setup once + // this.data is the data context - http://docs.meteor.com/#/full/template_data + // normalize all options into templateInstance.options, and remove them from .data + templateInstance.options = templateInstance.data.options || {}; + Object.keys(templateInstance.data).forEach(function (key) { + if (key === 'options' || key === 'items') return; + templateInstance.options[key] = templateInstance.data[key]; + delete templateInstance.data[key]; + }); + templateInstance.options.sortField = templateInstance.options.sortField || 'order'; + // We can get the collection via the .collection property of the cursor, but changes made that way + // will NOT be sent to the server - https://github.com/meteor/meteor/issues/3271#issuecomment-66656257 + // Thus we need to use dburles:mongo-collection-instances to get a *real* collection + if (templateInstance.data.items && templateInstance.data.items.collection) { + // cursor passed via items=; its .collection works client-only and has a .name property + templateInstance.collectionName = templateInstance.data.items.collection.name; + templateInstance.collection = Mongo.Collection.get(templateInstance.collectionName); + } else if (templateInstance.data.items) { + // collection passed via items=; does NOT have a .name property, but _name + templateInstance.collection = templateInstance.data.items; + templateInstance.collectionName = templateInstance.collection._name; + } else if (templateInstance.data.collection) { + // cursor passed directly + templateInstance.collectionName = templateInstance.data.collection.name; + templateInstance.collection = Mongo.Collection.get(templateInstance.collectionName); + } else { + templateInstance.collection = templateInstance.data; // collection passed directly + templateInstance.collectionName = templateInstance.collection._name; + } + + // TODO if (Array.isArray(templateInstance.collection)) + + // What if user filters some of the items in the cursor, instead of ordering the entire collection? + // Use case: reorder by preference movies of a given genre, a filter within all movies. + // A: Modify all intervening items **that are on the client**, to preserve the overall order + // TODO: update *all* orders via a server method that takes not ids, but start & end elements - mild security risk + delete templateInstance.data.options; + + /** + * When an element was moved, adjust its orders and possibly the order of + * other elements, so as to maintain a consistent and correct order. + * + * There are three approaches to this: + * 1) Using arbitrary precision arithmetic and setting only the order of the moved + * element to the average of the orders of the elements around it - + * http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible + * The downside is that the order field in the DB will increase by one byte every + * time an element is reordered. + * 2) Adjust the orders of the intervening items. This keeps the orders sane (integers) + * but is slower because we have to modify multiple documents. + * TODO: we may be able to update fewer records by only altering the + * order of the records between the newIndex/oldIndex and the start/end of the list. + * 3) Use regular precision arithmetic, but when the difference between the orders of the + * moved item and the one before/after it falls below a certain threshold, adjust + * the order of that other item, and cascade doing so up or down the list. + * This will keep the `order` field constant in size, and will only occasionally + * require updating the `order` of other records. + * + * For now, we use approach #2. + * + * @param {String} itemId - the _id of the item that was moved + * @param {Number} orderPrevItem - the order of the item before it, or null + * @param {Number} orderNextItem - the order of the item after it, or null + */ + templateInstance.adjustOrders = function adjustOrders(itemId, orderPrevItem, orderNextItem) { + var orderField = templateInstance.options.sortField; + var selector = {}, modifier = {$set: {}}; + var ids = []; + var startOrder = templateInstance.collection.findOne(itemId)[orderField]; + if (orderPrevItem !== null) { + // Element has a previous sibling, therefore it was moved down in the list. + // Decrease the order of intervening elements. + selector[orderField] = {$lte: orderPrevItem, $gt: startOrder}; + ids = _.pluck(templateInstance.collection.find(selector, {fields: {_id: 1}}).fetch(), '_id'); + Meteor.call('rubaxa:sortable/collection-update', templateInstance.collectionName, ids, orderField, -1); + + // Set the order of the dropped element to the order of its predecessor, whose order was decreased + modifier.$set[orderField] = orderPrevItem; + } else { + // element moved up the list, increase order of intervening elements + selector[orderField] = {$gte: orderNextItem, $lt: startOrder}; + ids = _.pluck(templateInstance.collection.find(selector, {fields: {_id: 1}}).fetch(), '_id'); + Meteor.call('rubaxa:sortable/collection-update', templateInstance.collectionName, ids, orderField, 1); + + // Set the order of the dropped element to the order of its successor, whose order was increased + modifier.$set[orderField] = orderNextItem; + } + templateInstance.collection.update(itemId, modifier); + }; + + templateInstance.setupDone = true; +}; + + +Template.sortable.rendered = function () { + var templateInstance = this; + var orderField = templateInstance.options.sortField; + + // sorting was changed within the list + var optionsOnUpdate = templateInstance.options.onUpdate; + templateInstance.options.onUpdate = function sortableUpdate(/**Event*/event) { + var itemEl = event.item; // dragged HTMLElement + event.data = Blaze.getData(itemEl); + if (event.newIndex < event.oldIndex) { + // Element moved up in the list. The dropped element has a next sibling for sure. + var orderNextItem = Blaze.getData(itemEl.nextElementSibling)[orderField]; + templateInstance.adjustOrders(event.data._id, null, orderNextItem); + } else if (event.newIndex > event.oldIndex) { + // Element moved down in the list. The dropped element has a previous sibling for sure. + var orderPrevItem = Blaze.getData(itemEl.previousElementSibling)[orderField]; + templateInstance.adjustOrders(event.data._id, orderPrevItem, null); + } else { + // do nothing - drag and drop in the same location + } + if (optionsOnUpdate) optionsOnUpdate(event); + }; + + // element was added from another list + var optionsOnAdd = templateInstance.options.onAdd; + templateInstance.options.onAdd = function sortableAdd(/**Event*/event) { + var itemEl = event.item; // dragged HTMLElement + event.data = Blaze.getData(itemEl); + // let the user decorate the object with additional properties before insertion + if (optionsOnAdd) optionsOnAdd(event); + + // Insert the new element at the end of the list and move it where it was dropped. + // We could insert it at the beginning, but that would lead to negative orders. + var sortSpecifier = {}; sortSpecifier[orderField] = -1; + event.data.order = templateInstance.collection.findOne({}, { sort: sortSpecifier, limit: 1 }).order + 1; + // TODO: this can obviously be optimized by setting the order directly as the arithmetic average, with the caveats described above + var newElementId = templateInstance.collection.insert(event.data); + event.data._id = newElementId; + if (itemEl.nextElementSibling) { + var orderNextItem = Blaze.getData(itemEl.nextElementSibling)[orderField]; + templateInstance.adjustOrders(newElementId, null, orderNextItem); + } else { + // do nothing - inserted after the last element + } + // remove the dropped HTMLElement from the list because we have inserted it in the collection, which will update the template + itemEl.parentElement.removeChild(itemEl); + }; + + // element was removed by dragging into another list + var optionsOnRemove = templateInstance.options.onRemove; + templateInstance.options.onRemove = function sortableRemove(/**Event*/event) { + var itemEl = event.item; // dragged HTMLElement + event.data = Blaze.getData(itemEl); + // don't remove from the collection if group.pull is clone or false + if (typeof templateInstance.options.group === 'undefined' + || typeof templateInstance.options.group.pull === 'undefined' + || templateInstance.options.group.pull === true + ) templateInstance.collection.remove(event.data._id); + if (optionsOnRemove) optionsOnRemove(event); + }; + + // just compute the `data` context + ['onStart', 'onEnd', 'onSort', 'onFilter'].forEach(function (eventHandler) { + if (templateInstance.options[eventHandler]) { + var userEventHandler = templateInstance.options[eventHandler]; + templateInstance.options[eventHandler] = function (/**Event*/event) { + var itemEl = event.item; // dragged HTMLElement + event.data = Blaze.getData(itemEl); + userEventHandler(event); + }; + } + }); + + templateInstance.sortable = Sortable.create(templateInstance.firstNode.parentElement, templateInstance.options); + // TODO make the object accessible, e.g. via Sortable.getSortableById() or some such +}; + + +Template.sortable.destroyed = function () { + this.sortable.destroy(); +}; diff --git a/meteor/runtests.sh b/meteor/runtests.sh index 400eee9..94aaecf 100755 --- a/meteor/runtests.sh +++ b/meteor/runtests.sh @@ -1,37 +1,46 @@ #!/bin/sh # Test Meteor package before publishing to Atmospherejs.com -# Make sure Meteor is installed, per https://www.meteor.com/install. The curl'ed script is totally safe; takes 2 minutes to read its source and check. +# Make sure Meteor is installed, per https://www.meteor.com/install. +# The curl'ed script is totally safe; takes 2 minutes to read its source and check. type meteor >/dev/null 2>&1 || { curl https://install.meteor.com/ | sh; } # sanity check: make sure we're in the root directory of the checkout cd "$( dirname "$0" )/.." -# run tests and delete the temporary package.js even if Ctrl+C is pressed + +# delete the temporary files even if Ctrl+C is pressed int_trap() { - echo - printf "Tests interrupted. Hopefully you verified in the browser that tests pass?\n\n" + printf "\nTests interrupted. Cleaning up...\n\n" } - trap int_trap INT -# test any package*.js packages we may have, e.g. package.js, package-compat.js -for PACKAGE_FILE in meteor/package*.js; do - PACKAGE_NAME=$(grep -i name $PACKAGE_FILE | head -1 | cut -d "'" -f 2) +ALL_EXIT_CODE=0 - echo "Testing $PACKAGE_NAME..." +# test any package*.js packages we may have, e.g. package.js, package-standalone.js +for PACKAGE_FILE in meteor/package*.js; do - # Meteor expects package.js to be in the root directory of the checkout, so copy there our package file under that name, temporarily + # Meteor expects package.js in the root dir of the checkout, so copy there our package file under that name, temporarily cp $PACKAGE_FILE ./package.js - # provide an invalid MONGO_URL so Meteor doesn't bog us down with an empty Mongo database - MONGO_URL=mongodb:// meteor test-packages ./ + PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2) - rm -rf ".build.$PACKAGE_NAME" - rm -rf ".build.local-test:$PACKAGE_NAME" - rm versions.json 2>/dev/null + echo "### Testing $PACKAGE_NAME..." - rm package.js + # provide an invalid MONGO_URL so Meteor doesn't bog us down with an empty Mongo database + if [ $# -gt 0 ]; then + # interpret any parameter to mean we want an interactive test + MONGO_URL=mongodb:// meteor test-packages ./ + else + # automated/CI test with phantomjs + ./node_modules/.bin/spacejam --mongo-url mongodb:// test-packages ./ + ALL_EXIT_CODES=$(( $ALL_EXIT_CODES + $? )) + fi + + # delete temporary build files and package.js + rm -rf .build.* versions.json package.js done + +exit $ALL_EXIT_CODES diff --git a/meteor/template.html b/meteor/template.html new file mode 100644 index 0000000..3923d3d --- /dev/null +++ b/meteor/template.html @@ -0,0 +1,5 @@ + diff --git a/ng-sortable.js b/ng-sortable.js index 3d3175b..12b9388 100644 --- a/ng-sortable.js +++ b/ng-sortable.js @@ -3,7 +3,7 @@ * @licence MIT */ angular.module('ng-sortable', []) - .constant('$version', '0.3.1') + .constant('$version', '0.3.2') .directive('ngSortable', ['$parse', function ($parse) { 'use strict'; @@ -28,9 +28,6 @@ angular.module('ng-sortable', []) }, items: function () { return itemsExpr(scope); - }, - upd: function () { - itemsExpr.assign(scope, this.items()); } }; } @@ -66,14 +63,12 @@ angular.module('ng-sortable', []) removed = prevItems.splice(oldIndex, 1)[0]; items.splice(newIndex, 0, removed); - prevSource.upd(); evt.from.appendChild(evt.item); // revert element } else { items.splice(newIndex, 0, items.splice(oldIndex, 1)[0]); } - source.upd(); scope.$apply(); } diff --git a/package.json b/package.json index 913e40a..cade1db 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "sortablejs", "exportName": "Sortable", - "version": "0.7.3", + "version": "1.0.0", "devDependencies": { "grunt": "*", "grunt-version": "*", - "grunt-shell": "*", + "grunt-exec": "*", "grunt-contrib-jshint": "0.9.2", - "grunt-contrib-uglify": "*" + "grunt-contrib-uglify": "*", + "spacejam": "*" }, - "description": "Minimalist library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery.", + "description": "Minimalist JavaScript library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery. Supports AngularJS and any CSS library, e.g. Bootstrap.", "main": "Sortable.js", "scripts": { "test": "grunt"