import userSearch from 'discourse/lib/user-search'; import { default as computed, observes, on } from 'ember-addons/ember-computed-decorators'; import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse/lib/link-category-hashtags'; import { linkSeenTagHashtags, fetchUnseenTagHashtags } from 'discourse/lib/link-tag-hashtag'; import Composer from 'discourse/models/composer'; import { load } from 'pretty-text/oneboxer'; import { applyInlineOneboxes } from 'pretty-text/inline-oneboxer'; import { ajax } from 'discourse/lib/ajax'; import InputValidation from 'discourse/models/input-validation'; import { findRawTemplate } from 'discourse/lib/raw-templates'; import { tinyAvatar, displayErrorForUpload, getUploadMarkdown, validateUploadedFiles, formatUsername, clipboardData } from 'discourse/lib/utilities'; import { cacheShortUploadUrl, resolveAllShortUrls } from 'pretty-text/image-short-url'; const REBUILD_SCROLL_MAP_EVENTS = [ 'composer:resized', 'composer:typed-reply' ]; export default Ember.Component.extend({ classNameBindings: ['showToolbar:toolbar-visible', ':wmd-controls'], uploadProgress: 0, _xhr: null, shouldBuildScrollMap: true, scrollMap: null, @computed uploadPlaceholder() { return `[${I18n.t('uploading')}]() `; }, @observes('composer.uploadCancelled') _cancelUpload() { if (!this.get('composer.uploadCancelled')) { return; } this.set('composer.uploadCancelled', false); if (this._xhr) { this._xhr._userCancelled = true; this._xhr.abort(); } this._resetUpload(true); }, @computed markdownOptions() { return { previewing: true, formatUsername, lookupAvatarByPostNumber: (postNumber, topicId) => { const topic = this.get('topic'); if (!topic) { return; } const posts = topic.get('postStream.posts'); if (posts && topicId === topic.get('id')) { const quotedPost = posts.findBy("post_number", postNumber); if (quotedPost) { return tinyAvatar(quotedPost.get('avatar_template')); } } }, lookupPrimaryUserGroupByPostNumber: (postNumber, topicId) => { const topic = this.get('topic'); if (!topic) { return; } const posts = topic.get('postStream.posts'); if (posts && topicId === topic.get('id')) { const quotedPost = posts.findBy("post_number", postNumber); if (quotedPost) { return quotedPost.primary_group_name; } } } }; }, @on('didInsertElement') _composerEditorInit() { const topicId = this.get('topic.id'); const $input = this.$('.d-editor-input'); const $preview = this.$('.d-editor-preview-wrapper'); if (this.siteSettings.enable_mentions) { $input.autocomplete({ template: findRawTemplate('user-selector-autocomplete'), dataSource: term => userSearch({ term, topicId, includeMentionableGroups: true }), key: "@", transformComplete: v => v.username || v.name }); } if (this._enableAdvancedEditorPreviewSync()) { this._initInputPreviewSync($input, $preview); } else { $input.on('scroll', () => Ember.run.throttle(this, this._syncEditorAndPreviewScroll, $input, $preview, 20 )); } // Focus on the body unless we have a title if (!this.get('composer.canEditTitle') && !this.capabilities.isIOS) { this.$('.d-editor-input').putCursorAtEnd(); } this._bindUploadTarget(); this.appEvents.trigger('composer:will-open'); }, @computed('composer.reply', 'composer.replyLength', 'composer.missingReplyCharacters', 'composer.minimumPostLength', 'lastValidatedAt') validation(reply, replyLength, missingReplyCharacters, minimumPostLength, lastValidatedAt) { const postType = this.get('composer.post.post_type'); if (postType === this.site.get('post_types.small_action')) { return; } let reason; if (replyLength < 1) { reason = I18n.t('composer.error.post_missing'); } else if (missingReplyCharacters > 0) { reason = I18n.t('composer.error.post_length', {min: minimumPostLength}); const tl = Discourse.User.currentProp("trust_level"); if (tl === 0 || tl === 1) { reason += "
" + I18n.t('composer.error.try_like'); } } if (reason) { return InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); } }, _enableAdvancedEditorPreviewSync() { return this.siteSettings.enable_advanced_editor_preview_sync; }, _resetShouldBuildScrollMap() { this.set('shouldBuildScrollMap', true); }, _initInputPreviewSync($input, $preview) { REBUILD_SCROLL_MAP_EVENTS.forEach(event => { this.appEvents.on(event, this, this._resetShouldBuildScrollMap); }); Ember.run.scheduleOnce("afterRender", () => { $input.on('touchstart mouseenter', () => { if (!$preview.is(":visible")) return; $preview.off('scroll'); $input.on('scroll', () => { this._syncScroll(this._syncEditorAndPreviewScroll, $input, $preview); }); }); $preview.on('touchstart mouseenter', () => { $input.off('scroll'); $preview.on('scroll', () => { this._syncScroll(this._syncPreviewAndEditorScroll, $input, $preview); }); }); }); }, _syncScroll($callback, $input, $preview) { if (!this.get('scrollMap') || this.get('shouldBuildScrollMap')) { this.set('scrollMap', this._buildScrollMap($input, $preview)); this.set('shouldBuildScrollMap', false); } Ember.run.throttle(this, $callback, $input, $preview, this.get('scrollMap'), 20); }, _teardownInputPreviewSync() { [this.$('.d-editor-input'), this.$('.d-editor-preview-wrapper')].forEach($element => { $element.off("mouseenter touchstart"); $element.off("scroll"); }); REBUILD_SCROLL_MAP_EVENTS.forEach(event => { this.appEvents.off(event, this, this._resetShouldBuildScrollMap); });; }, // Adapted from https://github.com/markdown-it/markdown-it.github.io _buildScrollMap($input, $preview) { let sourceLikeDiv = $('
').css({ position: 'absolute', height: 'auto', visibility: 'hidden', width: $input[0].clientWidth, 'font-size': $input.css('font-size'), 'font-family': $input.css('font-family'), 'line-height': $input.css('line-height'), 'white-space': $input.css('white-space') }).appendTo('body'); const linesMap = []; let numberOfLines = 0; $input.val().split('\n').forEach(text => { linesMap.push(numberOfLines); if (text.length === 0) { numberOfLines++; } else { sourceLikeDiv.text(text); let height; let lineHeight; height = parseFloat(sourceLikeDiv.css('height')); lineHeight = parseFloat(sourceLikeDiv.css('line-height')); numberOfLines += Math.round(height / lineHeight); } }); linesMap.push(numberOfLines); sourceLikeDiv.remove(); const previewOffsetTop = $preview.offset().top; const offset = $preview.scrollTop() - previewOffsetTop - ($input.offset().top - previewOffsetTop); const nonEmptyList = []; const scrollMap = []; for (let i = 0; i < numberOfLines; i++) { scrollMap.push(-1); }; nonEmptyList.push(0); scrollMap[0] = 0; $preview.find('.preview-sync-line').each((_, element) => { let $element = $(element); let lineNumber = $element.data('line-number'); let linesToTop = linesMap[lineNumber]; if (linesToTop !== 0) { nonEmptyList.push(linesToTop); } scrollMap[linesToTop] = Math.round($element.offset().top + offset); }); nonEmptyList.push(numberOfLines); scrollMap[numberOfLines] = $preview[0].scrollHeight; let position = 0; for (let i = 1; i < numberOfLines; i++) { if (scrollMap[i] !== -1) { position++; continue; } let top = nonEmptyList[position]; let bottom = nonEmptyList[position + 1]; scrollMap[i] = (( scrollMap[bottom] * (i - top) + scrollMap[top] * (bottom - i) ) / (bottom - top)).toFixed(2); }; return scrollMap; }, _syncEditorAndPreviewScroll($input, $preview, scrollMap) { if (this._enableAdvancedEditorPreviewSync()) { let scrollTop; const inputHeight = $input.height(); const inputScrollHeight = $input[0].scrollHeight; const inputClientHeight = $input[0].clientHeight; const scrollable = inputScrollHeight > inputClientHeight; if (scrollable && ((inputHeight + $input.scrollTop() + 100) > inputScrollHeight)) { scrollTop = $preview[0].scrollHeight; } else { const lineHeight = parseFloat($input.css('line-height')); const lineNumber = Math.floor($input.scrollTop() / lineHeight); scrollTop = scrollMap[lineNumber]; } $preview.stop(true).animate({ scrollTop }, 100, 'linear'); } else { if (!$input) { return; } if ($input.scrollTop() === 0) { $preview.scrollTop(0); return; } const inputHeight = $input[0].scrollHeight; const previewHeight = $preview[0].scrollHeight; if (($input.height() + $input.scrollTop() + 100) > inputHeight) { // cheat, special case for bottom $preview.scrollTop(previewHeight); return; } const scrollPosition = $input.scrollTop(); const factor = previewHeight / inputHeight; const desired = scrollPosition * factor; $preview.scrollTop(desired + 50); } }, _syncPreviewAndEditorScroll($input, $preview, scrollMap) { if (scrollMap.length < 1) return; let scrollTop; const previewScrollTop = $preview.scrollTop(); if (($preview.height() + previewScrollTop + 100) > $preview[0].scrollHeight) { scrollTop = $input[0].scrollHeight; } else { const lineHeight = parseFloat($input.css('line-height')); scrollTop = lineHeight * scrollMap.findIndex(offset => offset > previewScrollTop); } $input.stop(true).animate({ scrollTop }, 100, 'linear'); }, _renderUnseenMentions($preview, unseen) { // 'Create a New Topic' scenario is not supported (per conversation with codinghorror) // https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7 fetchUnseenMentions(unseen, this.get('composer.topic.id')).then(() => { linkSeenMentions($preview, this.siteSettings); this._warnMentionedGroups($preview); this._warnCannotSeeMention($preview); }); }, _renderUnseenCategoryHashtags($preview, unseen) { fetchUnseenCategoryHashtags(unseen).then(() => { linkSeenCategoryHashtags($preview); }); }, _renderUnseenTagHashtags($preview, unseen) { fetchUnseenTagHashtags(unseen).then(() => { linkSeenTagHashtags($preview); }); }, _loadInlineOneboxes(inline) { applyInlineOneboxes(inline, ajax); }, _loadOneboxes($oneboxes) { const post = this.get('composer.post'); let refresh = false; // If we are editing a post, we'll refresh its contents once. if (post && !post.get('refreshedPost')) { refresh = true; post.set('refreshedPost', true); } $oneboxes.each((_, o) => load(o, refresh, ajax, this.currentUser.id)); }, _warnMentionedGroups($preview) { Ember.run.scheduleOnce('afterRender', () => { var found = this.get('warnedGroupMentions') || []; $preview.find('.mention-group.notify').each((idx,e) => { const $e = $(e); var name = $e.data('name'); if (found.indexOf(name) === -1){ this.sendAction('groupsMentioned', [{name: name, user_count: $e.data('mentionable-user-count'), max_mentions: $e.data('max-mentions')}]); found.push(name); } }); this.set('warnedGroupMentions', found); }); }, _warnCannotSeeMention($preview) { const composerDraftKey = this.get('composer.draftKey'); if (composerDraftKey === Composer.CREATE_TOPIC || composerDraftKey === Composer.NEW_PRIVATE_MESSAGE_KEY || composerDraftKey === Composer.REPLY_AS_NEW_TOPIC_KEY || composerDraftKey === Composer.REPLY_AS_NEW_PRIVATE_MESSAGE_KEY) { return; } Ember.run.scheduleOnce('afterRender', () => { let found = this.get('warnedCannotSeeMentions') || []; $preview.find('.mention.cannot-see').each((idx,e) => { const $e = $(e); let name = $e.data('name'); if (found.indexOf(name) === -1) { this.sendAction('cannotSeeMention', [{ name: name }]); found.push(name); } }); this.set('warnedCannotSeeMentions', found); }); }, _resetUpload(removePlaceholder) { if (this._validUploads > 0) { this._validUploads--; } if (this._validUploads === 0) { this.setProperties({ uploadProgress: 0, isUploading: false, isCancellable: false }); } if (removePlaceholder) { this.appEvents.trigger('composer:replace-text', this.get('uploadPlaceholder'), ""); } }, _bindUploadTarget() { this._unbindUploadTarget(); // in case it's still bound, let's clean it up first this._pasted = false; const $element = this.$(); const csrf = this.session.get('csrfToken'); const uploadPlaceholder = this.get('uploadPlaceholder'); $element.fileupload({ url: Discourse.getURL(`/uploads.json?client_id=${this.messageBus.clientId}&authenticity_token=${encodeURIComponent(csrf)}`), dataType: "json", pasteZone: $element, }); $element.on('fileuploadpaste', (e) => { this._pasted = true; if (!$(".d-editor-input").is(":focus")) { return; } const { canUpload, canPasteHtml } = clipboardData(e, true); if (!canUpload || canPasteHtml) { e.preventDefault(); } }); $element.on('fileuploadsubmit', (e, data) => { const isPrivateMessage = this.get("composer.privateMessage"); data.formData = { type: "composer" }; if (isPrivateMessage) data.formData.for_private_message = true; if (this._pasted) data.formData.pasted = true; const opts = { isPrivateMessage, allowStaffToUploadAnyFileInPm: this.siteSettings.allow_staff_to_upload_any_file_in_pm, }; const isUploading = validateUploadedFiles(data.files, opts); this.setProperties({ uploadProgress: 0, isUploading }); return isUploading; }); $element.on("fileuploadprogressall", (e, data) => { this.set("uploadProgress", parseInt(data.loaded / data.total * 100, 10)); }); $element.on("fileuploadsend", (e, data) => { this._pasted = false; this._validUploads++; this.appEvents.trigger('composer:insert-text', uploadPlaceholder); if (data.xhr && data.originalFiles.length === 1) { this.set("isCancellable", true); this._xhr = data.xhr(); } }); $element.on("fileuploaddone", (e, data) => { let upload = data.result; if (!this._xhr || !this._xhr._userCancelled) { const markdown = getUploadMarkdown(upload); cacheShortUploadUrl(upload.short_url, upload.url); this.appEvents.trigger('composer:replace-text', uploadPlaceholder, markdown); this._resetUpload(false); } else { this._resetUpload(true); } }); $element.on("fileuploadfail", (e, data) => { this._resetUpload(true); const userCancelled = this._xhr && this._xhr._userCancelled; this._xhr = null; if (!userCancelled) { displayErrorForUpload(data); } }); if (this.site.mobileView) { $("#reply-control .mobile-file-upload").on("click.uploader", function () { // redirect the click on the hidden file input $("#mobile-uploader").click(); }); } this._firefoxPastingHack(); }, // Believe it or not pasting an image in Firefox doesn't work without this code _firefoxPastingHack() { const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); if (uaMatch) { let uaVersion = parseInt(uaMatch[1]); if (uaVersion < 24 || 50 <= uaVersion) { // The hack is no longer required in FF 50 and later. // See: https://bugzilla.mozilla.org/show_bug.cgi?id=906420 return; } this.$().append( Ember.$("
") ); this.$("textarea").off('keydown.contenteditable'); this.$("textarea").on('keydown.contenteditable', 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 = this.$("textarea")[0]; const selectionStart = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; // Focus the contenteditable div. const contentEditableDiv = this.$('#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(() => { 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 tag for the image. const imageSrc = pastedImg.attr('src'); if (imageSrc.match(/^data:image/)) { // Restore the cursor position, and remove any selected text. restoreSelection(""); // Create a Blob to upload. const image = new Image(); image.onload = () => { // Create a new canvas. const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); canvas.height = image.height; canvas.width = image.width; const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); canvas.toBlob(blob => this.$().fileupload('add', {files: blob})); }; image.src = imageSrc; } else { restoreSelection(""); } } contentEditableDiv.html(''); }, 100); } }); } }, @on('willDestroyElement') _unbindUploadTarget() { this._validUploads = 0; $("#reply-control .mobile-file-upload").off("click.uploader"); this.messageBus.unsubscribe("/uploads/composer"); const $uploadTarget = this.$(); try { $uploadTarget.fileupload("destroy"); } catch (e) { /* wasn't initialized yet */ } $uploadTarget.off(); }, @on('willDestroyElement') _composerClosed() { this.appEvents.trigger('composer:will-close'); Ember.run.next(() => { $('#main-outlet').css('padding-bottom', 0); // need to wait a bit for the "slide down" transition of the composer Ember.run.later(() => this.appEvents.trigger("composer:closed"), 400); }); if (this._enableAdvancedEditorPreviewSync()) this._teardownInputPreviewSync(); }, actions: { importQuote(toolbarEvent) { this.sendAction('importQuote', toolbarEvent); }, onExpandPopupMenuOptions(toolbarEvent) { const selected = toolbarEvent.selected; toolbarEvent.selectText(selected.start, selected.end - selected.start); this.sendAction('storeToolbarState', toolbarEvent); }, togglePreview() { this.sendAction('togglePreview'); }, showUploadModal(toolbarEvent) { this.sendAction('showUploadSelector', toolbarEvent); }, extraButtons(toolbar) { toolbar.addButton({ id: 'quote', group: 'fontStyles', icon: 'comment-o', sendAction: 'importQuote', title: 'composer.quote_post_title', unshift: true }); toolbar.addButton({ id: 'upload', group: 'insertions', icon: 'upload', title: 'upload', sendAction: 'showUploadModal' }); toolbar.addButton({ id: 'options', group: 'extras', icon: 'gear', title: 'composer.options', sendAction: 'onExpandPopupMenuOptions', popupMenu: true }); if (this.site.mobileView) { toolbar.addButton({ id: 'preview', group: 'mobileExtras', icon: 'television', title: 'composer.show_preview', sendAction: 'togglePreview' }); } }, previewUpdated($preview) { // Paint mentions const unseenMentions = linkSeenMentions($preview, this.siteSettings); if (unseenMentions.length) { Ember.run.debounce(this, this._renderUnseenMentions, $preview, unseenMentions, 450); } this._warnMentionedGroups($preview); this._warnCannotSeeMention($preview); // Paint category hashtags const unseenCategoryHashtags = linkSeenCategoryHashtags($preview); if (unseenCategoryHashtags.length) { Ember.run.debounce(this, this._renderUnseenCategoryHashtags, $preview, unseenCategoryHashtags, 450); } // Paint tag hashtags if (this.siteSettings.tagging_enabled) { const unseenTagHashtags = linkSeenTagHashtags($preview); if (unseenTagHashtags.length) { Ember.run.debounce(this, this._renderUnseenTagHashtags, $preview, unseenTagHashtags, 450); } } // Paint oneboxes const $oneboxes = $('a.onebox', $preview); if ($oneboxes.length > 0 && $oneboxes.length <= this.siteSettings.max_oneboxes_per_post) { Ember.run.debounce(this, this._loadOneboxes, $oneboxes, 450); } // Short upload urls need resolution resolveAllShortUrls(ajax); let inline = {}; $('a.inline-onebox-loading', $preview).each(function(index, link) { let $link = $(link); $link.removeClass('inline-onebox-loading'); let text = $link.text(); inline[text] = inline[text] || []; inline[text].push($link); }); if (Object.keys(inline).length > 0) { Ember.run.debounce(this, this._loadInlineOneboxes, inline, 450); } if (this._enableAdvancedEditorPreviewSync()) { this._syncScroll( this._syncEditorAndPreviewScroll, this.$('.d-editor-input'), $preview ); } this.trigger('previewRefreshed', $preview); this.sendAction('afterRefresh', $preview); }, } });