From: Zachary Vance Date: Tue, 12 May 2015 02:54:26 +0000 (-0700) Subject: Add support for deleting lines, breaking lines in half, reset button X-Git-Url: https://git.za3k.com/?a=commitdiff_plain;h=3d6cef99350057aa38c35af69c0808728b488db4;p=flowy.git Add support for deleting lines, breaking lines in half, reset button --- diff --git a/dist/flowy.js b/dist/flowy.js index acf7799..35b3122 100644 --- a/dist/flowy.js +++ b/dist/flowy.js @@ -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 $("
").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(//); 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 "); - 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 "); - //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 "); - //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 "); + 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 "); + 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 "); + 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: "
{{text}}
", 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); }, }); diff --git a/dist/flowy.unwrapped.js b/dist/flowy.unwrapped.js index 9cb41b7..a4d114f 100644 --- a/dist/flowy.unwrapped.js +++ b/dist/flowy.unwrapped.js @@ -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 $("
").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(//); 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 "); - 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 "); - //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 "); - //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 "); + 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 "); + 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 "); + 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: "
{{text}}
", 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); }, }); diff --git a/dist/index.html b/dist/index.html index ea60868..6ec7bcb 100644 --- a/dist/index.html +++ b/dist/index.html @@ -10,6 +10,7 @@
+
diff --git a/src/index.html b/src/index.html index ea60868..6ec7bcb 100644 --- a/src/index.html +++ b/src/index.html @@ -10,6 +10,7 @@
+
diff --git a/src/models/flowyDoc.js b/src/models/flowyDoc.js index 0e8508e..42c18cd 100644 --- a/src/models/flowyDoc.js +++ b/src/models/flowyDoc.js @@ -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', }); diff --git a/src/models/todo.js b/src/models/todo.js index b04d922..9a78d11 100644 --- a/src/models/todo.js +++ b/src/models/todo.js @@ -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; } }); diff --git a/src/views/app.js b/src/views/app.js index 064ce79..636cb22 100644 --- a/src/views/app.js +++ b/src/views/app.js @@ -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); }, }); diff --git a/src/views/todo.js b/src/views/todo.js index f4caa52..48a4501 100644 --- a/src/views/todo.js +++ b/src/views/todo.js @@ -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 $("
").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(//); 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 "); - 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 "); - //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 "); - //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 "); + 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 "); + 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 "); + 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: "
{{text}}
", 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() {