Skip to content

Instantly share code, notes, and snippets.

@otherjustin
Forked from dtuite/checkers.js
Created January 29, 2013 21:44
Show Gist options
  • Save otherjustin/4668196 to your computer and use it in GitHub Desktop.
Save otherjustin/4668196 to your computer and use it in GitHub Desktop.

Revisions

  1. David Tuite created this gist Jan 29, 2013.
    63 changes: 63 additions & 0 deletions checkers.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,63 @@
    // Service for checking the availability of a given word

    Splitter.module('Checkers', function(Checkers, Splitter) {

    var LocalChecker = {
    comKeyFor: function(compound) {
    return 'avail/.com/' + compound;
    },

    check: function(compound) {
    return $.jStorage.get(this.comKeyFor(compound));
    },

    store: function(compound, availability) {
    var key = '';

    // Don't want to store error states.
    if (availability === 'available' || availability === 'unavailable') {
    key = this.comKeyFor(compound);
    // Expire the value in 10 mins.
    return $.jStorage.set(key, availability, { TTL: 600000 })
    };

    return false;
    }
    };

    // Class which can check the availability of a word.
    var Checker = {
    check: function(compound, options) {
    options || (options = {});
    // console.log("Checking availability of", compound);
    // No-op if the word is blank.
    if ($.trim(compound).length === 0) { return; };

    var localAvailability = LocalChecker.check(compound);

    if (localAvailability) {
    // console.log("Local availability of", compound + ".com", localAvailability);
    options.success(localAvailability);
    } else {
    $.ajax({
    url: '/availabilities/' + encodeURIComponent(compound),
    dataType: 'json',
    success: function(json) {
    var availability = json.status;
    // console.log("Remote availability of", compound + ".com", availability);
    LocalChecker.store(compound, availability);
    options.success(availability);
    },
    error: options.error
    });
    }
    }
    };

    Checkers.check = function(word, success) {
    Checker.check(word, success);
    };

    // Create a debounced version of the checking method.
    Checkers.debouncedCheck = _.debounce(Checkers.check, 300);
    });
    397 changes: 397 additions & 0 deletions slots.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,397 @@
    // 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);
    });
    });
    22 changes: 22 additions & 0 deletions splitter.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,22 @@
    // This is the top level namespace for the application and some default settings.

    window.Splitter = new Backbone.Marionette.Application();

    Splitter.addRegions({
    slotsRegion: '#slots-region',
    wordRegion: '#word-region'
    });

    // Override Underscore templating style (because of erb).

    _.templateSettings = {
    interpolate: /\{\{\=(.+?)\}\}/g,
    evaluate: /\{\{(.+?)\}\}/g
    };

    $(function(){
    $("body").tooltip({
    selector: '[rel=tooltip]',
    placement: 'bottom'
    });
    });
    129 changes: 129 additions & 0 deletions word.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,129 @@
    // A model and view for showing the availability of the compound word chosen by the user.

    Splitter.module('Word', function(Word, Splitter) {

    var WordModel = Backbone.Model.extend({
    defaults: {
    components: [],
    comAvailability: ''
    },

    compound: function() {
    return this.get('components').join('').replace(/ /g, '');
    },

    updateAvailability: function() {
    // No-op if the same word as last time.
    if (this.lastCheckedCompound === this.compound()) { return; }
    // Set the com availability to waiting because we are
    // in the process of fetching new com availability.
    this.set({ comAvailability: 'waiting' });

    Splitter.Checkers.debouncedCheck(this.compound(), {
    success: _.bind(function(availabilty) {
    this.lastCheckedCompound = this.compound();
    this.set({ comAvailability: availabilty });
    }, this),
    error: _.bind(function(xhr) {
    // console.log("Availabilty checking error", this, arguments);
    this.lastCheckedCompound = this.compound();
    this.set({ comAvailability: 'invalid' });
    }, this)
    });
    },

    isBlank: function() { return this.compound().length === 0; },

    isComAvailable: function() {
    return this.get('comAvailability') === 'available';
    },

    isComUnavailable: function() {
    return this.get('comAvailability') === 'unavailable';
    },

    isComUnknown: function() {
    return this.get('comAvailability') === 'unknown';
    },

    changeComponent: function(index, newWord) {
    var newComponents = _.clone(this.get('components'));
    newComponents[index] = newWord;
    // console.log("Setting new components", newComponents, index, newWord);
    this.set({ components: newComponents });
    this.updateAvailability();
    }
    })

    var WordView = Backbone.Marionette.ItemView.extend({
    template: '#word-template',
    className: 'word',

    initialize: function() {
    this.bindTo(this.model, 'change', this.render, this);
    },

    serializeData: function() {
    return {
    compound: this.model.compound(),
    comAvailability: this.model.get('comAvailability'),
    notBlank: !this.model.isBlank(),
    isComAvailable: this.model.isComAvailable(),
    isComUnavailable: this.model.isComUnavailable(),
    isComUnknown: this.model.isComUnknown(),
    host: this.model.compound() + '.com',

    // Can't figure out how to make templateHelpers work.
    visitLink: function() {
    var localDetails = {},
    templateName = '#visit-link-template',
    mkup;

    if (this.isComUnavailable) {
    localDetails.href = "http://" + this.host;
    mkup = $(templateName).html();
    return _.template(mkup, _.extend(this, localDetails));
    }
    return '';
    },

    purcahseLink: function() {
    var localDetails = {},
    templateName = '#purchase-link-template',
    mkup;

    if (this.isComAvailable) {
    localDetails.href =
    Splitter.DomainRegistrar.affiliateUrlFor(this.host);
    localDetails.registrar = Splitter.domainRegistrar;
    mkup = $(templateName).html();
    return _.template(mkup, _.extend(this, localDetails));
    }
    return '';
    },

    unknownLink: function() {
    if (this.isComUnknown) {
    var templateName = '#unknown-link-template';
    return _.template($(templateName).html(), this);
    }
    return '';
    }
    };
    }
    });

    Splitter.addInitializer(function(options) {
    var wordAttrs = options.word || {},
    wordView;

    this.word = new WordModel(wordAttrs);
    wordView = new WordView({ model: this.word });

    this.vent.on('change:activeWord', function(id, word) {
    Splitter.word.changeComponent(id, word);
    });

    this.wordRegion.show(wordView);
    });
    });