6
\$\begingroup\$

I wrote a simple JavaScript tool to highlight multiple keywords at the same time in some text. It uses different color for each keyword, and also shows the count of matches.

I'm using Backbone.js with the following class structure:

  • App.Model: the main model, keeping track of:

    • the keywords
    • the words in the text and their count
    • the original text
    • the transformed text with highlighted words
  • App.OriginalTab, App.HighlightedTab: View classes, for showing the original text and the highlighted text, respectively, in tabs

  • App.KeywordView: a View class, handling the input of new keywords

  • App.Keyword: a Model class, keeping track of a keyword and its count in the text

  • App.KeywordList: a Collection class, keeping track of the keywords. Changes to the collection trigger updating the highlighted text

  • App.KeywordsView: a View class, to render the list of keywords, highlighted using the same color as the highlight in the text, showing the count, and a button to remove from the list

Implementation:

var App = window.App = {};

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

App.Model = Backbone.Model.extend({
    defaults: {
        original: '',
        keywords: [],
        words: {},
        highlighted: ''
    },
    initialize: function () {
        this.on('change:original', this.onOriginalUpdated, this);
        this.on('change:keywords', this.onKeywordsUpdated, this);
    },
    updateWords: function () {
        var words = {};
        _.each(this.get('original').split(/\W+/), function (word) {
            word = word.toLowerCase();
            words[word] = (words[word] || 0) + 1;
        });
        this.set({words: words});
    },
    updateHighlighted: function () {
        var highlighted = this.escape('original');
        var cnt = 1;
        _.each(this.get('keywords'), function (keyword) {
            var pattern = '\\b' + keyword;
            var cname = 'hlt' + cnt++;
            highlighted = highlighted.replace(new RegExp(pattern, 'gi'), '<span class="' + cname + '">' + keyword + '</span>');
        });
        this.set({highlighted: highlighted});
    },
    onOriginalUpdated: function () {
        this.updateWords();
        this.updateHighlighted();
    },
    onKeywordsUpdated: function () {
        this.updateHighlighted();
    },
    getCount: function (word) {
        var pattern = '\\b' + word;
        var matches = this.get('original').match(new RegExp(pattern, 'gi'));
        return matches ? matches.length : 0;
    }
});

App.Tab = Backbone.View.extend({
    activate: function () {
        var id = this.$el.attr('id');
        var anchor = $('a[href=#' + id + ']');
        anchor.tab('show');
    }
});

App.OriginalTab = App.Tab.extend({
    initialize: function () {
        this.text = this.$('.text');
    },
    fieldToFocus: this.$('.text'),
    events: {
        'blur .text': 'onTextChanged'
    },
    onTextChanged: function () {
        this.model.set({original: this.text.val()});
    }
});

jQuery.fn.selectText = function () {
    var element = this[0];
    var range;
    if (document.body.createTextRange) {
        range = document.body.createTextRange();
        range.moveToElementText(element);
        range.select();
    } else if (window.getSelection) {
        var selection = window.getSelection();
        range = document.createRange();
        range.selectNodeContents(element);
        selection.removeAllRanges();
        selection.addRange(range);
    }
};

App.HighlightedTab = App.Tab.extend({
    template: _.template($('#highlighted-template').html()),
    initialize: function () {
        this.model.bind('change:highlighted', this.render, this);
    },
    render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        var element = this.$('.highlighted');
        this.$('.select').click(function () {
            element.selectText();
        });
        return this;
    }
});

App.KeywordView = Backbone.View.extend({
    tagName: 'tr',
    template: _.template($('#keyword-template').html()),
    events: {
        'dblclick .view': 'edit',
        'click a.destroy': 'clear',
        'keypress .edit': 'updateOnEnter',
        'blur .edit': 'close'
    },
    initialize: function () {
        this.model.bind('change', this.render, this);
        this.model.bind('destroy', this.remove, this);
    },
    render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        this.input = this.$('.edit');
        return this;
    },
    edit: function () {
        this.$el.addClass('editing');
        this.input.focus();
    },
    close: function () {
        var value = this.input.val();
        if (!value) {
            this.clear();
        }
        this.model.set({keyword: value});
        this.$el.removeClass('editing');
    },
    updateOnEnter: function (e) {
        if (e.keyCode == 13) {
            this.close();
        }
    },
    clear: function () {
        this.model.clear();
    }
});

App.Keyword = Backbone.Model.extend({
    defaults: function () {
        return {
            keyword: 'empty keyword...',
            count: 0,
            index: 1
        };
    },
    initialize: function () {
        App.model.on('change:words', this.refreshCount, this);
        this.refreshCount();
    },
    refreshCount: function () {
        var keyword = this.get('keyword');
        this.set({count: App.model.getCount(keyword)});
    },
    clear: function () {
        this.destroy();
    }
});

