From 20ed2ca73c8b02355449d53938ee3be2bbdd4d6f Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Wed, 17 Dec 2014 10:31:53 -0800 Subject: [PATCH 01/11] Two-way integration with Meteor collections --- Gruntfile.js | 13 +- README.md | 3 +- meteor/README.md | 95 ++++++++- meteor/example/.meteor/.gitignore | 1 + meteor/example/.meteor/packages | 11 + meteor/example/.meteor/release | 1 + meteor/example/client/define-object-type.css | 57 +++++ meteor/example/client/define-object-type.html | 50 +++++ meteor/example/client/define-object-type.js | 78 +++++++ meteor/example/model.js | 2 + meteor/example/package.json | 1 + meteor/example/packages/Sortable | 1 + meteor/example/run.bat | 4 + meteor/example/run.sh | 15 ++ meteor/example/server/fixtures.js | 75 +++++++ meteor/methods.js | 20 ++ meteor/package.js | 34 +-- meteor/publish.sh | 68 ++---- meteor/reactivize.js | 201 ++++++++++++++++++ meteor/runtests.sh | 41 ++-- meteor/template.html | 5 + package.json | 5 +- 22 files changed, 681 insertions(+), 100 deletions(-) create mode 100644 meteor/example/.meteor/.gitignore create mode 100644 meteor/example/.meteor/packages create mode 100644 meteor/example/.meteor/release create mode 100644 meteor/example/client/define-object-type.css create mode 100644 meteor/example/client/define-object-type.html create mode 100644 meteor/example/client/define-object-type.js create mode 100644 meteor/example/model.js create mode 120000 meteor/example/package.json create mode 120000 meteor/example/packages/Sortable create mode 100755 meteor/example/run.bat create mode 100755 meteor/example/run.sh create mode 100644 meteor/example/server/fixtures.js create mode 100644 meteor/methods.js create mode 100644 meteor/reactivize.js create mode 100644 meteor/template.html 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..fc2e4ff 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 diff --git a/meteor/README.md b/meteor/README.md index 49c1118..a9cf729 100644 --- a/meteor/README.md +++ b/meteor/README.md @@ -1,26 +1,105 @@ -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..26940a1 --- /dev/null +++ b/meteor/example/.meteor/packages @@ -0,0 +1,11 @@ +# 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 +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/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..f8c23d2 --- /dev/null +++ b/meteor/example/client/define-object-type.html @@ -0,0 +1,50 @@ + + RubaXa:Sortable Demo + + + +
+ + + {{> typeDefinition}} +
+ + + + + diff --git a/meteor/example/client/define-object-type.js b/meteor/example/client/define-object-type.js new file mode 100644 index 0000000..9060cbd --- /dev/null +++ b/meteor/example/client/define-object-type.js @@ -0,0 +1,78 @@ +// 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' + }, + // event handler for reordering attributes + onSort: function (event) { + console.log('Moved object %d from %d to %d', + event.data.order, 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': function (event, template) { + // commit the change to the name + 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 + Attributes.update(this._id, {$set: {name: input.val()}}); + } +}); + +// 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); + } +}); 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..0d93b80 --- /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 +ln -s "../../package.json" package.json + +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/package.json b/package.json index 913e40a..2d5710e 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "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.", "main": "Sortable.js", From cd37a0d1d411f373ebeb0c2125a423dc7cfddd74 Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Wed, 17 Dec 2014 10:31:53 -0800 Subject: [PATCH 02/11] Two-way integration with Meteor collections --- Gruntfile.js | 13 +- README.md | 3 +- meteor/README.md | 95 ++++++++- meteor/example/.meteor/.gitignore | 1 + meteor/example/.meteor/packages | 12 ++ meteor/example/.meteor/release | 1 + meteor/example/client/define-object-type.css | 57 +++++ meteor/example/client/define-object-type.html | 50 +++++ meteor/example/client/define-object-type.js | 78 +++++++ meteor/example/model.js | 2 + meteor/example/package.json | 1 + meteor/example/packages/Sortable | 1 + meteor/example/run.bat | 4 + meteor/example/run.sh | 15 ++ meteor/example/server/fixtures.js | 75 +++++++ meteor/methods.js | 20 ++ meteor/package.js | 34 +-- meteor/publish.sh | 68 ++---- meteor/reactivize.js | 201 ++++++++++++++++++ meteor/runtests.sh | 41 ++-- meteor/template.html | 5 + package.json | 5 +- 22 files changed, 682 insertions(+), 100 deletions(-) create mode 100644 meteor/example/.meteor/.gitignore create mode 100644 meteor/example/.meteor/packages create mode 100644 meteor/example/.meteor/release create mode 100644 meteor/example/client/define-object-type.css create mode 100644 meteor/example/client/define-object-type.html create mode 100644 meteor/example/client/define-object-type.js create mode 100644 meteor/example/model.js create mode 120000 meteor/example/package.json create mode 120000 meteor/example/packages/Sortable create mode 100755 meteor/example/run.bat create mode 100755 meteor/example/run.sh create mode 100644 meteor/example/server/fixtures.js create mode 100644 meteor/methods.js create mode 100644 meteor/reactivize.js create mode 100644 meteor/template.html 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..fc2e4ff 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 diff --git a/meteor/README.md b/meteor/README.md index 49c1118..a9cf729 100644 --- a/meteor/README.md +++ b/meteor/README.md @@ -1,26 +1,105 @@ -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/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..f8c23d2 --- /dev/null +++ b/meteor/example/client/define-object-type.html @@ -0,0 +1,50 @@ + + RubaXa:Sortable Demo + + + +
+ + + {{> typeDefinition}} +
+ + + + + diff --git a/meteor/example/client/define-object-type.js b/meteor/example/client/define-object-type.js new file mode 100644 index 0000000..9060cbd --- /dev/null +++ b/meteor/example/client/define-object-type.js @@ -0,0 +1,78 @@ +// 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' + }, + // event handler for reordering attributes + onSort: function (event) { + console.log('Moved object %d from %d to %d', + event.data.order, 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': function (event, template) { + // commit the change to the name + 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 + Attributes.update(this._id, {$set: {name: input.val()}}); + } +}); + +// 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); + } +}); 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..0d93b80 --- /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 +ln -s "../../package.json" package.json + +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/package.json b/package.json index 913e40a..2d5710e 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "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.", "main": "Sortable.js", From 5361c1ebc71f7f73eef560f53e5fc371ca1cce56 Mon Sep 17 00:00:00 2001 From: RubaXa Date: Wed, 17 Dec 2014 22:32:35 +0300 Subject: [PATCH 03/11] * description --- index.html | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 5f8151d..4e755d4 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Sortable. No jQuery. - + diff --git a/package.json b/package.json index 913e40a..c645ce4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "grunt-contrib-jshint": "0.9.2", "grunt-contrib-uglify": "*" }, - "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" From 32f2f43c08207d14cff82965fcdc455b0f4ff5fe Mon Sep 17 00:00:00 2001 From: RubaXa Date: Wed, 17 Dec 2014 22:32:51 +0300 Subject: [PATCH 04/11] - upd --- ng-sortable.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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(); } From d8e70a0026543ac0a919df3148b70af2ab3f7669 Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Wed, 17 Dec 2014 11:58:54 -0800 Subject: [PATCH 05/11] Published 1.0 to Atmosphre + update example to it --- meteor/example/.meteor/versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meteor/example/.meteor/versions b/meteor/example/.meteor/versions index 8bbfe6a..ce300af 100644 --- a/meteor/example/.meteor/versions +++ b/meteor/example/.meteor/versions @@ -42,7 +42,7 @@ reactive-var@1.0.3 reload@1.1.1 retry@1.0.1 routepolicy@1.0.2 -rubaxa:sortable@0.7.3 +rubaxa:sortable@1.0.0 session@1.0.4 spacebars-compiler@1.0.3 spacebars@1.0.3 From e573d59bdac25d183ad989f8a23c839fceb52c34 Mon Sep 17 00:00:00 2001 From: Lebedev Konstantin Date: Wed, 17 Dec 2014 23:41:31 +0300 Subject: [PATCH 06/11] * typo --- meteor/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/meteor/README.md b/meteor/README.md index a9cf729..163223b 100644 --- a/meteor/README.md +++ b/meteor/README.md @@ -77,6 +77,7 @@ the data context in `event.data`. You can access `event.data.order` this way: ```handlebars {{sortable items=players options=playersOptions}} +``` ```js Template.myTemplate.helpers({ From b2fe4710125b98366d030f9b2e12e84bd8e5ee0d Mon Sep 17 00:00:00 2001 From: RubaXa Date: Thu, 18 Dec 2014 00:16:05 +0300 Subject: [PATCH 07/11] #131: v1.0, done --- README.md | 21 ++++++++++++++++++++- Sortable.js | 4 ++-- Sortable.min.js | 4 ++-- bower.json | 2 +- component.json | 2 +- index.html | 2 +- package.json | 2 +- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fc2e4ff..c84cfc5 100644 --- a/README.md +++ b/README.md @@ -388,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 @@ -401,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..ab53768 100644 --- a/Sortable.js +++ b/Sortable.js @@ -641,7 +641,7 @@ Sortable.active = null; // Save sorting - this.save() + this.save(); } }, @@ -975,7 +975,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 4e755d4..d01170e 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Sortable. No jQuery. - + diff --git a/package.json b/package.json index 9be9df5..cade1db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sortablejs", "exportName": "Sortable", - "version": "0.7.3", + "version": "1.0.0", "devDependencies": { "grunt": "*", "grunt-version": "*", From 281f5fc7f8c5109e233f2bda8ac34c5a0d1e2f33 Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Wed, 17 Dec 2014 13:07:41 -0800 Subject: [PATCH 08/11] Improve Meteor demo --- meteor/example/README.md | 60 +++++++++++++++++++ meteor/example/client/define-object-type.html | 52 ++++++++++++++-- meteor/example/client/define-object-type.js | 32 +++++++--- meteor/example/package.json | 1 + meteor/example/run.sh | 4 +- 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 meteor/example/README.md create mode 120000 meteor/example/package.json 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.html b/meteor/example/client/define-object-type.html index f8c23d2..d852cbc 100644 --- a/meteor/example/client/define-object-type.html +++ b/meteor/example/client/define-object-type.html @@ -1,11 +1,15 @@ - RubaXa:Sortable Demo + Reactive RubaXa:Sortable for Meteor + {{> navbar}} +
{{> typeDefinition}} @@ -26,7 +30,7 @@
- {{#sortable items=attributes animation="0" handle=".sortable-handle" ghostClass="sortable-ghost" options=attributesOptions}} + {{#sortable items=attributes animation="100" handle=".sortable-handle" ghostClass="sortable-ghost" options=attributesOptions}} {{> sortableItemTarget}} {{/sortable}}
@@ -34,7 +38,6 @@ - + + + \ No newline at end of file diff --git a/meteor/example/client/define-object-type.js b/meteor/example/client/define-object-type.js index 7850b14..ad2a19e 100644 --- a/meteor/example/client/define-object-type.js +++ b/meteor/example/client/define-object-type.js @@ -32,12 +32,12 @@ Template.typeDefinition.helpers({ 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' + event.data.name = 'Rename me (double click)' }, // event handler for reordering attributes onSort: function (event) { - console.log('Moved object %d from %d to %d', - event.data.order, event.oldIndex, event.newIndex + console.log('Item %s went from #%d to #%d', + event.data.name, event.oldIndex, event.newIndex ); } } @@ -59,17 +59,23 @@ Template.sortableItemTarget.events({ input.focus(); }, 'blur input[type=text]': function (event, template) { - // commit the change to the name + // 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 - Attributes.update(this._id, {$set: {name: input.val()}}); + if (this.name !== input.val() && this.name !== '') + Attributes.update(this._id, {$set: {name: input.val()}}); }, - 'keydown input[type=text]': function(event) { - // ESC or ENTER - if (event.which === 27 || event.which === 13) { + '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(); } @@ -81,5 +87,15 @@ 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/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/run.sh b/meteor/example/run.sh index 0d93b80..aab44ab 100755 --- a/meteor/example/run.sh +++ b/meteor/example/run.sh @@ -7,8 +7,8 @@ int_trap() { } trap int_trap INT -ln -s "meteor/package.js" ../../package.js -ln -s "../../package.json" package.json +ln -s "meteor/package.js" ../../package.js 2>/dev/null +ln -s "../../package.json" package.json 2>/dev/null meteor run "$@" From a22f9c1fc784b8dd92a2d3cb049e337a0410fa44 Mon Sep 17 00:00:00 2001 From: Roel van Duijnhoven Date: Thu, 18 Dec 2014 12:18:02 +0100 Subject: [PATCH 09/11] Sort event does not always fire In the dev branch the `sort` event is not always correctly called on the receiving list. This IS working in the latest released branch. The fix is trivial and included in this PR. The case is illustrated in this JsBin: http://jsbin.com/muxojulevo/3/edit. It occurs whenever an item is picked up from a list and dropped in a nested container. --- Sortable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sortable.js b/Sortable.js index ab53768..58c92f9 100644 --- a/Sortable.js +++ b/Sortable.js @@ -601,7 +601,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); From 9a9670ec4c6bb61dc8beb0acbb0d7b09109e17e3 Mon Sep 17 00:00:00 2001 From: RubaXa Date: Fri, 19 Dec 2014 10:10:02 +0300 Subject: [PATCH 10/11] #184: * 'start' event --- Sortable.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/Sortable.js b/Sortable.js index ab53768..117e6fc 100644 --- a/Sortable.js +++ b/Sortable.js @@ -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; } }, @@ -370,8 +368,6 @@ _on(document, 'drop', this); } - setTimeout(this._applyEffects, 0); - scrollEl = options.scroll; if (scrollEl === true) { @@ -386,6 +382,8 @@ /* jshint boss:true */ } while (scrollEl = scrollEl.parentNode); } + + setTimeout(this._dragStarted, 0); }, _onDrag: _throttle(function (/**Event*/evt) { @@ -621,7 +619,7 @@ } // Drag end event - _dispatchEvent(rootEl, 'end', dragEl, rootEl, startIndex, newIndex); + Sortable.active && _dispatchEvent(rootEl, 'end', dragEl, rootEl, startIndex, newIndex); } // Set NULL From 8ee92ddd32722240343d7e2e53ba27e2b149b1aa Mon Sep 17 00:00:00 2001 From: RubaXa Date: Fri, 19 Dec 2014 17:39:24 +0300 Subject: [PATCH 11/11] #185: fixed group & touch --- Sortable.js | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/Sortable.js b/Sortable.js index ea9d522..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 }; } @@ -286,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', '');