import userSearch from 'discourse/lib/user-search'; import afterTransition from 'discourse/lib/after-transition'; import loadScript from 'discourse/lib/load-script'; import avatarTemplate from 'discourse/lib/avatar-template'; import positioningWorkaround from 'discourse/lib/safari-hacks'; import debounce from 'discourse/lib/debounce'; import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; import { headerHeight } from 'discourse/views/header'; const ComposerView = Ember.View.extend(Ember.Evented, { _lastKeyTimeout: null, 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() { return this.get('model.composeState') || Discourse.Composer.CLOSED; }.property('model.composeState'), // Disable fields when we're loading loadingChanged: function() { if (this.get('loading')) { this.$('.wmd-input, #reply-title').prop('disabled', 'disabled'); } else { this.$('.wmd-input, #reply-title').prop('disabled', ''); } }.observes('loading'), postMade: function() { return !Ember.isEmpty(this.get('model.createdPost')) ? 'created-post' : null; }.property('model.createdPost'), refreshPreview: 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(sizePx) { $('#main-outlet').css('padding-bottom', sizePx); $('.composer-popup').css('bottom', sizePx); // signal the progress bar it should move! this.appEvents.trigger("composer:resized"); }, resize: function() { Ember.run.scheduleOnce('afterRender', () => { let h = $('#reply-control').height() || 0; this.movePanels(h + "px"); // Figure out the size of the fields const $fields = this.$('.composer-fields'); let pos = $fields.position(); if (pos) { this.$('.wmd-controls').css('top', $fields.height() + pos.top + 5); } // get the submit panel height pos = this.$('.submit-panel').position(); if (pos) { this.$('.wmd-controls').css('bottom', h - pos.top + 7); } }); }.observes('model.composeState', 'model.action'), keyUp() { const controller = this.get('controller'); controller.checkReplyLength(); this.get('controller.model').typing(); const 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. const self = this; Ember.run.cancel(this._lastKeyTimeout); this._lastKeyTimeout = Ember.run.later(function() { if (lastKeyUp !== self.get('lastKeyUp')) return; // Search for similar topics if the user pauses typing controller.findSimilarTopics(); }, 1000); }, keyDown(e) { if (e.which === 27) { // ESC this.get('controller').send('hitEsc'); return false; } else if (e.which === 13 && (e.ctrlKey || e.metaKey)) { // CTRL+ENTER or CMD+ENTER this.get('controller').send('save'); return false; } }, _enableResizing: function() { const $replyControl = $('#reply-control'); const runResize = () => { Ember.run(() => this.resize()); }; $replyControl.DivResizer({ maxHeight(winHeight) { return winHeight - headerHeight(); }, resize: runResize, onDrag: (sizePx) => this.movePanels(sizePx) }); afterTransition($replyControl, runResize); this.set('controller.view', this); positioningWorkaround(this.$()); }.on('didInsertElement'), _unlinkView: function() { this.set('controller.view', null); }.on('willDestroyElement'), click() { this.get('controller').send('openIfDraft'); }, // Called after the preview renders. Debounced for performance afterRender() { if (this._state !== "inDOM") { return; } const $wmdPreview = this.$('.wmd-preview'); if ($wmdPreview.length === 0) return; const post = this.get('model.post'); let 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.get('refreshedPost')) { refresh = true; post.set('refreshedPost', true); } // Load the post processing effects $('a.onebox', $wmdPreview).each(function(i, e) { Discourse.Onebox.load(e, refresh); }); const unseen = linkSeenMentions($wmdPreview, this.siteSettings); if (unseen.length) { Ember.run.debounce(this, this._renderUnseen, $wmdPreview, unseen, 500); } this.trigger('previewRefreshed', $wmdPreview); }, _renderUnseen: function($wmdPreview, unseen) { fetchUnseenMentions($wmdPreview, unseen, this.siteSettings).then(() => { linkSeenMentions($wmdPreview, this.siteSettings); this.trigger('previewRefreshed', $wmdPreview); }); }, _applyEmojiAutocomplete() { if (!this.siteSettings.enable_emoji) { return; } const template = this.container.lookup('template:emoji-selector-autocomplete.raw'); this.$('.wmd-input').autocomplete({ template: template, key: ":", transformComplete(v) { return v.code + ":"; }, dataSource(term){ return new Ember.RSVP.Promise(function(resolve) { const full = ":" + term; term = term.toLowerCase(); if (term === "") { return resolve(["smile", "smiley", "wink", "sunny", "blush"]); } if (Discourse.Emoji.translations[full]) { return resolve([Discourse.Emoji.translations[full]]); } const options = Discourse.Emoji.search(term, {maxResults: 5}); return resolve(options); }).then(function(list) { return list.map(function(i) { return {code: i, src: Discourse.Emoji.urlFor(i)}; }); }); } }); }, initEditor() { // 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 let $wmdInput; const self = this; this.wmdInput = $wmdInput = this.$('.wmd-input'); if ($wmdInput.length === 0 || $wmdInput.data('init') === true) return; loadScript('defer/html-sanitizer-bundle'); ComposerView.trigger("initWmdEditor"); this._applyEmojiAutocomplete(); const template = this.container.lookup('template:user-selector-autocomplete.raw'); $wmdInput.data('init', true); $wmdInput.autocomplete({ template: template, dataSource(term) { return userSearch({ term: term, topicId: self.get('controller.controllers.topic.model.id'), includeGroups: true }); }, key: "@", transformComplete(v) { return v.username ? v.username : v.usernames.join(", @"); } }); this.editor = Discourse.Markdown.createEditor({ containerElement: this.element, lookupAvatarByPostNumber(postNumber, topicId) { const posts = self.get('controller.controllers.topic.model.postStream.posts'); if (posts && topicId === self.get('controller.controllers.topic.model.id')) { const quotedPost = posts.findProperty("post_number", postNumber); if (quotedPost) { const username = quotedPost.get('username'), uploadId = quotedPost.get('uploaded_avatar_id'); return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId)); } } } }); // HACK to change the upload icon of the composer's toolbar if (!Discourse.Utilities.allowsAttachments()) { Em.run.scheduleOnce("afterRender", function() { $("#wmd-image-button").addClass("image-only"); }); } 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(); const saveDraft = debounce((function() { return self.get('controller').saveDraft(); }), 2000); $wmdInput.keyup(function() { saveDraft(); return true; }); const $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(); const $uploadTarget = $("#reply-control"), csrf = Discourse.Session.currentProp("csrfToken"), reset = () => this.setProperties({ uploadProgress: 0, isUploading: false }); var cancelledByTheUser; this.messageBus.subscribe("/uploads/composer", upload => { if (!cancelledByTheUser) { if (upload && upload.url) { const markdown = Discourse.Utilities.getUploadMarkdown(upload); this.addMarkdown(markdown + " "); } else { Discourse.Utilities.displayErrorForUpload(upload); } } // reset upload state reset(); }); $uploadTarget.fileupload({ url: Discourse.getURL("/uploads.json?client_id=" + this.messageBus.clientId + "&authenticity_token=" + encodeURIComponent(csrf)), dataType: "json", pasteZone: $uploadTarget, }); $uploadTarget.on("fileuploadsubmit", (e, data) => { const isValid = Discourse.Utilities.validateUploadedFiles(data.files); data.formData = { type: "composer" }; this.setProperties({ uploadProgress: 0, isUploading: isValid }); return isValid; }); $uploadTarget.on("fileuploadsend", (e, data) => { // hide the "file selector" modal this.get("controller").send("closeModal"); // deal with cancellation cancelledByTheUser = false; if (data["xhr"]) { const jqHXR = data.xhr(); if (jqHXR) { // need to wait for the link to show up in the DOM Em.run.schedule("afterRender", () => { const $cancel = $("#cancel-file-upload"); $cancel.on("click", () => { if (jqHXR) { cancelledByTheUser = true; // might trigger a "fileuploadfail" event with status = 0 jqHXR.abort(); // make sure we always reset the uploading status reset(); } // unbind $cancel.off("click"); }); }); } } }); $uploadTarget.on("fileuploadprogressall", (e, data) => { const progress = parseInt(data.loaded / data.total * 100, 10); this.set("uploadProgress", progress); }); $uploadTarget.on("fileuploadfail", (e, data) => { reset(); if (!cancelledByTheUser) { 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. const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); if (uaMatch && parseInt(uaMatch[1]) >= 24) { self.$().append( Ember.$("
") ); 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. const textarea = self.$("textarea")[0], selectionStart = textarea.selectionStart, selectionEnd = textarea.selectionEnd; // Focus the contenteditable div. const 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() { const pastedImg = contentEditableDiv.find('img'); if ( pastedImg.length === 1 ) { pastedImg.remove(); } // For restoring the selection. textarea.focus(); const textareaContent = $(textarea).val(), startContent = textareaContent.substring(0, selectionStart), endContent = textareaContent.substring(selectionEnd); const 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