App.KeywordList = Backbone.Collection.extend({
    model: App.Keyword,
    localStorage: new Store('highlighter-backbone'),
    initialize: function () {
        this.on('add', this.onChange, this);
        this.on('remove', this.onChange, this);
        this.on('reset', this.onChange, this);
    },
    onChange: function () {
        var keywords = this.pluck('keyword');
        App.model.set({keywords: keywords});
        App.highlightedTab.activate();
    }
});

App.KeywordsView = Backbone.View.extend({
    el: '#keywords',
    events: {
        'keypress .keyword': 'createOnEnter',
        'click th a.destroy': 'clear'
    },
    initialize: function (options) {
        this.keywords = options.list;
        this.input = this.$('.keyword');
        this.keywords.bind('add', this.add, this);
        this.keywords.bind('reset', this.reset, this);
        this.keywords.fetch();
        if (this.keywords.length) {
            this.keywords.each(this.add);
        }
        else {
            this.create('lorem');
            this.create('ipsum');
            this.create('dolor');
        }
    },
    add: function (keyword) {
        var view = new App.KeywordView({model: keyword});
        this.$('#keyword-list').append(view.render().el);
    },
    reset: function () {
        this.$('#keyword-list').empty();
    },
    createOnEnter: function (e) {
        if (e.keyCode != 13) {
            return;
        }
        if (!this.input.val()) {
            return;
        }
        var keyword = this.input.val();
        this.create(keyword);
        this.input.val('');
    },
    create: function (keyword) {
        var index = this.keywords.length + 1;
        this.keywords.create({keyword: keyword, index: index});
    },
    clear: function () {
        // todo: isn't there a better way?
        var i = 0;
        var maxiter = 10;
        while (true) {
            this.keywords.invoke('destroy');
            if (!this.keywords.length || ++i > maxiter) {
                break;
            }
        }
    }
});

function onDomReady() {
    App.model = new App.Model();

    App.originalTab = new App.OriginalTab({
        el: $('#original-tab'),
        model: App.model
    });

    App.highlightedTab = new App.HighlightedTab({
        el: $('#highlighted-tab'),
        model: App.model
    });

    App.keywordList = new App.KeywordList();
    App.keywordsView = new App.KeywordsView({
        model: App.model,
        list: App.keywordList
    });

    $('#reset').click(function () {
        App.keywordsView.clear();
        App.originalTab.text.val('');
        App.originalTab.activate();
        App.originalTab.text.focus();
    });

    // other initialization
    App.keywordsView.input.focus();
    App.model.set({original: App.originalTab.text.text()});
}

$(function () {
    onDomReady();
});

Is there a better way to do this? Or simpler? Am I using Backbone.js correctly? Can I organize my classes better?

You can see the code in action here.

The full source code is hosted on GitHub.

\$\endgroup\$
  • 1
    \$\begingroup\$ Please test this scenario: Original Text : "12" Tokens to Search : ["1","2"]. This works as expected. {<span class="hlt1">1</span> <span class="hlt2">2</span>} Now try searching with three tokens : ["1","2","span"] - Here's a screenshot: i.stack.imgur.com/EOrzh.png \$\endgroup\$ – user62985 Jan 14 '15 at 11:59
  • \$\begingroup\$ @anoopPrasad clearly a bug, well spotted, thanks! If you expand on that point, including suggestions of what I did wrong and how it can be better, that could become an answer I would upvote... \$\endgroup\$ – janos Jan 14 '15 at 13:04
4
\$\begingroup\$

One quick note (I'm so sorry I have no chance to read the full code) please be careful when using reference values (i.e object and arrays) in defaults, let me show you one example of how this could be turned into an unexpected behavior:

A = Backbone.Model.extend({defaults:{arr: ['a', 'b']}})
a = new A()
a.get('arr').push('c')
a.get('arr')
>> ["a", "b", "c"]
b = new A()
b.get('arr')
>>["a", "b", "c"]

This happens because arr attributes in both a and b instances of Model A refers to same value which is arr array reference in memory so when you are doing a.get('arr').push('c') you actually manipulate the arr value you referenced when you first declared defaults so any next instance of Model A will use the new value of arr which is ["a", "b", "c"] I hope this is clear.

One way you could solve this is to check for your defaults in initialize method instead so it'll create new instance of arr whenever there's no one present if we apply this to your initialize method:

   initialize: function() {
    if (!this.get('keywords')) {
        this.set({
            keywords: new Array()
        });
    }

    if (!this.get('words')) {
        this.set({
            words: new Object()
        });
    }
    this.on('change:original', this.onOriginalUpdated, this);
    this.on('change:keywords', this.onKeywordsUpdated, this);
}
\$\endgroup\$
  • \$\begingroup\$ Excellent point! I corrected it by changing to defaults: function () { ... } instead of a plain object, and that works well. I do wish you'd read till the end and criticized more ;-) \$\endgroup\$ – janos Aug 28 '14 at 4:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.