]> git.za3k.com Git - flowy.git/commitdiff
Add support for deleting lines, breaking lines in half, reset button
authorZachary Vance <vanceza@gmail.com>
Tue, 12 May 2015 02:54:26 +0000 (19:54 -0700)
committerZachary Vance <vanceza@gmail.com>
Tue, 12 May 2015 02:54:26 +0000 (19:54 -0700)
dist/flowy.js
dist/flowy.unwrapped.js
dist/index.html
src/index.html
src/models/flowyDoc.js
src/models/todo.js
src/views/app.js
src/views/todo.js

index acf7799a2abcff742ae269aae8aa393713085659..35b3122cd2f96e215d52695b1402dfbe2b4b38c3 100644 (file)
@@ -6,8 +6,10 @@ var TodoView = Backbone.View.extend({
     //"click > .checkbox": "toggleComplete",
     "input > .text": "textChange",
     "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end
+    "keydown > .text": "backspace",
   },
   initialize: function() {
+    this.childViewPositions = [];
     this.listenTo(this.model, "change", this.render);
     this.listenTo(this.model, 'destroy', this.remove);
   },
@@ -16,8 +18,14 @@ var TodoView = Backbone.View.extend({
     e.stopPropagation();
     return this;
   },
-  startEditingText: function() {
-    this.$el.find("> .text").focus();
+  startEditingText: function(options) {
+    options = options || {};
+    if (!options.atEnd) {
+        this.$el.find("> .text").focus();
+    } else {
+        this.$el.find("> .text").focus();
+        this.$el.find("> .text").val(this.$el.find("> .text"));
+    }
     return this;
   },
   stopEditingText: function() {
@@ -27,50 +35,68 @@ var TodoView = Backbone.View.extend({
   decodeText: function(encodedText) {
     return $("<div/>").html(encodedText).text();
   },
+  backspace: function(e) {
+    // TODO: Handle backspace at beginning of non-empty line
+    if (e.keyCode !== 8 /* backspace */ && e.keyCode !== 46 /* delete */) {
+        return;
+    }
+    if (this.model.get("text") !== "") {
+        return;
+    }
+    e.preventDefault();
+    // TODO: Focus end of previous node
+    //var previousNode = this.model.previousNode(this.model.collection);
+    this.model.remove(this.model.collection);
+    //var previousNodeView = this.collectionView.getViewFor(previousNode);
+    //var previousNodeView.startEditingText({"atEnd":true});
+  },
   textChange: function(e) {
-    // TODO: Handle backspace on totally empty bullet and beginning of line
+    var collection = this.model.collection;
     var lines = $(e.target).html().split(/<br\\?>/);
     if (lines.length === 0) {
         console.log("unexpected number of lines in textChange");
     } else if (lines.length === 1) {
         // Normal edit
         this.model.setText(this.decodeText(lines[0]));
-    } else if (lines.length === 2) {
-        // If there's a line break in the text, they pressed "Enter", and it's more complicated.
-        // We're here going to duplicate Workflowy's behavior:
-        // If the line break is at the beginning
-        //  - Make a sibling node before, focus the first, empty node.
-        // If the line break is in the middle
-        //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
-        // If the line break is at the end
-        //  - If there are no children, make a sibling node after. Focus the second node.
-        //  - If there are children, make the new node the first child. Focus the second node.
-        // NOTE: Copy-paste is overridden so there can't be more than one line break.
-        // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
-        if (lines[1].length === 0) { // Line break at end
-            console.log("TODO: Line breaks not implemented <end>");
-            this.model.setText(this.decodeText(lines[0]));
-            //var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}); // Child or not depending on whether this has children
-            this.stopEditingText();
-            //emptyAfter.view.startEditingText();
-            // Focus emptyAfter
-        } else if (lines[0].length === 0) { // Line break at beginning
-            console.log("TODO: Line breaks not implemented <start>");
-            //var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText();
-            //emptyBefore.view.startEditingText();
-            // Focus emptyBefore
-        } else { // Line break in middle
-            console.log("TODO: Line breaks not implemented <middle>");
-            //var newNode = this.model.addTodoBefore({text: this.decodeText(lines[1])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText(); // For re-render
-            this.startEditingText();
-        }
+
+    // If there's a line break in the text, they pressed "Enter", and it's more complicated.
+    // We're here going to duplicate Workflowy's behavior:
+    // If the line break is at the beginning
+    //  - Make a sibling node before, focus the first, empty node.
+    // If the line break is in the middle
+    //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
+    // If the line break is at the end
+    //  - If there are no children, make a sibling node after. Focus the second node.
+    //  - If there are children, make the new node the first child. Focus the second node.
+    // NOTE: Copy-paste is overridden so there can't be more than one line break.
+    // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
+
+     } else if ((lines.length === 2 && lines[1].length === 0) || (lines.length === 3 && lines[1].length === 0 && lines[2].length === 0)) { // Line break at end
+        console.log("TODO: Line breaks not implemented <end>");
+        this.model.setText(this.decodeText(lines[0]));
+        var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}, collection); // Child or not depending on whether this has children
+        this.stopEditingText();
+        //emptyAfter.view.startEditingText();
+        // TODO: Focus emptyAfter
+     } else if (lines.length === 2 && lines[0].length === 0) { // Line break at beginning
+        console.log("TODO: Line breaks not implemented <start>");
+        var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText();
+        //emptyBefore.view.startEditingText();
+        // TODO: Focus emptyBefore
+    } else if (lines.length === 2) { // Line break in middle
+        console.log("TODO: Line breaks not implemented <middle>");
+        var newNode = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText(); // For re-render
+        this.startEditingText();
+        // Keep focus on current node (second half)
     } else if (lines.length > 2) {
+        console.log(lines.length);
+        console.log($(e.target).html());
+        console.log(lines);
         console.log("TODO: Support copy-paste in textChange");
-
     } else {
         console.log("unexpected number of lines in textChange");
     }
@@ -78,10 +104,19 @@ var TodoView = Backbone.View.extend({
   },
   template: "<div class=\"text\" contenteditable=\"plaintext-only\">{{text}}</div><div class=\"bullets\"></div>",
   addChild: function(el, position) {
-     if(typeof position === 'undefined') {
+     if(typeof position === 'undefined' || position === -1) {
         console.log("TodoView:addChild called without a position");
+        return this;
+     }
+     // Position was given as an index into the children array, not relative to inserted elements in the DOM.
+     // So, convert to index relative to things inserted so far
+     var relativePosition = _.chain(this.childViewPositions).where(function(x) { return x<= position; }).size().value();
+     this.childViewPositions.push(position);
+     if (relativePosition === 0) {
+        this.$el.find("> .bullets").prepend(el);
+     } else {
+        this.$el.find("> .bullets > *").eq(relativePosition-1).after(el);
      }
-     this.$el.find("> .bullets").append(el);
      return this;
   },
   render: function() {
@@ -131,14 +166,64 @@ var TodoModel = Backbone.Model.extend({
     isParentLoaded: function(collection, collectionView) {
         return this.isTopLevel(collection) ? true : (this.getParent(collection) && collectionView.getView(this.getParent(collection)));
     },
-    addTodoBefore: function(todo) {
+    findChild: function(childId) {
+        return this.get("bullets").indexOf(childId);
+    },
+    insertChild: function(childTodoModel, index) {
+        childTodoModel.set("parent", this.id);
+        var bullets = this.get("bullets");
+        bullets.splice(index, 0, childTodoModel.id);
+        this.set("bullets", bullets);
+        return this;
+    },
+    removeChild: function(childTodoModel, collection) {
+        if (childTodoModel.get("bullets").length > 0) {
+            console.log("Cannot delete node with children.");
+            return;
+        }
+        var index = this.findChild(childTodoModel.id);
+        if (index < 0) {
+            console.log("Tried to delete node from incorrect parent.");
+            return;
+        }
+        var parent = this;
+        childTodoModel.destroy({"success": function(model, response) {
+            var bullets = parent.get("bullets");
+            bullets.splice(index, 1);
+            parent.save({"bullets":bullets});
+        }});
+        return this;
+    },
+    remove: function(collection) {
+        this.getParent(collection).removeChild(this, collection);
+    },
+    addTodoBefore: function(todo, collection) {
         // Todo always goes as the previous sibling
-        // TODO
+        var parent = this.getParent(collection);
+        var todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+        parent.insertChild(todoModel, parent.findChild(this.id));
+        parent.save();
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     },
-    addTodoAfter: function(todo) {
+    addTodoAfter: function(todo, collection) {
         // If there are children, the todo goes as the first child.
         // Otherwise, the todo goes as the next sibling
-        // TODO
+        var todoModel;
+        if (this.get("bullets").length > 0) {
+            todoModel = collection.create(_.extend(todo, {"parent": this.id}));
+            this.insertChild(todoModel, 0);
+            this.save();
+        } else {
+            parent = this.getParent(collection);
+            todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+            parent.insertChild(todoModel, parent.findChild(this.id)+1);
+            parent.save();
+        }
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     }
 });
 
@@ -154,11 +239,7 @@ var FlowyDocModel = Backbone.Collection.extend({
     },
     model: TodoModel,
     localStorage: new Backbone.LocalStorage("todos-backbone"),
-    nextOrder: function() {
-        if (!this.length) return 1;
-        return this.last().get('order') + 1;
-    },
-    comparator: 'order',
+    comparator: 'id',
 });
 
 /**
@@ -170,6 +251,12 @@ var FlowyDocModel = Backbone.Collection.extend({
 var testTodos = [
     new TodoModel({
         parent: null,
+        id: 0,
+        text: "Root (will be invisible in final)",
+        bullets: [1, 5],
+    }),
+    new TodoModel({
+        parent: 0,
         id: 1,
         text: "Daily todos",
         bullets: [2,3,4],
@@ -192,7 +279,7 @@ var testTodos = [
         text: "Eat green eggs and ham",
     }),
     new TodoModel({
-        parent: null,
+        parent: 0,
         id: 5,
         text: "To do this year",
         collapsed: true,
@@ -219,9 +306,20 @@ var AppView = Backbone.View.extend({
     el: $("#todo-app"),
     initialize: function(options) {
         options = _.defaults({}, options, appDefaults);
-        this.listenTo(todos, 'add', this.addOne);
-        this.listenTo(todos, 'reset', this.addAll);
-        this.list = options.list || new FlowDocModel();
+        this.list = options.list || new FlowyDocModel();
+        this.listenTo(this.list, 'add', this.addOne);
+        this.listenTo(this.list, 'reset', this.addAll);
+        var self = this;
+        this.$("#reset-button").on("click", function() {
+            var model;
+            for (var i = self.list.length - 1; i >= 0; i--) {
+                self.list.at(i).destroy();
+            }
+            self.list.reset(testTodos);
+            self.list.each(function(e) {
+                e.save();
+            });
+        });
         this.views = {}; // A list of views for each element in the collection
         this.list.fetch();
     },
@@ -236,15 +334,15 @@ var AppView = Backbone.View.extend({
             console.log("View rendered more than once for todo #" + todo.id);
             return;
         }
-        var view = new TodoView({model: todo});
+        var view = new TodoView({model: todo, collection: this.list});
         if (todo.isTopLevel(this.list)) {
             this.setView(todo, view);
             this.$("#todo-list").append(view.render().el);
-        } else if (todo.isParentLoaded(this.list, this)) {
+        } else if (todo.isParentLoaded(this.list, this) && todo.getParent(this.list).findChild(todo.id) >= 0 /* because move/insert is nonatomic */) {
             this.setView(todo, view);
             var parent = todo.getParent(this.list);
             var parentView = this.getView(parent);
-            parentView.addChild(view.render().el);
+            parentView.addChild(view.render().el, parent.findChild(todo.id));
         }
 
         // Find unrendered descendents and render them, too.
@@ -262,6 +360,8 @@ var AppView = Backbone.View.extend({
         return this.views[model.id];
     },
     addAll: function() {
+        this.views = {};
+        this.$("#todo-list").html(null);
         this.list.each(this.addOne, this);
     },
 });
index 9cb41b7b8ac86ab16976dc8daf04e296e6c3fa18..a4d114f741b06bff5479e317dc3c145ab180b9f7 100644 (file)
@@ -5,8 +5,10 @@ var TodoView = Backbone.View.extend({
     //"click > .checkbox": "toggleComplete",
     "input > .text": "textChange",
     "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end
+    "keydown > .text": "backspace",
   },
   initialize: function() {
+    this.childViewPositions = [];
     this.listenTo(this.model, "change", this.render);
     this.listenTo(this.model, 'destroy', this.remove);
   },
@@ -15,8 +17,14 @@ var TodoView = Backbone.View.extend({
     e.stopPropagation();
     return this;
   },
-  startEditingText: function() {
-    this.$el.find("> .text").focus();
+  startEditingText: function(options) {
+    options = options || {};
+    if (!options.atEnd) {
+        this.$el.find("> .text").focus();
+    } else {
+        this.$el.find("> .text").focus();
+        this.$el.find("> .text").val(this.$el.find("> .text"));
+    }
     return this;
   },
   stopEditingText: function() {
@@ -26,50 +34,68 @@ var TodoView = Backbone.View.extend({
   decodeText: function(encodedText) {
     return $("<div/>").html(encodedText).text();
   },
+  backspace: function(e) {
+    // TODO: Handle backspace at beginning of non-empty line
+    if (e.keyCode !== 8 /* backspace */ && e.keyCode !== 46 /* delete */) {
+        return;
+    }
+    if (this.model.get("text") !== "") {
+        return;
+    }
+    e.preventDefault();
+    // TODO: Focus end of previous node
+    //var previousNode = this.model.previousNode(this.model.collection);
+    this.model.remove(this.model.collection);
+    //var previousNodeView = this.collectionView.getViewFor(previousNode);
+    //var previousNodeView.startEditingText({"atEnd":true});
+  },
   textChange: function(e) {
-    // TODO: Handle backspace on totally empty bullet and beginning of line
+    var collection = this.model.collection;
     var lines = $(e.target).html().split(/<br\\?>/);
     if (lines.length === 0) {
         console.log("unexpected number of lines in textChange");
     } else if (lines.length === 1) {
         // Normal edit
         this.model.setText(this.decodeText(lines[0]));
-    } else if (lines.length === 2) {
-        // If there's a line break in the text, they pressed "Enter", and it's more complicated.
-        // We're here going to duplicate Workflowy's behavior:
-        // If the line break is at the beginning
-        //  - Make a sibling node before, focus the first, empty node.
-        // If the line break is in the middle
-        //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
-        // If the line break is at the end
-        //  - If there are no children, make a sibling node after. Focus the second node.
-        //  - If there are children, make the new node the first child. Focus the second node.
-        // NOTE: Copy-paste is overridden so there can't be more than one line break.
-        // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
-        if (lines[1].length === 0) { // Line break at end
-            console.log("TODO: Line breaks not implemented <end>");
-            this.model.setText(this.decodeText(lines[0]));
-            //var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}); // Child or not depending on whether this has children
-            this.stopEditingText();
-            //emptyAfter.view.startEditingText();
-            // Focus emptyAfter
-        } else if (lines[0].length === 0) { // Line break at beginning
-            console.log("TODO: Line breaks not implemented <start>");
-            //var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText();
-            //emptyBefore.view.startEditingText();
-            // Focus emptyBefore
-        } else { // Line break in middle
-            console.log("TODO: Line breaks not implemented <middle>");
-            //var newNode = this.model.addTodoBefore({text: this.decodeText(lines[1])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText(); // For re-render
-            this.startEditingText();
-        }
+
+    // If there's a line break in the text, they pressed "Enter", and it's more complicated.
+    // We're here going to duplicate Workflowy's behavior:
+    // If the line break is at the beginning
+    //  - Make a sibling node before, focus the first, empty node.
+    // If the line break is in the middle
+    //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
+    // If the line break is at the end
+    //  - If there are no children, make a sibling node after. Focus the second node.
+    //  - If there are children, make the new node the first child. Focus the second node.
+    // NOTE: Copy-paste is overridden so there can't be more than one line break.
+    // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
+
+     } else if ((lines.length === 2 && lines[1].length === 0) || (lines.length === 3 && lines[1].length === 0 && lines[2].length === 0)) { // Line break at end
+        console.log("TODO: Line breaks not implemented <end>");
+        this.model.setText(this.decodeText(lines[0]));
+        var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}, collection); // Child or not depending on whether this has children
+        this.stopEditingText();
+        //emptyAfter.view.startEditingText();
+        // TODO: Focus emptyAfter
+     } else if (lines.length === 2 && lines[0].length === 0) { // Line break at beginning
+        console.log("TODO: Line breaks not implemented <start>");
+        var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText();
+        //emptyBefore.view.startEditingText();
+        // TODO: Focus emptyBefore
+    } else if (lines.length === 2) { // Line break in middle
+        console.log("TODO: Line breaks not implemented <middle>");
+        var newNode = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText(); // For re-render
+        this.startEditingText();
+        // Keep focus on current node (second half)
     } else if (lines.length > 2) {
+        console.log(lines.length);
+        console.log($(e.target).html());
+        console.log(lines);
         console.log("TODO: Support copy-paste in textChange");
-
     } else {
         console.log("unexpected number of lines in textChange");
     }
@@ -77,10 +103,19 @@ var TodoView = Backbone.View.extend({
   },
   template: "<div class=\"text\" contenteditable=\"plaintext-only\">{{text}}</div><div class=\"bullets\"></div>",
   addChild: function(el, position) {
-     if(typeof position === 'undefined') {
+     if(typeof position === 'undefined' || position === -1) {
         console.log("TodoView:addChild called without a position");
+        return this;
+     }
+     // Position was given as an index into the children array, not relative to inserted elements in the DOM.
+     // So, convert to index relative to things inserted so far
+     var relativePosition = _.chain(this.childViewPositions).where(function(x) { return x<= position; }).size().value();
+     this.childViewPositions.push(position);
+     if (relativePosition === 0) {
+        this.$el.find("> .bullets").prepend(el);
+     } else {
+        this.$el.find("> .bullets > *").eq(relativePosition-1).after(el);
      }
-     this.$el.find("> .bullets").append(el);
      return this;
   },
   render: function() {
@@ -130,14 +165,64 @@ var TodoModel = Backbone.Model.extend({
     isParentLoaded: function(collection, collectionView) {
         return this.isTopLevel(collection) ? true : (this.getParent(collection) && collectionView.getView(this.getParent(collection)));
     },
-    addTodoBefore: function(todo) {
+    findChild: function(childId) {
+        return this.get("bullets").indexOf(childId);
+    },
+    insertChild: function(childTodoModel, index) {
+        childTodoModel.set("parent", this.id);
+        var bullets = this.get("bullets");
+        bullets.splice(index, 0, childTodoModel.id);
+        this.set("bullets", bullets);
+        return this;
+    },
+    removeChild: function(childTodoModel, collection) {
+        if (childTodoModel.get("bullets").length > 0) {
+            console.log("Cannot delete node with children.");
+            return;
+        }
+        var index = this.findChild(childTodoModel.id);
+        if (index < 0) {
+            console.log("Tried to delete node from incorrect parent.");
+            return;
+        }
+        var parent = this;
+        childTodoModel.destroy({"success": function(model, response) {
+            var bullets = parent.get("bullets");
+            bullets.splice(index, 1);
+            parent.save({"bullets":bullets});
+        }});
+        return this;
+    },
+    remove: function(collection) {
+        this.getParent(collection).removeChild(this, collection);
+    },
+    addTodoBefore: function(todo, collection) {
         // Todo always goes as the previous sibling
-        // TODO
+        var parent = this.getParent(collection);
+        var todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+        parent.insertChild(todoModel, parent.findChild(this.id));
+        parent.save();
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     },
-    addTodoAfter: function(todo) {
+    addTodoAfter: function(todo, collection) {
         // If there are children, the todo goes as the first child.
         // Otherwise, the todo goes as the next sibling
-        // TODO
+        var todoModel;
+        if (this.get("bullets").length > 0) {
+            todoModel = collection.create(_.extend(todo, {"parent": this.id}));
+            this.insertChild(todoModel, 0);
+            this.save();
+        } else {
+            parent = this.getParent(collection);
+            todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+            parent.insertChild(todoModel, parent.findChild(this.id)+1);
+            parent.save();
+        }
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     }
 });
 
@@ -153,11 +238,7 @@ var FlowyDocModel = Backbone.Collection.extend({
     },
     model: TodoModel,
     localStorage: new Backbone.LocalStorage("todos-backbone"),
-    nextOrder: function() {
-        if (!this.length) return 1;
-        return this.last().get('order') + 1;
-    },
-    comparator: 'order',
+    comparator: 'id',
 });
 
 /**
@@ -169,6 +250,12 @@ var FlowyDocModel = Backbone.Collection.extend({
 var testTodos = [
     new TodoModel({
         parent: null,
+        id: 0,
+        text: "Root (will be invisible in final)",
+        bullets: [1, 5],
+    }),
+    new TodoModel({
+        parent: 0,
         id: 1,
         text: "Daily todos",
         bullets: [2,3,4],
@@ -191,7 +278,7 @@ var testTodos = [
         text: "Eat green eggs and ham",
     }),
     new TodoModel({
-        parent: null,
+        parent: 0,
         id: 5,
         text: "To do this year",
         collapsed: true,
@@ -218,9 +305,20 @@ var AppView = Backbone.View.extend({
     el: $("#todo-app"),
     initialize: function(options) {
         options = _.defaults({}, options, appDefaults);
-        this.listenTo(todos, 'add', this.addOne);
-        this.listenTo(todos, 'reset', this.addAll);
-        this.list = options.list || new FlowDocModel();
+        this.list = options.list || new FlowyDocModel();
+        this.listenTo(this.list, 'add', this.addOne);
+        this.listenTo(this.list, 'reset', this.addAll);
+        var self = this;
+        this.$("#reset-button").on("click", function() {
+            var model;
+            for (var i = self.list.length - 1; i >= 0; i--) {
+                self.list.at(i).destroy();
+            }
+            self.list.reset(testTodos);
+            self.list.each(function(e) {
+                e.save();
+            });
+        });
         this.views = {}; // A list of views for each element in the collection
         this.list.fetch();
     },
@@ -235,15 +333,15 @@ var AppView = Backbone.View.extend({
             console.log("View rendered more than once for todo #" + todo.id);
             return;
         }
-        var view = new TodoView({model: todo});
+        var view = new TodoView({model: todo, collection: this.list});
         if (todo.isTopLevel(this.list)) {
             this.setView(todo, view);
             this.$("#todo-list").append(view.render().el);
-        } else if (todo.isParentLoaded(this.list, this)) {
+        } else if (todo.isParentLoaded(this.list, this) && todo.getParent(this.list).findChild(todo.id) >= 0 /* because move/insert is nonatomic */) {
             this.setView(todo, view);
             var parent = todo.getParent(this.list);
             var parentView = this.getView(parent);
-            parentView.addChild(view.render().el);
+            parentView.addChild(view.render().el, parent.findChild(todo.id));
         }
 
         // Find unrendered descendents and render them, too.
@@ -261,6 +359,8 @@ var AppView = Backbone.View.extend({
         return this.views[model.id];
     },
     addAll: function() {
+        this.views = {};
+        this.$("#todo-list").html(null);
         this.list.each(this.addOne, this);
     },
 });
index ea6086861e54b11e081e0a9eb3c02f3673e839c6..6ec7bcbf444a0fe81baccfa43f350c5252364f07 100644 (file)
@@ -10,6 +10,7 @@
     <div id="todo-list">
 
     </div>
+    <button id="reset-button">Everyone heard me say "reset button", right?</button>
 </div>
 </body>
 </html>
index ea6086861e54b11e081e0a9eb3c02f3673e839c6..6ec7bcbf444a0fe81baccfa43f350c5252364f07 100644 (file)
@@ -10,6 +10,7 @@
     <div id="todo-list">
 
     </div>
+    <button id="reset-button">Everyone heard me say "reset button", right?</button>
 </div>
 </body>
 </html>
index 0e8508e9337c9c4cdcf526648ffdfe3c70c7df07..42c18cd2975c583c2349037f29fa48d5b600e209 100644 (file)
@@ -10,9 +10,5 @@ var FlowyDocModel = Backbone.Collection.extend({
     },
     model: TodoModel,
     localStorage: new Backbone.LocalStorage("todos-backbone"),
-    nextOrder: function() {
-        if (!this.length) return 1;
-        return this.last().get('order') + 1;
-    },
-    comparator: 'order',
+    comparator: 'id',
 });
index b04d9226c1991d2949eb3c926917209a91cd97ce..9a78d119fb835c9c80c01e80140172ab0ee0906e 100644 (file)
@@ -31,13 +31,63 @@ var TodoModel = Backbone.Model.extend({
     isParentLoaded: function(collection, collectionView) {
         return this.isTopLevel(collection) ? true : (this.getParent(collection) && collectionView.getView(this.getParent(collection)));
     },
-    addTodoBefore: function(todo) {
+    findChild: function(childId) {
+        return this.get("bullets").indexOf(childId);
+    },
+    insertChild: function(childTodoModel, index) {
+        childTodoModel.set("parent", this.id);
+        var bullets = this.get("bullets");
+        bullets.splice(index, 0, childTodoModel.id);
+        this.set("bullets", bullets);
+        return this;
+    },
+    removeChild: function(childTodoModel, collection) {
+        if (childTodoModel.get("bullets").length > 0) {
+            console.log("Cannot delete node with children.");
+            return;
+        }
+        var index = this.findChild(childTodoModel.id);
+        if (index < 0) {
+            console.log("Tried to delete node from incorrect parent.");
+            return;
+        }
+        var parent = this;
+        childTodoModel.destroy({"success": function(model, response) {
+            var bullets = parent.get("bullets");
+            bullets.splice(index, 1);
+            parent.save({"bullets":bullets});
+        }});
+        return this;
+    },
+    remove: function(collection) {
+        this.getParent(collection).removeChild(this, collection);
+    },
+    addTodoBefore: function(todo, collection) {
         // Todo always goes as the previous sibling
-        // TODO
+        var parent = this.getParent(collection);
+        var todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+        parent.insertChild(todoModel, parent.findChild(this.id));
+        parent.save();
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     },
-    addTodoAfter: function(todo) {
+    addTodoAfter: function(todo, collection) {
         // If there are children, the todo goes as the first child.
         // Otherwise, the todo goes as the next sibling
-        // TODO
+        var todoModel;
+        if (this.get("bullets").length > 0) {
+            todoModel = collection.create(_.extend(todo, {"parent": this.id}));
+            this.insertChild(todoModel, 0);
+            this.save();
+        } else {
+            parent = this.getParent(collection);
+            todoModel = collection.create(_.extend(todo, {"parent": parent.id}));
+            parent.insertChild(todoModel, parent.findChild(this.id)+1);
+            parent.save();
+        }
+
+        collection.trigger("add", todoModel);
+        return todoModel;
     }
 });
index 064ce7910307ba5b1f9032fa7b4577ffccabda88..636cb22c5e63d07ec6c782bac431891ab7315d45 100644 (file)
@@ -7,6 +7,12 @@
 var testTodos = [
     new TodoModel({
         parent: null,
+        id: 0,
+        text: "Root (will be invisible in final)",
+        bullets: [1, 5],
+    }),
+    new TodoModel({
+        parent: 0,
         id: 1,
         text: "Daily todos",
         bullets: [2,3,4],
@@ -29,7 +35,7 @@ var testTodos = [
         text: "Eat green eggs and ham",
     }),
     new TodoModel({
-        parent: null,
+        parent: 0,
         id: 5,
         text: "To do this year",
         collapsed: true,
@@ -56,9 +62,20 @@ var AppView = Backbone.View.extend({
     el: $("#todo-app"),
     initialize: function(options) {
         options = _.defaults({}, options, appDefaults);
-        this.listenTo(todos, 'add', this.addOne);
-        this.listenTo(todos, 'reset', this.addAll);
-        this.list = options.list || new FlowDocModel();
+        this.list = options.list || new FlowyDocModel();
+        this.listenTo(this.list, 'add', this.addOne);
+        this.listenTo(this.list, 'reset', this.addAll);
+        var self = this;
+        this.$("#reset-button").on("click", function() {
+            var model;
+            for (var i = self.list.length - 1; i >= 0; i--) {
+                self.list.at(i).destroy();
+            }
+            self.list.reset(testTodos);
+            self.list.each(function(e) {
+                e.save();
+            });
+        });
         this.views = {}; // A list of views for each element in the collection
         this.list.fetch();
     },
@@ -73,15 +90,15 @@ var AppView = Backbone.View.extend({
             console.log("View rendered more than once for todo #" + todo.id);
             return;
         }
-        var view = new TodoView({model: todo});
+        var view = new TodoView({model: todo, collection: this.list});
         if (todo.isTopLevel(this.list)) {
             this.setView(todo, view);
             this.$("#todo-list").append(view.render().el);
-        } else if (todo.isParentLoaded(this.list, this)) {
+        } else if (todo.isParentLoaded(this.list, this) && todo.getParent(this.list).findChild(todo.id) >= 0 /* because move/insert is nonatomic */) {
             this.setView(todo, view);
             var parent = todo.getParent(this.list);
             var parentView = this.getView(parent);
-            parentView.addChild(view.render().el);
+            parentView.addChild(view.render().el, parent.findChild(todo.id));
         }
 
         // Find unrendered descendents and render them, too.
@@ -99,6 +116,8 @@ var AppView = Backbone.View.extend({
         return this.views[model.id];
     },
     addAll: function() {
+        this.views = {};
+        this.$("#todo-list").html(null);
         this.list.each(this.addOne, this);
     },
 });
index f4caa524344470b366180d707840c0d38f69c828..48a45011b399d6e1bd1c5ecfc0af20f5ffbfd853 100644 (file)
@@ -5,8 +5,10 @@ var TodoView = Backbone.View.extend({
     //"click > .checkbox": "toggleComplete",
     "input > .text": "textChange",
     "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end
+    "keydown > .text": "backspace",
   },
   initialize: function() {
+    this.childViewPositions = [];
     this.listenTo(this.model, "change", this.render);
     this.listenTo(this.model, 'destroy', this.remove);
   },
@@ -15,8 +17,14 @@ var TodoView = Backbone.View.extend({
     e.stopPropagation();
     return this;
   },
-  startEditingText: function() {
-    this.$el.find("> .text").focus();
+  startEditingText: function(options) {
+    options = options || {};
+    if (!options.atEnd) {
+        this.$el.find("> .text").focus();
+    } else {
+        this.$el.find("> .text").focus();
+        this.$el.find("> .text").val(this.$el.find("> .text"));
+    }
     return this;
   },
   stopEditingText: function() {
@@ -26,50 +34,68 @@ var TodoView = Backbone.View.extend({
   decodeText: function(encodedText) {
     return $("<div/>").html(encodedText).text();
   },
+  backspace: function(e) {
+    // TODO: Handle backspace at beginning of non-empty line
+    if (e.keyCode !== 8 /* backspace */ && e.keyCode !== 46 /* delete */) {
+        return;
+    }
+    if (this.model.get("text") !== "") {
+        return;
+    }
+    e.preventDefault();
+    // TODO: Focus end of previous node
+    //var previousNode = this.model.previousNode(this.model.collection);
+    this.model.remove(this.model.collection);
+    //var previousNodeView = this.collectionView.getViewFor(previousNode);
+    //var previousNodeView.startEditingText({"atEnd":true});
+  },
   textChange: function(e) {
-    // TODO: Handle backspace on totally empty bullet and beginning of line
+    var collection = this.model.collection;
     var lines = $(e.target).html().split(/<br\\?>/);
     if (lines.length === 0) {
         console.log("unexpected number of lines in textChange");
     } else if (lines.length === 1) {
         // Normal edit
         this.model.setText(this.decodeText(lines[0]));
-    } else if (lines.length === 2) {
-        // If there's a line break in the text, they pressed "Enter", and it's more complicated.
-        // We're here going to duplicate Workflowy's behavior:
-        // If the line break is at the beginning
-        //  - Make a sibling node before, focus the first, empty node.
-        // If the line break is in the middle
-        //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
-        // If the line break is at the end
-        //  - If there are no children, make a sibling node after. Focus the second node.
-        //  - If there are children, make the new node the first child. Focus the second node.
-        // NOTE: Copy-paste is overridden so there can't be more than one line break.
-        // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
-        if (lines[1].length === 0) { // Line break at end
-            console.log("TODO: Line breaks not implemented <end>");
-            this.model.setText(this.decodeText(lines[0]));
-            //var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}); // Child or not depending on whether this has children
-            this.stopEditingText();
-            //emptyAfter.view.startEditingText();
-            // Focus emptyAfter
-        } else if (lines[0].length === 0) { // Line break at beginning
-            console.log("TODO: Line breaks not implemented <start>");
-            //var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText();
-            //emptyBefore.view.startEditingText();
-            // Focus emptyBefore
-        } else { // Line break in middle
-            console.log("TODO: Line breaks not implemented <middle>");
-            //var newNode = this.model.addTodoBefore({text: this.decodeText(lines[1])});
-            this.model.setText(this.decodeText(lines[1]));
-            this.stopEditingText(); // For re-render
-            this.startEditingText();
-        }
+
+    // If there's a line break in the text, they pressed "Enter", and it's more complicated.
+    // We're here going to duplicate Workflowy's behavior:
+    // If the line break is at the beginning
+    //  - Make a sibling node before, focus the first, empty node.
+    // If the line break is in the middle
+    //  - Make a new sibling node after, and move all the children to that sibling node (basically, make a new sibling node BEFORE). Focus the second node.
+    // If the line break is at the end
+    //  - If there are no children, make a sibling node after. Focus the second node.
+    //  - If there are children, make the new node the first child. Focus the second node.
+    // NOTE: Copy-paste is overridden so there can't be more than one line break.
+    // NOTE: Shift-enter is overridden and handled seperately, to allow "notes" spanning multiple lines.
+
+     } else if ((lines.length === 2 && lines[1].length === 0) || (lines.length === 3 && lines[1].length === 0 && lines[2].length === 0)) { // Line break at end
+        console.log("TODO: Line breaks not implemented <end>");
+        this.model.setText(this.decodeText(lines[0]));
+        var emptyAfter = this.model.addTodoAfter({text: this.decodeText(lines[1])}, collection); // Child or not depending on whether this has children
+        this.stopEditingText();
+        //emptyAfter.view.startEditingText();
+        // TODO: Focus emptyAfter
+     } else if (lines.length === 2 && lines[0].length === 0) { // Line break at beginning
+        console.log("TODO: Line breaks not implemented <start>");
+        var emptyBefore = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText();
+        //emptyBefore.view.startEditingText();
+        // TODO: Focus emptyBefore
+    } else if (lines.length === 2) { // Line break in middle
+        console.log("TODO: Line breaks not implemented <middle>");
+        var newNode = this.model.addTodoBefore({text: this.decodeText(lines[0])}, collection);
+        this.model.setText(this.decodeText(lines[1]));
+        this.stopEditingText(); // For re-render
+        this.startEditingText();
+        // Keep focus on current node (second half)
     } else if (lines.length > 2) {
+        console.log(lines.length);
+        console.log($(e.target).html());
+        console.log(lines);
         console.log("TODO: Support copy-paste in textChange");
-
     } else {
         console.log("unexpected number of lines in textChange");
     }
@@ -77,10 +103,19 @@ var TodoView = Backbone.View.extend({
   },
   template: "<div class=\"text\" contenteditable=\"plaintext-only\">{{text}}</div><div class=\"bullets\"></div>",
   addChild: function(el, position) {
-     if(typeof position === 'undefined') {
+     if(typeof position === 'undefined' || position === -1) {
         console.log("TodoView:addChild called without a position");
+        return this;
+     }
+     // Position was given as an index into the children array, not relative to inserted elements in the DOM.
+     // So, convert to index relative to things inserted so far
+     var relativePosition = _.chain(this.childViewPositions).where(function(x) { return x<= position; }).size().value();
+     this.childViewPositions.push(position);
+     if (relativePosition === 0) {
+        this.$el.find("> .bullets").prepend(el);
+     } else {
+        this.$el.find("> .bullets > *").eq(relativePosition-1).after(el);
      }
-     this.$el.find("> .bullets").append(el);
      return this;
   },
   render: function() {