// The widgets for selecting words. This comprises the main part of the UI. Splitter.module('Slot', function(Slot, Splitter, Backbone, M) { var Word = Backbone.Model.extend({ defaults: { text: '', active: false }, activate: function(options) { options || (options = {}); this.collection.activateWord(this, options); }, deactivate: function(options) { options || (options = {}); return this.set({ active: false }, options); }, }); var SynList = Backbone.Collection.extend({ model: Word }); var WordList = Backbone.Collection.extend({ model: Word, url: '/words', activeWord: function() { var word = this.where({ active: true })[0]; if (!word) { word = this.add({ active: true, text: '' }); }; return word; }, activeIndex: function() { return this.indexOf(this.activeWord()); }, preActives: function() { var number = this.length - (this.activeIndex() + 1); return this.last(number); }, postActives: function() { return this.first(this.activeIndex()); }, resetActive: function(text) { var newActiveWord; text = $.trim(text); if (text !== this.lastFetchedText) { newActiveWord = new this.model(); // Don't do anything if the text is invalid. if (newActiveWord.set({ text: text, active: true })) { this.reset(newActiveWord, { silent: true }); this.fetchSynonyms(); } } }, fetchSynonyms: function() { var text = this.activeWord().get('text'); // Don't actually send a request if the text is blank. if (text.length === 0) { this.lastFetchedText = text; this.reset(); return; } $.ajax({ url: '/synonyms/' + encodeURIComponent(text), dataType: 'json', success: _.bind(function(json) { var wordAttrs; // console.log("Fetched synonyms of", text, json.synonyms); wordAttrs = _.map(json.synonyms, function(syn) { return { text: syn, active: false }; }) // The problem with using add is that it causes the // syn lists to re-render loads of times. THus, we must add // silently before triggering the event once. this.add(wordAttrs, { silent: true }); }, this), error: _.bind(function(json) { this.trigger('error', text, json.error); }, this), // This needs to happen on both success and error. complete: _.bind(function(json) { this.lastFetchedText = text; // console.log("Last fetched text set to", this.lastFetchedText); // To clear the synonyms. this.trigger('reset'); }, this) }); }, deactivateAll: function() { this.each(function(word) { word.deactivate({ silent: true }) }); }, activateWord: function(word, options) { options || (options = {}); if (word) { this.deactivateAll(); return word.set({ active: true }, options); }; }, promoteActive: function() { this.changeActiveBy(1); }, demoteActive: function() { this.changeActiveBy(-1); }, changeActiveBy: function(places) { var currentActiveIndex = this.activeIndex(), wordToActivate = this.at(currentActiveIndex + places); // console.log("Changing active by", places, currentActiveIndex, wordToActivate); this.activateWord(wordToActivate); } }); var SlotView = M.Layout.extend({ template: '#slot-layout-template', className: 'slot span4', regions: { postActiveRegion: '.post-active-region', preActiveRegion: '.pre-active-region', activeWordRegion: '.active-word-region' }, events: { 'click a.demote' : 'demoteClicked', 'click a.promote' : 'promoteClicked' }, initialize: function() { var modelId = this.model.id; // Grab the focus when the slot tells us to. this.bindTo(this.model, 'focus', this.takeFocus); // Attach the sub views. this.activeWordView = new ActiveWordView({ collection: this.model.get('words'), }); this.activeWordView.on('show', function() { // Add an arbitrary integer to the tabindex to leave // room for in-between tab indexes later. // NOTE: I can't move this into the sub view bacause I don't // have access to the slot id there. this.setTabIndex(modelId + 11); }); }, renderInactives: function() { var preActives = new SynonymListView({ collection: this.model.get('preActives') }); this.preActiveRegion.show(preActives); var postActives = new SynonymListView({ collection: this.model.get('postActives') }); this.postActiveRegion.show(postActives); }, renderActive: function() { this.activeWordRegion.show(this.activeWordView); }, onRender: function() { // Add the slot id to the root element. this.$el.attr({ id: 'slot_' + this.model.id }); this.renderActive(); this.renderInactives(); }, takeFocus: function() { this.activeWordView.takeFocus(); }, demoteClicked: function(e) { e.preventDefault(); this.model.demoteActive(); }, promoteClicked: function(e) { e.preventDefault(); this.model.promoteActive(); } }); var Slot = Backbone.Model.extend({ defaults: function() { return { words: new WordList(), preActives: new SynList(), postActives: new SynList() }; }, initialize: function() { this.activeWord().bind('fetch:synonyms', this.fetchSynonyms, this); this.get('words').bind('reset add change:active', this.resetSynLists, this); this.get('words').bind('reset change:active', function(word) { // console.log("Acive component", this.id, this.activeText()); Splitter.vent.trigger('change:activeWord', this.id, this.activeText()); }, this); }, resetSynLists: function() { this.get('preActives').reset(this.get('words').preActives()); this.get('postActives').reset(this.get('words').postActives()); }, activeText: function() { return this.activeWord().get('text'); }, activeWord: function() { return this.get('words').activeWord(); }, promoteActive: function() { this.get('words').promoteActive(); }, demoteActive: function() { this.get('words').demoteActive(); }, }); var SynonymView = M.ItemView.extend({ template: '#synonym-template', tagName: 'li', events: { 'click a' : 'linkClicked' }, serializeData: function() { return { text: this.model.get('text') }; }, linkClicked: function(e) { e.preventDefault(); this.model.activate(); } }); var SynonymListView = M.CollectionView.extend({ itemView: SynonymView, tagName: 'ul', }); var ActiveWordView = M.ItemView.extend({ template: '#active-word-template', tagName: 'form', events: { 'keyup input' : 'keyPressed', 'submit' : 'formSubmitted' }, // Override the initial events method to prevent the // view from re-rendering whenever the collection resets. initialEvents: function() {}, keyPressed: function(e) { switch(e.which) { case 40: this.downPressed(e); break; case 38: this.upPressed(e); break; case 37: this.leftPressed(e); break; case 39: this.rightPressed(e); break; default: this.typingFinished(e); return; } }, ui: { input: 'input' }, cursorPosition: function() { return this.$el.find(this.ui.input).cursorPosition(); }, getText: function() { return this.$el.find(this.ui.input).val(); }, setTabIndex: function(index) { this.$el.find(this.ui.input).attr({ tabIndex: index }); }, leftPressed: function(e) { if (this.cursorPosition() === 0) { this.removeErrors(); Splitter.slotsList.focusLeft(); } }, rightPressed: function(e) { if (this.cursorPosition() === this.getText().length) { this.removeErrors(); Splitter.slotsList.focusRight(); } }, takeFocus: function() { this.$el.find(this.ui.input).select(); }, downPressed: function() { this.collection.demoteActive(); this.takeFocus(); }, upPressed: function(e) { this.collection.promoteActive(); this.takeFocus(); }, initialize: function() { this.typingFinished = _.debounce(this.typingFinished, 500); // Re-render when the user changes the active word. this.bindTo(this.collection, 'change', this.render); // Display invalid when the user enters an invalid word. this.bindTo(this.collection, 'error', this.displayErrors); }, serializeData: function() { return { text: this.collection.activeWord().get('text') }; }, displayErrors: function() { this.$el.find(this.ui.input) .closest('.control-group').addClass('error'); }, removeErrors: function() { this.$el.find(this.ui.input) .closest('.control-group').removeClass('error'); }, typingFinished: function(e) { this.removeErrors(); this.collection.resetActive(this.getText()); }, formSubmitted: function(e) { e.preventDefault(); this.typingFinished(); } }); var SlotsList = Backbone.Collection.extend({ url: '/words', model: Slot, nextSlotId: function() { return this.models.length; }, focusLeft: function() { // HACK: Hard-coded slot id. var leftSlot = this.at(0); if (leftSlot) { leftSlot.trigger('focus'); }; }, focusRight: function() { // HACK: Hard-coded slot id. var rightSlot = this.at(1); if (rightSlot) { rightSlot.trigger('focus'); }; }, // Add a slot to the collection. addSlot: function(component) { var newModel = new this.model({ id: this.nextSlotId() }); this.add(newModel); if (component) { newModel.get('words').resetActive(component); } } }); var SlotsListView = M.CollectionView.extend({ itemView: SlotView, }); Splitter.addInitializer(function(options) { var wordAttrs = options.word || {}, slotsListView; this.slotsList = new SlotsList(); _.each(wordAttrs.components, _.bind(function(component) { this.slotsList.addSlot(component); }, this)); slotsListView = new SlotsListView({ collection: this.slotsList }); Splitter.slotsRegion.show(slotsListView); }); });