view wikked/assets/js/wikked/views.js @ 301:19a377f4e962

No more `meta` link stuff, everything is an endpoint link.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 29 Sep 2014 07:26:17 -0700
parents 8bf279b1e8f8
children e4e13e1138b2
line wrap: on
line source

/**
 * Wikked views.
 */
define([
        'jquery',
        'jquery_validate',
        'underscore',
        'backbone',
        'handlebars',
        'bootstrap_tooltip',
        'bootstrap_alert',
        'bootstrap_collapse',
        'pagedown_converter',
        'pagedown_editor',
        'pagedown_sanitizer',
        'js/wikked/client',
        'js/wikked/models',
        'js/wikked/util',
        'text!tpl/read-page.html',
        'text!tpl/meta-page.html',
        'text!tpl/edit-page.html',
        'text!tpl/history-page.html',
        'text!tpl/revision-page.html',
        'text!tpl/diff-page.html',
        'text!tpl/inlinks-page.html',
        'text!tpl/nav.html',
        'text!tpl/footer.html',
        'text!tpl/search-results.html',
        'text!tpl/login.html',
        'text!tpl/error-unauthorized.html',
        'text!tpl/error-not-found.html',
        'text!tpl/error-unauthorized-edit.html',
        'text!tpl/state-warning.html',
        'text!tpl/special-nav.html',
        'text!tpl/special-pages.html',
        'text!tpl/special-changes.html',
        'text!tpl/special-orphans.html'
        ],
    function($, JQueryValidate, _, Backbone, Handlebars,
        BootstrapTooltip, BootstrapAlert, BootstrapCollapse,
        PageDownConverter, PageDownEditor, PageDownSanitizer,
        Client, Models, Util,
        tplReadPage, tplMetaPage, tplEditPage, tplHistoryPage, tplRevisionPage, tplDiffPage, tplInLinksPage,
        tplNav, tplFooter, tplSearchResults, tplLogin,
        tplErrorNotAuthorized, tplErrorNotFound, tplErrorUnauthorizedEdit, tplStateWarning,
        tplSpecialNav, tplSpecialPages, tplSpecialChanges, tplSpecialOrphans) {

    var exports = {};

    // JQuery feature for watching size changes in a DOM element.
    jQuery.fn.watch = function(id, fn) {
        return this.each(function() {
            var self = this;
            var oldVal = self[id];
            $(self).data(
                'watch_timer',
                setInterval(
                    function() {
                        if (self[id] !== oldVal) {
                            fn.call(self, id, oldVal, self[id]);
                            oldVal = self[id];
                        }
                    },
                    100
                )
            );
        });
    };
    jQuery.fn.unwatch = function(id) {
        return this.each(function() {
            clearInterval($(this).data('watch_timer'));
        });
    };

    // Override JQuery-validation plugin's way of highlighting errors
    // with something that works with Bootstrap.
    $.validator.setDefaults({
        highlight: function(element) {
            $(element).closest('.form-group')
                .addClass('has-error')
                .removeClass('has-success');
        },
        unhighlight: function(element) {
            $(element).closest('.form-group')
                .removeClass('has-error')
                .addClass('has-success');
        },
        errorElement: 'span',
        errorClass: 'help-block',
        errorPlacement: function(error, element) {
            if(element.parent('.input-group').length) {
                error.insertAfter(element.parent());
            } else {
                error.insertAfter(element);
            }
        }
    });

    // Utility function to make wiki links into usable links for
    // this UI frontend.
    var processWikiLinks = function(el) {
        $('a.wiki-link', el).each(function(i) {
            var jel = $(this);
            var wiki_url = jel.attr('data-wiki-url').replace(/^\//, '');
            if (jel.hasClass('missing') || jel.attr('data-action') == 'edit')
                jel.attr('href', '/#/edit/' + wiki_url);
            else
                jel.attr('href', '/#/read/' + wiki_url);
        });
    };

    var PageView = exports.PageView = Backbone.View.extend({
        tagName: 'div',
        className: 'wrapper',
        isMainPage: true,
        initialize: function() {
            PageView.__super__.initialize.apply(this, arguments);
            if (this.model)
                this.model.on("change", this._onModelChange, this);
            return this;
        },
        dispose: function() {
            this.remove();
            this.unbind();
            if (this.model) {
                this.model.unbind();
            }
            if (this._onDispose) {
                this._onDispose();
            }
        },
        render: function(view) {
            if (this.template === undefined && this.templateSource !== undefined) {
                this.template = Handlebars.compile(_.result(this, 'templateSource'));
            }
            if (this.template !== undefined) {
                var markup = this.renderTemplate(this.template);
                var $markup = $(markup);
                if (this.preRenderCallback !== undefined) {
                    this.preRenderCallback($markup);
                }
                this.$el.empty().append($markup);
                if (this.renderCallback !== undefined) {
                    this.renderCallback();
                }
            }
            if (this.isMainPage) {
                this.renderTitle(this.titleFormat);
            }
            return this;
        },
        renderTemplate: function(tpl) {
            var ctx = this.renderContext();
            return tpl(ctx);
        },
        renderContext: function() {
            return this.model.toJSON();
        },
        renderTitle: function(formatter) {
            var title = _.result(this.model, 'title');
            if (formatter !== undefined) {
                title = formatter.call(this, title);
            }
            document.title = title;
        },
        _onModelChange: function() {
            this.render();
        }
    });
    _.extend(PageView, Backbone.Events);

    var NavigationView = exports.NavigationView = PageView.extend({
        className: 'nav-wrapper',
        templateSource: tplNav,
        isMainPage: false,
        initialize: function() {
            NavigationView.__super__.initialize.apply(this, arguments);
            return this;
        },
        renderContext: function() {
            var ctx = NavigationView.__super__.renderContext.apply(this, arguments);
            var ima = localStorage.getItem('wikked.nav.isMenuActive');
            if (ima == 'true') ctx.showMenu = true;
            return ctx;
        },
        renderCallback: function() {
            // Hide the drop-down for the search results.
            this.searchPreviewList = this.$('#search-preview');
            this.searchPreviewList.hide();
            this.activeResultIndex = -1;

            // Cache some stuff for handling the menu.
            this.wikiMenu = this.$('#wiki-menu');
            this.wrapperAndWikiMenu = this.$('.wrapper, #wiki-menu');
            this.isMenuActive = (this.wikiMenu.css('left') == '0px');
            this.isMenuActiveLocked = false;
        },
        events: {
            "click #wiki-menu-shortcut": "_onMenuShortcutClick",
            "mouseenter #wiki-menu-shortcut": "_onMenuShortcutHover",
            "mouseleave #wiki-menu-shortcut": "_onMenuShortcutLeave",
            "mouseenter #wiki-menu": "_onMenuHover",
            "mouseleave #wiki-menu": "_onMenuLeave",
            "submit #search": "_submitSearch",
            "submit #newpage": "_submitNewPage",
            "input #search-query": "_previewSearch",
            "keyup #search-query": "_searchQueryChanged",
            "focus #search-query": "_searchQueryFocused",
            "blur #search-query": "_searchQueryBlurred"
        },
        _onMenuShortcutClick: function(e) {
            this.isMenuActive = !this.isMenuActive;
            localStorage.setItem('wikked.nav.isMenuActive', this.isMenuActive);
        },
        _onMenuShortcutHover: function(e) {
            if (!this.isMenuActive && !this.isMenuActiveLocked)
                this._toggleWikiMenu(true);
        },
        _onMenuShortcutLeave: function(e) {
            if (!this.isMenuActive && !this.isMenuActiveLocked)
                this._toggleWikiMenu(false);
        },
        _onMenuHover: function(e) {
            if (!this.isMenuActive && !this.isMenuActiveLocked)
                this._toggleWikiMenu(true);
        },
        _onMenuLeave: function(e) {
            if (!this.isMenuActive && !this.isMenuActiveLocked)
                this._toggleWikiMenu(false);
        },
        _toggleWikiMenu: function(onOff) {
            if (onOff) {
                this.wrapperAndWikiMenu.toggleClass('wiki-menu-inactive', false);
                this.wrapperAndWikiMenu.toggleClass('wiki-menu-active', true);
            } else {
                this.wrapperAndWikiMenu.toggleClass('wiki-menu-active', false);
                this.wrapperAndWikiMenu.toggleClass('wiki-menu-inactive', true);
            }
        },
        _submitSearch: function(e) {
            e.preventDefault();
            if (this.activeResultIndex >= 0) {
                var entries = this.searchPreviewList.children();
                var choice = $('a', entries[this.activeResultIndex]);
                this.model.doGoToSearchResult(choice.attr('href'));
            } else {
                this.model.doSearch(e.currentTarget);
            }
            return false;
        },
        _submitNewPage: function(e) {
            e.preventDefault();
            this.model.doNewPage(e.currentTarget);
            return false;
        },
        _previewSearch: function(e) {
            var query = $(e.currentTarget).val();
            if (query && query.length >= 1) {
                var $view = this;
                this.model.doPreviewSearch(query, function(data) {
                    var resultStr = '';
                    for (var i = 0; i < data.hits.length; ++i) {
                        var hitUrl = data.hits[i].url.replace(/^\//, '');
                        resultStr += '<li>' +
                            '<a href="/#read/' + hitUrl + '">' +
                            data.hits[i].title +
                            '</a>' +
                            '</li>';
                    }
                    $view.searchPreviewList.html(resultStr);
                    if (!$view.searchPreviewList.is(':visible'))
                        $view.searchPreviewList.slideDown(200);
                });
            } else if(!query || query.length === 0) {
                this.searchPreviewList.slideUp(200);
            }
        },
        _searchQueryChanged: function(e) {
            if (e.keyCode == 27) {
                // Clear search on `Esc`.
                $(e.currentTarget).val('').trigger('input');
            } else if (e.keyCode == 38) {
                // Up arrow.
                e.preventDefault();
                if (this.activeResultIndex >= 0) {
                    this.activeResultIndex--;
                    this._updateActiveResult();
                }
            } else if (e.keyCode == 40) {
                // Down arrow.
                e.preventDefault();
                if (this.activeResultIndex < 
                        this.searchPreviewList.children().length - 1) {
                    this.activeResultIndex++;
                    this._updateActiveResult();
                }
            }
        },
        _updateActiveResult: function() {
            var entries = this.searchPreviewList.children();
            entries.toggleClass('search-result-hover', false);
            if (this.activeResultIndex >= 0)
                $(entries[this.activeResultIndex]).toggleClass('search-result-hover', true);
        },
        _searchQueryFocused: function(e) {
            this.isMenuActiveLocked = true;
            this.wikiMenu.toggleClass('wiki-menu-ext', true);
        },
        _searchQueryBlurred: function(e) {
            $(e.currentTarget).val('').trigger('input');
            this.wikiMenu.toggleClass('wiki-menu-ext', false);
            this.isMenuActiveLocked = false;
            if ($(document.activeElement).parents('#wiki-menu').length === 0)
                this._onMenuLeave(e);
        }
    });

    var FooterView = exports.FooterView = PageView.extend({
        className: 'footer-wrapper',
        templateSource: tplFooter,
        isMainPage: false,
        initialize: function() {
            FooterView.__super__.initialize.apply(this, arguments);
            return this;
        },
        render: function() {
            FooterView.__super__.render.apply(this, arguments);
            return this;
        }
    });

    var LoginView = exports.LoginView = PageView.extend({
        templateSource: tplLogin,
        events: {
            "submit #login": "_submitLogin"
        },
        _submitLogin: function(e) {
            e.preventDefault();
            this.model.doLogin(e.currentTarget);
            return false;
        }
    });

    var MasterPageView = exports.MasterPageView = PageView.extend({
        className: function() {
            var cls = 'wrapper';

            // HACK-ish: we need to know if the menu needs to be shown 
            //           on init or not. Can't do it later otherwise
            //           we'll get the CSS animation to play right away.
            var ima = localStorage.getItem('wikked.nav.isMenuActive');
            if (ima == 'true') cls += ' wiki-menu-active';

            return cls;
        },
        initialize: function() {
            MasterPageView.__super__.initialize.apply(this, arguments);
            this.nav = this._createNavigation(this.model.nav);
            this.footer = this._createFooter(this.model.footer);
            return this;
        },
        dispose: function() {
            if (this.footer) this.footer.dispose();
            if (this.nav) this.nav.dispose();
            MasterPageView.__super__.dispose.apply(this, arguments);
        },
        renderCallback: function() {
            if (this.nav) {
                this.nav.render();
                this.$el.prepend(this.nav.el);
            }
            if (this.footer) {
                this.footer.render();
                this.$el.append(this.footer.el);
            }
            this.isError = (this.model.get('error_code') !== undefined);
        },
        templateSource: function() {
            switch (this.model.get('error_code')) {
                case 401:
                    return tplErrorNotAuthorized;
                case 404:
                    return tplErrorNotFound;
                default:
                    return _.result(this, 'defaultTemplateSource');
            }
        },
        _createNavigation: function(model) {
            return new NavigationView({ model: model });
        },
        _createFooter: function(model) {
            return new FooterView({ model: model });
        }
    });

    var PageReadView = exports.PageReadView = MasterPageView.extend({
        defaultTemplateSource: tplReadPage,
        initialize: function() {
            PageReadView.__super__.initialize.apply(this, arguments);
            this.warningTemplate = Handlebars.compile(tplStateWarning);
            return this;
        },
        renderCallback: function() {
            PageReadView.__super__.renderCallback.apply(this, arguments);
            if (this.isError) {
                return;
            }

            processWikiLinks(this.$el);

            // If we've already rendered the content, see if we need to display a
            // warning about the page's state.
            if (this.model.get('content')) {
                if (this._pageState === undefined) {
                    if (!this._isCheckingPageState)
                        this._checkPageState();
                } else {
                    this._showPageStateWarning();
                }
            }
        },
        _showPageStateWarning: function() {
            if (this._pageState === undefined)
                return;

            var state = this._pageState.get('state');
            if (state == 'new' || state == 'modified') {
                var article = $('.wrapper>article');
                var warning = $(this.warningTemplate(this._pageState.toJSON()));
                $('[rel="tooltip"]', warning).tooltip({container:'body'});
                //warning.css('display', 'none');
                warning.prependTo(article);
                //warning.slideDown();
                $('.dismiss', warning).click(function() {
                    //warning.slideUp();
                    warning.remove();
                    return false;
                });
            }
        },
        _enableStateCheck: false,
        _isCheckingPageState: false,
        _checkPageState: function() {
            if (!this._enableStateCheck)
                return;
            this._isCheckingPageState = true;
            var $view = this;
            var statePath = this.model.checkStatePath();
            if (!statePath)
                return;
            var stateModel = new Models.PageStateModel({ path: statePath });
            stateModel.fetch({
                success: function(model, response, options) {
                    $view._pageState = model;
                    $view._isCheckingPageState = false;
                    if ($view.model && $view.model.get('content')) {
                        $view._showPageStateWarning();
                    }
                }
            });
        }
    });

    var PageEditView = exports.PageEditView = MasterPageView.extend({
        defaultTemplateSource: tplEditPage,
        dispose: function() {
            PageEditView.__super__.dispose.apply(this, arguments);
        },
        renderCallback: function() {
            PageEditView.__super__.renderCallback.apply(this, arguments);
            if (this.isError) {
                return;
            }

            // Initialize the preview.
            this.inputSection = $('.editing-input');
            this.inputCtrl = $('#editing-input-area');
            this.previewSection = $('.editing-preview');
            this.previewSection.hide();
            this.previewButtonLabel = $('.editing-preview-button-label');
            this.errorSection = $('.editing-error');
            this.errorSection.hide();

            // Start validation on the form.
            $('#page-edit').validate({
                rules: {
                    title: {
                        required: true,
                        remote: {
                            url: '/api/validate/newpage',
                            type: 'post'
                        }
                    }
                }
            });
        },
        events: {
            "mousedown #editing-input-grip": "_inputGripMouseDown",
            "click #editing-preview-button": "_togglePreview",
            "click #editing-cancel-button": "_cancelEdit",
            "submit #page-edit": "_submitEditedPage"
        },
        _inputGripMouseDown: function(e) {
            // Input area resizing with the grip.
            var $view = this;
            var last_pageY = e.pageY;
            $('body')
                .on('mousemove.wikked.editor_resize', function(e) {
                    var ctrl = $view.inputCtrl;
                    ctrl.height(ctrl.height() + e.pageY - last_pageY);
                    last_pageY = e.pageY;
                })
                .on('mouseup.wikked.editor_resize mouseleave.wikked.editor_resize', function(e) {
                    $('body').off('.wikked.editor_resize');
                });
        },
        _togglePreview: function(e) {
            e.preventDefault();

            if (this.previewSection.is(':visible')) {
                // Hide the preview, restore the textbox.
                this.inputSection.show();
                this.previewSection.hide();
                this.previewButtonLabel.html("Preview");
                return;
            }

            // Get the server to compute the preview text, hide the textbox,
            // show the rendered text.
            var $view = this;
            var previewData = {
                url: this.model.get('path'),
                text: this.inputCtrl.val()
            };
            $.post('/api/preview', previewData)
                .success(function(data) {
                    var el = $view.previewSection;
                    el.html(data.text);
                    processWikiLinks(el);
                    el.show();
                    $view.inputSection.hide();
                    $view.previewButtonLabel.html("Edit");
                    $view.errorSection.hide();
                })
                .error(function() {
                    $('.editing-error-message').html("Error running preview.");
                    $view.errorSection.show();
                });
            return false;
        },
        _submitEditedPage: function(e) {
            // Make the model submit the form.
            e.preventDefault();
            this.model.doEdit(e.currentTarget);
            return false;
        },
        _cancelEdit: function(e) {
            e.preventDefault();
            this.model.doCancel();
            return false;
        },
        titleFormat: function(title) {
            return 'Editing: ' + title;
        }
    });

    var PageHistoryView = exports.PageHistoryView = MasterPageView.extend({
        defaultTemplateSource: tplHistoryPage,
        events: {
            "submit #diff-page": "_submitDiffPage"
        },
        _submitDiffPage: function(e) {
            e.preventDefault();
            this.model.doDiff(e.currentTarget);
            return false;
        },
        titleFormat: function(title) {
            return 'History: ' + title;
        }
    });

    var PageRevisionView = exports.PageRevisionView = MasterPageView.extend({
        defaultTemplateSource: tplRevisionPage,
        titleFormat: function(title) {
            return title + ' [' + this.model.get('rev') + ']';
        },
        events: {
            "submit #page-revert": "_submitPageRevert"
        },
        _submitPageRevert: function(e) {
            e.preventDefault();
            this.model.doRevert(e.currentTarget);
            return false;
        }
    });

    var PageDiffView = exports.PageDiffView = MasterPageView.extend({
        defaultTemplateSource: tplDiffPage,
        titleFormat: function(title) {
            return title + ' [' + this.model.get('rev1') + '-' + this.model.get('rev2') + ']';
        }
    });

    var IncomingLinksView = exports.IncomingLinksView = MasterPageView.extend({
        defaultTemplateSource: tplInLinksPage,
        titleFormat: function(title) {
            return 'Incoming Links: ' + title;
        }
    });

    var WikiSearchView = exports.WikiSearchView = MasterPageView.extend({
        defaultTemplateSource: tplSearchResults
    });

    var SpecialMasterPageView = exports.SpecialMasterPageView = MasterPageView.extend({
        className: 'wrapper special'
    });

    var SpecialPagesView = exports.SpecialPagesView = SpecialMasterPageView.extend({
        defaultTemplateSource: tplSpecialPages
    });

    var SpecialChangesView = exports.SpecialChangesView = SpecialMasterPageView.extend({
        defaultTemplateSource: tplSpecialChanges,
        renderCallback: function() {
            SpecialChangesView.__super__.renderCallback.apply(this, arguments);
            if (this.isError) {
                return;
            }

            this.$('.wiki-history .wiki-history-entry-details').hide();
            this.$('.wiki-history .wiki-history-entry-collapser').click(function(e) {
                var btn = $(this);
                index = btn.attr('data-index');
                var tgt = $('.wiki-history .wiki-history-entry-details-' + index);
                tgt.toggle();
                if (tgt.is(':visible')) {
                    $('.glyphicon', btn).removeClass('glyphicon-chevron-down');
                    $('.glyphicon', btn).addClass('glyphicon-chevron-up');
                    $('small', btn).html('Hide');
                } else {
                    $('.glyphicon', btn).removeClass('glyphicon-chevron-up');
                    $('.glyphicon', btn).addClass('glyphicon-chevron-down');
                    $('small', btn).html('Show');
                }
                e.preventDefault();
            });
        }
    });

    var SpecialOrphansView = exports.SpecialOrphansView = SpecialMasterPageView.extend({
        defaultTemplateSource: tplSpecialOrphans
    });

    return exports;
});