|
| 1 | +import userSearch from 'discourse/lib/user-search'; |
| 2 | +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; |
| 3 | +import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; |
| 4 | + |
| 5 | +export default Ember.Component.extend({ |
| 6 | + classNames: ['wmd-controls'], |
| 7 | + classNameBindings: [':wmd-controls', 'showPreview', 'showPreview::hide-preview'], |
| 8 | + |
| 9 | + uploadProgress: 0, |
| 10 | + showPreview: true, |
| 11 | + _xhr: null, |
| 12 | + |
| 13 | + @computed |
| 14 | + uploadPlaceholder() { |
| 15 | + return `[${I18n.t('uploading')}]() `; |
| 16 | + }, |
| 17 | + |
| 18 | + @on('init') |
| 19 | + _setupPreview() { |
| 20 | + const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); |
| 21 | + this.set('showPreview', val === 'true'); |
| 22 | + }, |
| 23 | + |
| 24 | + @computed('showPreview') |
| 25 | + toggleText: function(showPreview) { |
| 26 | + return showPreview ? I18n.t('composer.hide_preview') : I18n.t('composer.show_preview'); |
| 27 | + }, |
| 28 | + |
| 29 | + @computed |
| 30 | + markdownOptions() { |
| 31 | + return { |
| 32 | + lookupAvatarByPostNumber: (postNumber, topicId) => { |
| 33 | + const topic = this.get('topic'); |
| 34 | + if (!topic) { return; } |
| 35 | + |
| 36 | + const posts = topic.get('postStream.posts'); |
| 37 | + if (posts && topicId === topic.get('id')) { |
| 38 | + const quotedPost = posts.findProperty("post_number", postNumber); |
| 39 | + if (quotedPost) { |
| 40 | + return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template')); |
| 41 | + } |
| 42 | + } |
| 43 | + } |
| 44 | + }; |
| 45 | + }, |
| 46 | + |
| 47 | + @on('didInsertElement') |
| 48 | + _composerEditorInit() { |
| 49 | + const topicId = this.get('topic.id'); |
| 50 | + const template = this.container.lookup('template:user-selector-autocomplete.raw'); |
| 51 | + const $input = this.$('.d-editor-input'); |
| 52 | + $input.autocomplete({ |
| 53 | + template, |
| 54 | + dataSource: term => userSearch({ term, topicId, includeGroups: true }), |
| 55 | + key: "@", |
| 56 | + transformComplete: v => v.username || v.usernames.join(", @") |
| 57 | + }); |
| 58 | + |
| 59 | + // Focus on the body unless we have a title |
| 60 | + if (!this.get('composer.canEditTitle') && !Discourse.Mobile.mobileView) { |
| 61 | + this.$('.d-editor-input').putCursorAtEnd(); |
| 62 | + } |
| 63 | + |
| 64 | + this._bindUploadTarget(); |
| 65 | + this.appEvents.trigger('composer:opened'); |
| 66 | + }, |
| 67 | + |
| 68 | + @computed('composer.reply', 'composer.replyLength', 'composer.missingReplyCharacters', 'composer.minimumPostLength', 'lastValidatedAt') |
| 69 | + validation(reply, replyLength, missingReplyCharacters, minimumPostLength, lastValidatedAt) { |
| 70 | + const postType = this.get('composer.post.post_type'); |
| 71 | + if (postType === this.site.get('post_types.small_action')) { return; } |
| 72 | + |
| 73 | + let reason; |
| 74 | + if (replyLength < 1) { |
| 75 | + reason = I18n.t('composer.error.post_missing'); |
| 76 | + } else if (missingReplyCharacters > 0) { |
| 77 | + reason = I18n.t('composer.error.post_length', {min: minimumPostLength}); |
| 78 | + const tl = Discourse.User.currentProp("trust_level"); |
| 79 | + if (tl === 0 || tl === 1) { |
| 80 | + reason += "<br/>" + I18n.t('composer.error.try_like'); |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + if (reason) { |
| 85 | + return Discourse.InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); |
| 86 | + } |
| 87 | + }, |
| 88 | + |
| 89 | + _renderUnseen: function($preview, unseen) { |
| 90 | + fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => { |
| 91 | + linkSeenMentions($preview, this.siteSettings); |
| 92 | + this.trigger('previewRefreshed', $preview); |
| 93 | + }); |
| 94 | + }, |
| 95 | + |
| 96 | + _resetUpload() { |
| 97 | + this.setProperties({ uploadProgress: 0, isUploading: false }); |
| 98 | + this.set('composer.reply', this.get('composer.reply').replace(this.get('uploadPlaceholder'), "")); |
| 99 | + }, |
| 100 | + |
| 101 | + _bindUploadTarget() { |
| 102 | + this._unbindUploadTarget(); // in case it's still bound, let's clean it up first |
| 103 | + |
| 104 | + const $element = this.$();; |
| 105 | + const csrf = this.session.get('csrfToken'); |
| 106 | + const uploadPlaceholder = this.get('uploadPlaceholder'); |
| 107 | + |
| 108 | + $element.fileupload({ |
| 109 | + url: Discourse.getURL(`/uploads.json?client_id=${this.messageBus.clientId}&authenticity_token=${encodeURIComponent(csrf)}`), |
| 110 | + dataType: "json", |
| 111 | + pasteZone: $element, |
| 112 | + }); |
| 113 | + |
| 114 | + $element.on('fileuploadsubmit', (e, data) => { |
| 115 | + const isUploading = Discourse.Utilities.validateUploadedFiles(data.files); |
| 116 | + data.formData = { type: "composer" }; |
| 117 | + this.setProperties({ uploadProgress: 0, isUploading }); |
| 118 | + return isUploading; |
| 119 | + }); |
| 120 | + |
| 121 | + $element.on("fileuploadprogressall", (e, data) => { |
| 122 | + this.set("uploadProgress", parseInt(data.loaded / data.total * 100, 10)); |
| 123 | + }); |
| 124 | + |
| 125 | + $element.on("fileuploadsend", (e, data) => { |
| 126 | + // add upload placeholder |
| 127 | + this.appEvents.trigger('composer:insert-text', uploadPlaceholder); |
| 128 | + |
| 129 | + if (data.xhr) { |
| 130 | + this._xhr = data.xhr(); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + $element.on("fileuploadfail", (e, data) => { |
| 135 | + this._resetUpload(); |
| 136 | + |
| 137 | + const userCancelled = this._xhr && this._xhr._userCancelled; |
| 138 | + this._xhr = null; |
| 139 | + |
| 140 | + if (!userCancelled) { |
| 141 | + Discourse.Utilities.displayErrorForUpload(data); |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + this.messageBus.subscribe("/uploads/composer", upload => { |
| 146 | + // replace upload placeholder |
| 147 | + if (upload && upload.url) { |
| 148 | + if (!this._xhr || !this._xhr._userCancelled) { |
| 149 | + const markdown = Discourse.Utilities.getUploadMarkdown(upload); |
| 150 | + this.set('composer.reply', this.get('composer.reply').replace(uploadPlaceholder, markdown)); |
| 151 | + } |
| 152 | + } else { |
| 153 | + Discourse.Utilities.displayErrorForUpload(upload); |
| 154 | + } |
| 155 | + |
| 156 | + // reset upload state |
| 157 | + this._resetUpload(); |
| 158 | + }); |
| 159 | + |
| 160 | + if (Discourse.Mobile.mobileView) { |
| 161 | + this.$(".mobile-file-upload").on("click.uploader", function () { |
| 162 | + // redirect the click on the hidden file input |
| 163 | + $("#mobile-uploader").click(); |
| 164 | + }); |
| 165 | + } |
| 166 | + |
| 167 | + this._firefoxPastingHack(); |
| 168 | + }, |
| 169 | + |
| 170 | + // Believe it or not pasting an image in Firefox doesn't work without this code |
| 171 | + _firefoxPastingHack() { |
| 172 | + const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); |
| 173 | + if (uaMatch && parseInt(uaMatch[1]) >= 24) { |
| 174 | + this.$().append( Ember.$("<div id='contenteditable' contenteditable='true' style='height: 0; width: 0; overflow: hidden'></div>") ); |
| 175 | + this.$("textarea").off('keydown.contenteditable'); |
| 176 | + this.$("textarea").on('keydown.contenteditable', event => { |
| 177 | + // Catch Ctrl+v / Cmd+v and hijack focus to a contenteditable div. We can't |
| 178 | + // use the onpaste event because for some reason the paste isn't resumed |
| 179 | + // after we switch focus, probably because it is being executed too late. |
| 180 | + if ((event.ctrlKey || event.metaKey) && (event.keyCode === 86)) { |
| 181 | + // Save the current textarea selection. |
| 182 | + const textarea = this.$("textarea")[0]; |
| 183 | + const selectionStart = textarea.selectionStart; |
| 184 | + const selectionEnd = textarea.selectionEnd; |
| 185 | + |
| 186 | + // Focus the contenteditable div. |
| 187 | + const contentEditableDiv = this.$('#contenteditable'); |
| 188 | + contentEditableDiv.focus(); |
| 189 | + |
| 190 | + // The paste doesn't finish immediately and we don't have any onpaste |
| 191 | + // event, so wait for 100ms which _should_ be enough time. |
| 192 | + setTimeout(() => { |
| 193 | + const pastedImg = contentEditableDiv.find('img'); |
| 194 | + |
| 195 | + if ( pastedImg.length === 1 ) { |
| 196 | + pastedImg.remove(); |
| 197 | + } |
| 198 | + |
| 199 | + // For restoring the selection. |
| 200 | + textarea.focus(); |
| 201 | + const textareaContent = $(textarea).val(), |
| 202 | + startContent = textareaContent.substring(0, selectionStart), |
| 203 | + endContent = textareaContent.substring(selectionEnd); |
| 204 | + |
| 205 | + const restoreSelection = function(pastedText) { |
| 206 | + $(textarea).val( startContent + pastedText + endContent ); |
| 207 | + textarea.selectionStart = selectionStart + pastedText.length; |
| 208 | + textarea.selectionEnd = textarea.selectionStart; |
| 209 | + }; |
| 210 | + |
| 211 | + if (contentEditableDiv.html().length > 0) { |
| 212 | + // If the image wasn't the only pasted content we just give up and |
| 213 | + // fall back to the original pasted text. |
| 214 | + contentEditableDiv.find("br").replaceWith("\n"); |
| 215 | + restoreSelection(contentEditableDiv.text()); |
| 216 | + } else { |
| 217 | + // Depending on how the image is pasted in, we may get either a |
| 218 | + // normal URL or a data URI. If we get a data URI we can convert it |
| 219 | + // to a Blob and upload that, but if it is a regular URL that |
| 220 | + // operation is prevented for security purposes. When we get a regular |
| 221 | + // URL let's just create an <img> tag for the image. |
| 222 | + const imageSrc = pastedImg.attr('src'); |
| 223 | + |
| 224 | + if (imageSrc.match(/^data:image/)) { |
| 225 | + // Restore the cursor position, and remove any selected text. |
| 226 | + restoreSelection(""); |
| 227 | + |
| 228 | + // Create a Blob to upload. |
| 229 | + const image = new Image(); |
| 230 | + image.onload = function() { |
| 231 | + // Create a new canvas. |
| 232 | + const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); |
| 233 | + canvas.height = image.height; |
| 234 | + canvas.width = image.width; |
| 235 | + const ctx = canvas.getContext('2d'); |
| 236 | + ctx.drawImage(image, 0, 0); |
| 237 | + |
| 238 | + canvas.toBlob(blob => this.$().fileupload('add', {files: blob})); |
| 239 | + }; |
| 240 | + image.src = imageSrc; |
| 241 | + } else { |
| 242 | + restoreSelection("<img src='" + imageSrc + "'>"); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + contentEditableDiv.html(''); |
| 247 | + }, 100); |
| 248 | + } |
| 249 | + }); |
| 250 | + } |
| 251 | + }, |
| 252 | + |
| 253 | + @on('willDestroyElement') |
| 254 | + _unbindUploadTarget() { |
| 255 | + this.$(".mobile-file-upload").off("click.uploader"); |
| 256 | + this.messageBus.unsubscribe("/uploads/composer"); |
| 257 | + const $uploadTarget = this.$(); |
| 258 | + try { $uploadTarget.fileupload("destroy"); } |
| 259 | + catch (e) { /* wasn't initialized yet */ } |
| 260 | + $uploadTarget.off(); |
| 261 | + }, |
| 262 | + |
| 263 | + @on('willDestroyElement') |
| 264 | + _composerClosed() { |
| 265 | + Ember.run.next(() => { |
| 266 | + $('#main-outlet').css('padding-bottom', 0); |
| 267 | + // need to wait a bit for the "slide down" transition of the composer |
| 268 | + Ember.run.later(() => this.appEvents.trigger("composer:closed"), 400); |
| 269 | + }); |
| 270 | + }, |
| 271 | + |
| 272 | + actions: { |
| 273 | + importQuote(toolbarEvent) { |
| 274 | + this.sendAction('importQuote', toolbarEvent); |
| 275 | + }, |
| 276 | + |
| 277 | + cancelUpload() { |
| 278 | + if (this._xhr) { |
| 279 | + this._xhr._userCancelled = true; |
| 280 | + this._xhr.abort(); |
| 281 | + this._resetUpload(); |
| 282 | + } |
| 283 | + this._resetUpload(); |
| 284 | + }, |
| 285 | + |
| 286 | + showOptions() { |
| 287 | + const myPos = this.$().position(); |
| 288 | + const buttonPos = this.$('.options').position(); |
| 289 | + |
| 290 | + this.sendAction('showOptions', { position: "absolute", |
| 291 | + left: myPos.left + buttonPos.left, |
| 292 | + top: myPos.top + buttonPos.top }); |
| 293 | + }, |
| 294 | + |
| 295 | + showUploadModal(toolbarEvent) { |
| 296 | + this.sendAction('showUploadSelector', toolbarEvent); |
| 297 | + }, |
| 298 | + |
| 299 | + togglePreview() { |
| 300 | + this.toggleProperty('showPreview'); |
| 301 | + this.keyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); |
| 302 | + }, |
| 303 | + |
| 304 | + extraButtons(toolbar) { |
| 305 | + toolbar.addButton({ |
| 306 | + id: 'quote', |
| 307 | + group: 'fontStyles', |
| 308 | + icon: 'comment-o', |
| 309 | + sendAction: 'importQuote', |
| 310 | + title: 'composer.quote_post_title', |
| 311 | + unshift: true |
| 312 | + }); |
| 313 | + |
| 314 | + toolbar.addButton({ |
| 315 | + id: 'upload', |
| 316 | + group: 'insertions', |
| 317 | + icon: 'upload', |
| 318 | + title: 'upload', |
| 319 | + sendAction: 'showUploadModal' |
| 320 | + }); |
| 321 | + |
| 322 | + if (this.get('canWhisper')) { |
| 323 | + toolbar.addButton({ |
| 324 | + id: 'options', |
| 325 | + group: 'extras', |
| 326 | + icon: 'gear', |
| 327 | + title: 'composer.options', |
| 328 | + sendAction: 'showOptions' |
| 329 | + }); |
| 330 | + } |
| 331 | + }, |
| 332 | + |
| 333 | + previewUpdated($preview) { |
| 334 | + // Paint mentions |
| 335 | + const unseen = linkSeenMentions($preview, this.siteSettings); |
| 336 | + if (unseen.length) { |
| 337 | + Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500); |
| 338 | + } |
| 339 | + |
| 340 | + const post = this.get('composer.post'); |
| 341 | + let refresh = false; |
| 342 | + |
| 343 | + // If we are editing a post, we'll refresh its contents once. This is a feature that |
| 344 | + // allows a user to refresh its contents once. |
| 345 | + if (post && !post.get('refreshedPost')) { |
| 346 | + refresh = true; |
| 347 | + post.set('refreshedPost', true); |
| 348 | + } |
| 349 | + |
| 350 | + // Paint oneboxes |
| 351 | + $('a.onebox', $preview).each((i, e) => Discourse.Onebox.load(e, refresh)); |
| 352 | + }, |
| 353 | + } |
| 354 | +}); |
0 commit comments