This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/views/composer/composer_view.js
Brad Wilson 836b0c41ab BUGFIX: adjust page padding when composer view open
From https://meta.discourse.org/t/discourse-general-polish/13184 :

"Footer padding gets out of sync: start typing a new topic in the composer ... size it big ... got to random topic ... footer padding is not added so you can not see the last post on topic. (workaround, resize composer once more so footer padding is added). This need to be added everywhere automatically. Including admin screens."
2014-04-11 09:54:14 +10:00

506 lines
18 KiB
JavaScript

/*global assetPath:true */
/**
This view handles rendering of the composer
@class ComposerView
@extends Discourse.View
@namespace Discourse
@module Discourse
**/
Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
templateName: 'composer',
elementId: 'reply-control',
classNameBindings: ['model.creatingPrivateMessage:private-message',
'composeState',
'model.loading',
'model.canEditTitle:edit-title',
'postMade',
'model.creatingTopic:topic',
'model.showPreview',
'model.hidePreview'],
model: Em.computed.alias('controller.model'),
// This is just in case something still references content. Can probably be removed
content: Em.computed.alias('model'),
composeState: function() {
var state = this.get('model.composeState');
if (state) return state;
return Discourse.Composer.CLOSED;
}.property('model.composeState'),
draftStatus: function() {
$('#draft-status').text(this.get('model.draftStatus') || "");
}.observes('model.draftStatus'),
// Disable fields when we're loading
loadingChanged: function() {
if (this.get('loading')) {
$('#wmd-input, #reply-title').prop('disabled', 'disabled');
} else {
$('#wmd-input, #reply-title').prop('disabled', '');
}
}.observes('loading'),
postMade: function() {
return this.present('controller.createdPost') ? 'created-post' : null;
}.property('model.createdPost'),
refreshPreview: Discourse.debounce(function() {
if (this.editor) {
this.editor.refreshPreview();
}
}, 30),
observeReplyChanges: function() {
if (this.get('model.hidePreview')) return;
Ember.run.scheduleOnce('afterRender', this, 'refreshPreview');
}.observes('model.reply', 'model.hidePreview'),
movePanels: function(sizePx) {
$('.composer-popup').css('bottom', sizePx);
},
focusIn: function() {
var controller = this.get('controller');
if (controller) controller.updateDraftStatus();
},
resize: function() {
// this still needs to wait on animations, need a clean way to do that
return Em.run.schedule('afterRender', function() {
var replyControl = $('#reply-control');
var h = replyControl.height() || 0;
var sizePx = "" + h + "px";
$('#main-outlet').css('padding-bottom', sizePx);
$('.composer-popup').css('bottom', sizePx);
});
}.observes('model.composeState'),
keyUp: function() {
var controller = this.get('controller');
controller.checkReplyLength();
var lastKeyUp = new Date();
this.set('lastKeyUp', lastKeyUp);
// One second from now, check to see if the last key was hit when
// we recorded it. If it was, the user paused typing.
var self = this;
Em.run.later(function() {
if (lastKeyUp !== self.get('lastKeyUp')) return;
// Search for similar topics if the user pauses typing
controller.findSimilarTopics();
}, 1000);
},
keyDown: function(e) {
if (e.which === 27) {
// ESC
this.get('controller').hitEsc();
return false;
} else if (e.which === 13 && (e.ctrlKey || e.metaKey)) {
// CTRL+ENTER or CMD+ENTER
this.get('controller').send('save');
return false;
}
},
didInsertElement: function() {
var $replyControl = $('#reply-control');
$replyControl.DivResizer({ resize: this.resize, onDrag: this.movePanels });
Discourse.TransitionHelper.after($replyControl, this.resize);
this.ensureMaximumDimensionForImagesInPreview();
},
ensureMaximumDimensionForImagesInPreview: function() {
// This enforce maximum dimensions of images in the preview according
// to the current site settings.
// For interactivity, we immediately insert the locally cooked version
// of the post into the stream when the user hits reply. We therefore also
// need to enforce these rules on the .cooked version.
// Meanwhile, the server is busy post-processing the post and generating thumbnails.
$('<style>#wmd-preview img:not(.thumbnail), .cooked img:not(.thumbnail) {' +
'max-width:' + Discourse.SiteSettings.max_image_width + 'px;' +
'max-height:' + Discourse.SiteSettings.max_image_height + 'px;' +
'}</style>'
).appendTo('head');
},
click: function() {
this.get('controller').openIfDraft();
},
// Called after the preview renders. Debounced for performance
afterRender: function() {
var $wmdPreview = $('#wmd-preview');
if ($wmdPreview.length === 0) return;
Discourse.SyntaxHighlighting.apply($wmdPreview);
var post = this.get('model.post'),
refresh = false;
// If we are editing a post, we'll refresh its contents once. This is a feature that
// allows a user to refresh its contents once.
if (post && post.blank('refreshedPost')) {
refresh = true;
post.set('refreshedPost', true);
}
// Load the post processing effects
$('a.onebox', $wmdPreview).each(function(i, e) {
Discourse.Onebox.load(e, refresh);
});
$('span.mention', $wmdPreview).each(function(i, e) {
Discourse.Mention.load(e, refresh);
});
this.trigger('previewRefreshed', $wmdPreview);
},
initEditor: function() {
// not quite right, need a callback to pass in, meaning this gets called once,
// but if you start replying to another topic it will get the avatars wrong
var $wmdInput, editor, self = this;
this.wmdInput = $wmdInput = $('#wmd-input');
if ($wmdInput.length === 0 || $wmdInput.data('init') === true) return;
$LAB.script(assetPath('defer/html-sanitizer-bundle'));
Discourse.ComposerView.trigger("initWmdEditor");
var template = Discourse.UserSelector.templateFunction();
$wmdInput.data('init', true);
$wmdInput.autocomplete({
template: template,
dataSource: function(term) {
return Discourse.UserSearch.search({
term: term,
topicId: self.get('controller.controllers.topic.model.id'),
include_groups: true
});
},
key: "@",
transformComplete: function(v) {
if (v.username) {
return v.username;
} else {
return v.usernames.join(", @");
}
}
});
this.editor = editor = Discourse.Markdown.createEditor({
lookupAvatarByPostNumber: function(postNumber) {
var posts = self.get('controller.controllers.topic.postStream.posts');
if (posts) {
var quotedPost = posts.findProperty("post_number", postNumber);
if (quotedPost) {
return Discourse.Utilities.tinyAvatar(quotedPost.get("avatar_template"));
}
}
}
});
this.editor.hooks.insertImageDialog = function(callback) {
callback(null);
self.get('controller').send('showUploadSelector', self);
return true;
};
this.editor.hooks.onPreviewRefresh = function() {
return self.afterRender();
};
this.editor.run();
this.set('editor', this.editor);
this.loadingChanged();
var saveDraft = Discourse.debounce((function() {
return self.get('controller').saveDraft();
}), 2000);
$wmdInput.keyup(function() {
saveDraft();
return true;
});
var $replyTitle = $('#reply-title');
$replyTitle.keyup(function() {
saveDraft();
// removes the red background once the requirements are met
if (self.get('model.missingTitleCharacters') <= 0) {
$replyTitle.removeClass("requirements-not-met");
}
return true;
});
// when the title field loses the focus...
$replyTitle.blur(function(){
// ...and the requirements are not met (ie. the minimum number of characters)
if (self.get('model.missingTitleCharacters') > 0) {
// then, "redify" the background
$replyTitle.toggleClass("requirements-not-met", true);
}
});
// in case it's still bound somehow
this._unbindUploadTarget();
var $uploadTarget = $('#reply-control');
$uploadTarget.fileupload({
url: Discourse.getURL('/uploads'),
dataType: 'json'
});
// submit - this event is triggered for each upload
$uploadTarget.on('fileuploadsubmit', function (e, data) {
var result = Discourse.Utilities.validateUploadedFiles(data.files);
// reset upload status when everything is ok
if (result) self.setProperties({ uploadProgress: 0, isUploading: true });
return result;
});
// send - this event is triggered when the upload request is about to start
$uploadTarget.on('fileuploadsend', function (e, data) {
// hide the "file selector" modal
self.get('controller').send('closeModal');
// cf. https://github.com/blueimp/jQuery-File-Upload/wiki/API#how-to-cancel-an-upload
var jqXHR = data.xhr();
// need to wait for the link to show up in the DOM
Em.run.schedule('afterRender', function() {
// bind on the click event on the cancel link
$('#cancel-file-upload').on('click', function() {
// cancel the upload
// NOTE: this will trigger a 'fileuploadfail' event with status = 0
if (jqXHR) jqXHR.abort();
// unbind
$(this).off('click');
});
});
});
// progress all
$uploadTarget.on('fileuploadprogressall', function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
self.set('uploadProgress', progress);
});
// done
$uploadTarget.on('fileuploaddone', function (e, data) {
// make sure we have a url
if (data.result.url) {
var markdown = Discourse.Utilities.getUploadMarkdown(data.result);
// appends a space at the end of the inserted markdown
self.addMarkdown(markdown + " ");
self.set('isUploading', false);
} else {
bootbox.alert(I18n.t('post.errors.upload'));
}
});
// fail
$uploadTarget.on('fileuploadfail', function (e, data) {
// hide upload status
self.set('isUploading', false);
// display an error message
Discourse.Utilities.displayErrorForUpload(data);
});
// contenteditable div hack for getting image paste to upload working in
// Firefox. This is pretty dangerous because it can potentially break
// Ctrl+v to paste so we should be conservative about what browsers this runs
// in.
var uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/);
if (uaMatch && parseInt(uaMatch[1]) >= 24) {
self.$().append( Ember.$("<div id='contenteditable' contenteditable='true' style='height: 0; width: 0; overflow: hidden'></div>") );
self.$("textarea").off('keydown.contenteditable');
self.$("textarea").on('keydown.contenteditable', function(event) {
// Catch Ctrl+v / Cmd+v and hijack focus to a contenteditable div. We can't
// use the onpaste event because for some reason the paste isn't resumed
// after we switch focus, probably because it is being executed too late.
if ((event.ctrlKey || event.metaKey) && (event.keyCode === 86)) {
// Save the current textarea selection.
var textarea = self.$("textarea")[0],
selectionStart = textarea.selectionStart,
selectionEnd = textarea.selectionEnd;
// Focus the contenteditable div.
var contentEditableDiv = self.$('#contenteditable');
contentEditableDiv.focus();
// The paste doesn't finish immediately and we don't have any onpaste
// event, so wait for 100ms which _should_ be enough time.
setTimeout(function() {
var pastedImg = contentEditableDiv.find('img');
if ( pastedImg.length === 1 ) {
pastedImg.remove();
}
// For restoring the selection.
textarea.focus();
var textareaContent = $(textarea).val(),
startContent = textareaContent.substring(0, selectionStart),
endContent = textareaContent.substring(selectionEnd);
var restoreSelection = function(pastedText) {
$(textarea).val( startContent + pastedText + endContent );
textarea.selectionStart = selectionStart + pastedText.length;
textarea.selectionEnd = textarea.selectionStart;
};
if (contentEditableDiv.html().length > 0) {
// If the image wasn't the only pasted content we just give up and
// fall back to the original pasted text.
contentEditableDiv.find("br").replaceWith("\n");
restoreSelection(contentEditableDiv.text());
} else {
// Depending on how the image is pasted in, we may get either a
// normal URL or a data URI. If we get a data URI we can convert it
// to a Blob and upload that, but if it is a regular URL that
// operation is prevented for security purposes. When we get a regular
// URL let's just create an <img> tag for the image.
var imageSrc = pastedImg.attr('src');
if (imageSrc.match(/^data:image/)) {
// Restore the cursor position, and remove any selected text.
restoreSelection("");
// Create a Blob to upload.
var image = new Image();
image.onload = function() {
// Create a new canvas.
var canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
canvas.height = image.height;
canvas.width = image.width;
var ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
canvas.toBlob(function(blob) {
$uploadTarget.fileupload('add', {files: blob});
});
};
image.src = imageSrc;
} else {
restoreSelection("<img src='" + imageSrc + "'>");
}
}
contentEditableDiv.html('');
}, 100);
}
});
}
// I hate to use Em.run.later, but I don't think there's a way of waiting for a CSS transition
// to finish.
return Em.run.later(jQuery, (function() {
var replyTitle = $('#reply-title');
self.resize();
return replyTitle.length ? replyTitle.putCursorAtEnd() : $wmdInput.putCursorAtEnd();
}), 300);
},
addMarkdown: function(text) {
var ctrl = $('#wmd-input').get(0),
caretPosition = Discourse.Utilities.caretPosition(ctrl),
current = this.get('model.reply');
this.set('model.reply', current.substring(0, caretPosition) + text + current.substring(caretPosition, current.length));
Em.run.schedule('afterRender', function() {
Discourse.Utilities.setCaretPosition(ctrl, caretPosition + text.length);
});
},
// Uses javascript to get the image sizes from the preview, if present
imageSizes: function() {
var result = {};
$('#wmd-preview img').each(function(i, e) {
var $img = $(e),
src = $img.prop('src');
if (src && src.length) {
result[src] = { width: $img.width(), height: $img.height() };
}
});
return result;
},
childDidInsertElement: function() {
return this.initEditor();
},
childWillDestroyElement: function() {
$('#main-outlet').css('padding-bottom', 0);
this._unbindUploadTarget();
},
titleValidation: function() {
var titleLength = this.get('model.titleLength'),
missingChars = this.get('model.missingTitleCharacters'),
reason;
if( titleLength < 1 ){
reason = I18n.t('composer.error.title_missing');
} else if( missingChars > 0 ) {
reason = I18n.t('composer.error.title_too_short', {min: this.get('model.minimumTitleLength')});
} else if( titleLength > Discourse.SiteSettings.max_topic_title_length ) {
reason = I18n.t('composer.error.title_too_long', {max: Discourse.SiteSettings.max_topic_title_length});
}
if( reason ) {
return Discourse.InputValidation.create({ failed: true, reason: reason });
}
}.property('model.titleLength', 'model.missingTitleCharacters', 'model.minimumTitleLength'),
categoryValidation: function() {
if( !Discourse.SiteSettings.allow_uncategorized_topics && !this.get('model.categoryId')) {
return Discourse.InputValidation.create({ failed: true, reason: I18n.t('composer.error.category_missing') });
}
}.property('model.categoryId'),
replyValidation: function() {
var replyLength = this.get('model.replyLength'),
missingChars = this.get('model.missingReplyCharacters'),
reason;
if( replyLength < 1 ){
reason = I18n.t('composer.error.post_missing');
} else if( missingChars > 0 ) {
reason = I18n.t('composer.error.post_length', {min: this.get('model.minimumPostLength')});
}
if( reason ) {
return Discourse.InputValidation.create({ failed: true, reason: reason });
}
}.property('model.reply', 'model.replyLength', 'model.missingReplyCharacters', 'model.minimumPostLength'),
_unbindUploadTarget: function() {
var $uploadTarget = $('#reply-control');
$uploadTarget.fileupload('destroy');
$uploadTarget.off();
}
});
// not sure if this is the right way, keeping here for now, we could use a mixin perhaps
Discourse.NotifyingTextArea = Ember.TextArea.extend({
placeholder: function() {
return I18n.t(this.get('placeholderKey'));
}.property('placeholderKey'),
didInsertElement: function() {
return this.get('parent').childDidInsertElement(this);
},
willDestroyElement: function() {
return this.get('parent').childWillDestroyElement(this);
}
});
RSVP.EventTarget.mixin(Discourse.ComposerView);