Ghost/core/client/views/editor.js

435 lines
16 KiB
JavaScript
Raw Normal View History

// # Article Editor
/*global window, document, setTimeout, navigator, $, _, Backbone, Ghost, Showdown, CodeMirror, shortcut, Countable, JST */
(function () {
"use strict";
var PublishBar,
ActionsWidget,
MarkdownShortcuts = [
{'key': 'Ctrl+B', 'style': 'bold'},
{'key': 'Meta+B', 'style': 'bold'},
{'key': 'Ctrl+I', 'style': 'italic'},
{'key': 'Meta+I', 'style': 'italic'},
{'key': 'Ctrl+Alt+U', 'style': 'strike'},
{'key': 'Ctrl+Shift+K', 'style': 'code'},
{'key': 'Meta+K', 'style': 'code'},
{'key': 'Ctrl+Alt+1', 'style': 'h1'},
{'key': 'Ctrl+Alt+2', 'style': 'h2'},
{'key': 'Ctrl+Alt+3', 'style': 'h3'},
{'key': 'Ctrl+Alt+4', 'style': 'h4'},
{'key': 'Ctrl+Alt+5', 'style': 'h5'},
{'key': 'Ctrl+Alt+6', 'style': 'h6'},
{'key': 'Ctrl+Shift+L', 'style': 'link'},
{'key': 'Ctrl+Shift+I', 'style': 'image'},
{'key': 'Ctrl+Q', 'style': 'blockquote'},
{'key': 'Ctrl+Shift+1', 'style': 'currentDate'},
{'key': 'Ctrl+U', 'style': 'uppercase'},
{'key': 'Ctrl+Shift+U', 'style': 'lowercase'},
{'key': 'Ctrl+Alt+Shift+U', 'style': 'titlecase'},
{'key': 'Ctrl+Alt+W', 'style': 'selectword'},
2013-07-22 16:50:50 +04:00
{'key': 'Ctrl+L', 'style': 'list'},
{'key': 'Ctrl+Alt+C', 'style': 'copyHTML'},
{'key': 'Meta+Alt+C', 'style': 'copyHTML'}
];
// The publish bar associated with a post, which has the TagWidget and
// Save button and options and such.
// ----------------------------------------
PublishBar = Ghost.View.extend({
initialize: function () {
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
this.addSubview(new Ghost.View.PostSettings({el: $('#entry-controls'), model: this.model})).render();
},
render: function () { return this; }
});
// The Publish, Queue, Publish Now buttons
// ----------------------------------------
ActionsWidget = Ghost.View.extend({
events: {
'click [data-set-status]': 'handleStatus',
'click .js-publish-button': 'handlePostButton'
},
statusMap: null,
createStatusMap: {
'draft': 'Save Draft',
'published': 'Publish Now'
},
updateStatusMap: {
'draft': 'Unpublish',
'published': 'Update Post'
},
notificationMap: {
'draft': 'saved as a draft',
'published': 'published'
},
initialize: function () {
var self = this;
// Toggle publish
shortcut.add("Ctrl+Alt+P", function () {
self.toggleStatus();
});
shortcut.add("Ctrl+S", function () {
self.updatePost();
});
shortcut.add("Meta+S", function () {
self.updatePost();
});
this.listenTo(this.model, 'change:status', this.render);
this.model.on('change:id', function (m) {
Backbone.history.navigate('/editor/' + m.id);
});
},
toggleStatus: function () {
var self = this,
keys = Object.keys(this.statusMap),
model = self.model,
prevStatus = model.get('status'),
currentIndex = keys.indexOf(prevStatus),
newIndex;
newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1;
this.setActiveStatus(keys[newIndex], this.statusMap[keys[newIndex]], prevStatus);
this.savePost({
status: keys[newIndex]
}).then(function () {
Ghost.notifications.addItem({
type: 'success',
message: 'Your post has been ' + self.notificationMap[newIndex] + '.',
status: 'passive'
});
}, function (xhr) {
var status = keys[newIndex];
// Show a notification about the error
self.reportSaveError(xhr, model, status);
});
},
setActiveStatus: function (newStatus, displayText, currentStatus) {
var isPublishing = (newStatus === 'published' && currentStatus !== 'published'),
isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'),
// Controls when background of button has the splitbutton-delete/button-delete classes applied
isImportantStatus = (isPublishing || isUnpublishing);
$('.js-publish-splitbutton')
.removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete')
.addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save');
// Set the publish button's action and proper coloring
$('.js-publish-button')
.attr('data-status', newStatus)
.text(displayText)
.removeClass(isImportantStatus ? 'button-save' : 'button-delete')
.addClass(isImportantStatus ? 'button-delete' : 'button-save');
// Remove the animated popup arrow
$('.js-publish-splitbutton > a')
.removeClass('active');
// Set the active action in the popup
$('.js-publish-splitbutton .editor-options li')
.removeClass('active')
.filter(['li[data-set-status="', newStatus, '"]'].join(''))
.addClass('active');
},
handleStatus: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-set-status'),
currentStatus = this.model.get('status');
this.setActiveStatus(status, this.statusMap[status], currentStatus);
// Dismiss the popup menu
$('body').find('.overlay:visible').fadeOut();
},
handlePostButton: function (e) {
if (e) { e.preventDefault(); }
var status = $(e.currentTarget).attr('data-status');
this.updatePost(status);
},
updatePost: function (status) {
var self = this,
model = this.model,
prevStatus = model.get('status'),
notificationMap = this.notificationMap;
// Default to same status if not passed in
status = status || prevStatus;
model.trigger('willSave');
this.savePost({
status: status
}).then(function () {
Ghost.notifications.addItem({
type: 'success',
message: ['Your post has been ', notificationMap[status], '.'].join(''),
status: 'passive'
});
// Refresh publish button and all relevant controls with updated status.
self.render();
}, function (xhr) {
// Set the model status back to previous
model.set({ status: prevStatus });
// Set appropriate button status
self.setActiveStatus(status, self.statusMap[status], prevStatus);
// Show a notification about the error
self.reportSaveError(xhr, model, status);
});
},
savePost: function (data) {
// TODO: The markdown getter here isn't great, shouldn't rely on currentView.
_.each(this.model.blacklist, function (item) {
this.model.unset(item);
}, this);
var saved = this.model.save(_.extend({
title: $('#entry-title').val(),
markdown: Ghost.currentView.editor.getValue()
}, data));
// TODO: Take this out if #2489 gets merged in Backbone. Or patch Backbone
// ourselves for more consistent promises.
if (saved) {
return saved;
}
return $.Deferred().reject();
},
reportSaveError: function (response, model, status) {
var title = model.get('title') || '[Untitled]',
notificationStatus = this.notificationMap[status],
message = 'Your post: ' + title + ' has not been ' + notificationStatus;
if (response) {
// Get message from response
message = Ghost.Views.Utils.getRequestErrorMessage(response);
} else if (model.validationError) {
// Grab a validation error
message += "; " + model.validationError;
}
Ghost.notifications.addItem({
type: 'error',
message: message,
status: 'passive'
});
},
setStatusLabels: function (statusMap) {
_.each(statusMap, function (label, status) {
$('li[data-set-status="' + status + '"] > a').text(label);
});
},
render: function () {
var status = this.model.get('status');
// Assume that we're creating a new post
if (status !== 'published') {
this.statusMap = this.createStatusMap;
} else {
this.statusMap = this.updateStatusMap;
}
// Populate the publish menu with the appropriate verbiage
this.setStatusLabels(this.statusMap);
// Default the selected publish option to the current status of the post.
this.setActiveStatus(status, this.statusMap[status], status);
}
});
// The entire /editor page's route (TODO: move all views to client side templates)
// ----------------------------------------
Ghost.Views.Editor = Ghost.View.extend({
initialize: function () {
// Add the container view for the Publish Bar
this.addSubview(new PublishBar({el: "#publish-bar", model: this.model})).render();
this.$('#entry-title').val(this.model.get('title')).focus();
this.$('#entry-markdown').html(this.model.get('markdown'));
this.initMarkdown();
this.renderPreview();
$('.entry-content header, .entry-preview header').on('click', function () {
$('.entry-content, .entry-preview').removeClass('active');
$(this).closest('section').addClass('active');
});
$('.entry-title .icon-fullscreen').on('click', function (e) {
e.preventDefault();
$('body').toggleClass('fullscreen');
});
this.$('.CodeMirror-scroll').on('scroll', this.syncScroll);
this.$('.CodeMirror-scroll').scrollClass({target: '.entry-markdown', offset: 10});
this.$('.entry-preview-content').scrollClass({target: '.entry-preview', offset: 10});
// Zen writing mode shortcut
shortcut.add("Alt+Shift+Z", function () {
$('body').toggleClass('zen');
});
$('.entry-markdown header, .entry-preview header').click(function (e) {
$('.entry-markdown, .entry-preview').removeClass('active');
$(e.target).closest('section').addClass('active');
});
},
events: {
'click .markdown-help': 'showHelp',
'blur #entry-title': 'trimTitle',
'orientationchange': 'orientationChange'
},
2013-07-18 15:06:52 +04:00
syncScroll: _.debounce(function (e) {
var $codeViewport = $(e.target),
$previewViewport = $('.entry-preview-content'),
$codeContent = $('.CodeMirror-sizer'),
$previewContent = $('.rendered-markdown'),
// calc position
codeHeight = $codeContent.height() - $codeViewport.height(),
previewHeight = $previewContent.height() - $previewViewport.height(),
ratio = previewHeight / codeHeight,
previewPostition = $codeViewport.scrollTop() * ratio;
// apply new scroll
$previewViewport.scrollTop(previewPostition);
2013-07-18 15:06:52 +04:00
}, 50),
showHelp: function () {
this.addSubview(new Ghost.Views.Modal({
model: {
options: {
close: true,
type: "info",
style: ["wide"],
animation: 'fade'
},
content: {
template: 'markdown',
title: 'Markdown Help'
}
}
}));
},
trimTitle: function () {
var $title = $('#entry-title'),
rawTitle = $title.val(),
trimmedTitle = $.trim(rawTitle);
if (rawTitle !== trimmedTitle) {
$title.val(trimmedTitle);
}
},
// This is a hack to remove iOS6 white space on orientation change bug
// See: http://cl.ly/RGx9
orientationChange: function () {
if (/iPhone/.test(navigator.userAgent) && !/Opera Mini/.test(navigator.userAgent)) {
var focusedElement = document.activeElement,
s = document.documentElement.style;
focusedElement.blur();
s.display = 'none';
setTimeout(function () { s.display = 'block'; focusedElement.focus(); }, 0);
}
},
// This updates the editor preview panel.
// Currently gets called on every key press.
// Also trigger word count update
renderPreview: function () {
var self = this,
preview = document.getElementsByClassName('rendered-markdown')[0];
preview.innerHTML = this.converter.makeHtml(this.editor.getValue());
this.$('.js-drop-zone').upload({editor: true});
Countable.once(preview, function (counter) {
self.$('.entry-word-count').text($.pluralize(counter.words, 'word'));
self.$('.entry-character-count').text($.pluralize(counter.characters, 'character'));
self.$('.entry-paragraph-count').text($.pluralize(counter.paragraphs, 'paragraph'));
});
},
// Markdown converter & markdown shortcut initialization.
initMarkdown: function () {
var self = this;
this.converter = new Showdown.converter({extensions: ['ghostdown', 'github']});
this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
mode: 'gfm',
tabMode: 'indent',
tabindex: "2",
lineWrapping: true,
dragDrop: false
});
// Inject modal for HTML to be viewed in
shortcut.add("Ctrl+Alt+C", function () {
self.showHTML();
});
shortcut.add("Ctrl+Alt+C", function () {
self.showHTML();
});
_.each(MarkdownShortcuts, function (combo) {
shortcut.add(combo.key, function () {
return self.editor.addMarkdown({style: combo.style});
});
});
this.editor.on('change', function () {
self.renderPreview();
});
},
showHTML: function () {
this.addSubview(new Ghost.Views.Modal({
model: {
options: {
close: true,
type: "info",
style: ["wide"],
animation: 'fade'
},
content: {
template: 'copyToHTML',
title: 'Copied HTML'
}
}
}));
},
render: function () { return this; }
});
}());