diff --git a/.codeclimate.yml b/.codeclimate.yml index 975bbe566c8..009a4859a06 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -6,7 +6,6 @@ languages: exclude_paths: - "app/assets/javascripts/defer/*" - - "app/assets/javascripts/discourse/lib/Markdown.Editor.js" - "app/assets/javascripts/ember-addons/*" - "lib/autospec/*" - "lib/es6_module_transpiler/*" diff --git a/.eslintignore b/.eslintignore index 5b61bf6c51f..87cbecfae70 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,9 +6,7 @@ app/assets/javascripts/pagedown_custom.js app/assets/javascripts/vendor.js app/assets/javascripts/locales/i18n.js app/assets/javascripts/defer/html-sanitizer-bundle.js -app/assets/javascripts/discourse/lib/Markdown.Editor.js app/assets/javascripts/ember-addons/ -jsapp/lib/Markdown.Editor.js lib/javascripts/locale/ lib/javascripts/messageformat.js lib/javascripts/moment.js diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 new file mode 100644 index 00000000000..046e92fc11c --- /dev/null +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -0,0 +1,354 @@ +import userSearch from 'discourse/lib/user-search'; +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; +import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; + +export default Ember.Component.extend({ + classNames: ['wmd-controls'], + classNameBindings: [':wmd-controls', 'showPreview', 'showPreview::hide-preview'], + + uploadProgress: 0, + showPreview: true, + _xhr: null, + + @computed + uploadPlaceholder() { + return `[${I18n.t('uploading')}]() `; + }, + + @on('init') + _setupPreview() { + const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); + this.set('showPreview', val === 'true'); + }, + + @computed('showPreview') + toggleText: function(showPreview) { + return showPreview ? I18n.t('composer.hide_preview') : I18n.t('composer.show_preview'); + }, + + @computed + markdownOptions() { + return { + 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.findProperty("post_number", postNumber); + if (quotedPost) { + return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template')); + } + } + } + }; + }, + + @on('didInsertElement') + _composerEditorInit() { + const topicId = this.get('topic.id'); + const template = this.container.lookup('template:user-selector-autocomplete.raw'); + const $input = this.$('.d-editor-input'); + $input.autocomplete({ + template, + dataSource: term => userSearch({ term, topicId, includeGroups: true }), + key: "@", + transformComplete: v => v.username || v.usernames.join(", @") + }); + + // Focus on the body unless we have a title + if (!this.get('composer.canEditTitle') && !Discourse.Mobile.mobileView) { + this.$('.d-editor-input').putCursorAtEnd(); + } + + this._bindUploadTarget(); + this.appEvents.trigger('composer:opened'); + }, + + @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 Discourse.InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); + } + }, + + _renderUnseen: function($preview, unseen) { + fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => { + linkSeenMentions($preview, this.siteSettings); + this.trigger('previewRefreshed', $preview); + }); + }, + + _resetUpload() { + this.setProperties({ uploadProgress: 0, isUploading: false }); + this.set('composer.reply', this.get('composer.reply').replace(this.get('uploadPlaceholder'), "")); + }, + + _bindUploadTarget() { + this._unbindUploadTarget(); // in case it's still bound, let's clean it up first + + 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('fileuploadsubmit', (e, data) => { + const isUploading = Discourse.Utilities.validateUploadedFiles(data.files); + data.formData = { type: "composer" }; + 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) => { + // add upload placeholder + this.appEvents.trigger('composer:insert-text', uploadPlaceholder); + + if (data.xhr) { + this._xhr = data.xhr(); + } + }); + + $element.on("fileuploadfail", (e, data) => { + this._resetUpload(); + + const userCancelled = this._xhr && this._xhr._userCancelled; + this._xhr = null; + + if (!userCancelled) { + Discourse.Utilities.displayErrorForUpload(data); + } + }); + + this.messageBus.subscribe("/uploads/composer", upload => { + // replace upload placeholder + if (upload && upload.url) { + if (!this._xhr || !this._xhr._userCancelled) { + const markdown = Discourse.Utilities.getUploadMarkdown(upload); + this.set('composer.reply', this.get('composer.reply').replace(uploadPlaceholder, markdown)); + } + } else { + Discourse.Utilities.displayErrorForUpload(upload); + } + + // reset upload state + this._resetUpload(); + }); + + if (Discourse.Mobile.mobileView) { + this.$(".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 && parseInt(uaMatch[1]) >= 24) { + 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 = function() { + // 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.$(".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() { + 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); + }); + }, + + actions: { + importQuote(toolbarEvent) { + this.sendAction('importQuote', toolbarEvent); + }, + + cancelUpload() { + if (this._xhr) { + this._xhr._userCancelled = true; + this._xhr.abort(); + this._resetUpload(); + } + this._resetUpload(); + }, + + showOptions() { + const myPos = this.$().position(); + const buttonPos = this.$('.options').position(); + + this.sendAction('showOptions', { position: "absolute", + left: myPos.left + buttonPos.left, + top: myPos.top + buttonPos.top }); + }, + + showUploadModal(toolbarEvent) { + this.sendAction('showUploadSelector', toolbarEvent); + }, + + togglePreview() { + this.toggleProperty('showPreview'); + this.keyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); + }, + + 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' + }); + + if (this.get('canWhisper')) { + toolbar.addButton({ + id: 'options', + group: 'extras', + icon: 'gear', + title: 'composer.options', + sendAction: 'showOptions' + }); + } + }, + + previewUpdated($preview) { + // Paint mentions + const unseen = linkSeenMentions($preview, this.siteSettings); + if (unseen.length) { + Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500); + } + + const post = this.get('composer.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); + } + + // Paint oneboxes + $('a.onebox', $preview).each((i, e) => Discourse.Onebox.load(e, refresh)); + }, + } +}); diff --git a/app/assets/javascripts/discourse/components/composer-text-area.js.es6 b/app/assets/javascripts/discourse/components/composer-text-area.js.es6 deleted file mode 100644 index 37ac9b1cd98..00000000000 --- a/app/assets/javascripts/discourse/components/composer-text-area.js.es6 +++ /dev/null @@ -1,15 +0,0 @@ -export default Ember.TextArea.extend({ - classNameBindings: [':wmd-input'], - - placeholder: function() { - return I18n.t('composer.reply_placeholder'); - }.property('placeholderKey'), - - _signalParentInsert: function() { - this.get('parentView').childDidInsertElement(this); - }.on('didInsertElement'), - - _signalParentDestroy: function() { - this.get('parentView').childWillDestroyElement(this); - }.on('willDestroyElement') -}); diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 new file mode 100644 index 00000000000..5a1968ad4c9 --- /dev/null +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -0,0 +1,29 @@ +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['title-input'], + + @on('didInsertElement') + _focusOnReply() { + if (!Discourse.Mobile.mobileView) { + this.$('input').putCursorAtEnd(); + } + }, + + @computed('composer.titleLength', 'composer.missingTitleCharacters', 'composer.minimumTitleLength', 'lastValidatedAt') + validation(titleLength, missingTitleChars, minimumTitleLength, lastValidatedAt) { + + let reason; + if (titleLength < 1) { + reason = I18n.t('composer.error.title_missing'); + } else if (missingTitleChars > 0) { + reason = I18n.t('composer.error.title_too_short', {min: minimumTitleLength}); + } else if (titleLength > this.siteSettings.max_topic_title_length) { + reason = I18n.t('composer.error.title_too_long', {max: this.siteSettings.max_topic_title_length}); + } + + if (reason) { + return Discourse.InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 4d26eb3e436..14c83b14081 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -1,6 +1,6 @@ /*global Mousetrap:true */ import loadScript from 'discourse/lib/load-script'; -import { default as property, on } from 'ember-addons/ember-computed-decorators'; +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; import { showSelector } from "discourse/lib/emoji/emoji-toolbar"; // Our head can be a static string or a function that returns a string @@ -111,6 +111,10 @@ Toolbar.prototype.addButton = function(button) { perform: button.perform || Ember.K }; + if (button.sendAction) { + createdButton.sendAction = button.sendAction; + } + const title = I18n.t(button.title || `composer.${button.id}_title`); if (button.shortcut) { const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); @@ -130,7 +134,11 @@ Toolbar.prototype.addButton = function(button) { createdButton.title = title; } - g.buttons.push(createdButton); + if (button.unshift) { + g.buttons.unshift(createdButton); + } else { + g.buttons.push(createdButton); + } }; export function onToolbarCreate(func) { @@ -144,9 +152,16 @@ export default Ember.Component.extend({ link: '', lastSel: null, + @computed('placeholder') + placeholderTranslated(placeholder) { + if (placeholder) return I18n.t(placeholder); + return null; + }, + @on('didInsertElement') _startUp() { this._applyEmojiAutocomplete(); + loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true)); const shortcuts = this.get('toolbar.shortcuts'); @@ -156,27 +171,52 @@ export default Ember.Component.extend({ this.send(button.action, button); }); }); + + // disable clicking on links in the preview + this.$('.d-editor-preview').on('click.preview', e => { + e.preventDefault(); + return false; + }); + + this.appEvents.on('composer:insert-text', text => { + this._addText(this._getSelected(), text); + }); }, @on('willDestroyElement') _shutDown() { + this.appEvents.off('composer:insert-text'); + Ember.keys(this.get('toolbar.shortcuts')).forEach(sc => { Mousetrap(this.$('.d-editor-input')[0]).unbind(sc); }); + this.$('.d-editor-preview').off('click.preview'); }, - @property + @computed toolbar() { const toolbar = new Toolbar(); _createCallbacks.forEach(cb => cb(toolbar)); + this.sendAction('extraButtons', toolbar); return toolbar; }, - @property('ready', 'value') + @computed('ready', 'value') preview(ready, value) { if (!ready) { return; } - const text = Discourse.Dialect.cook(value || "", {sanitize: true}); + const markdownOptions = this.get('markdownOptions') || {}; + markdownOptions.sanitize = true; + + const text = Discourse.Dialect.cook(value || "", markdownOptions); + Ember.run.scheduleOnce('afterRender', () => { + if (this._state !== "inDOM") { return; } + const $preview = this.$('.d-editor-preview'); + if ($preview.length === 0) return; + + this.sendAction('previewUpdated', $preview); + }); + return text ? text : ""; }, @@ -339,12 +379,18 @@ export default Ember.Component.extend({ actions: { toolbarButton(button) { const selected = this._getSelected(); - button.perform({ + const toolbarEvent = { selected, applySurround: (head, tail, exampleKey) => this._applySurround(selected, head, tail, exampleKey), applyList: (head, exampleKey) => this._applyList(selected, head, exampleKey), addText: text => this._addText(selected, text) - }); + }; + + if (button.sendAction) { + return this.sendAction(button.sendAction, toolbarEvent); + } else { + button.perform(toolbarEvent); + } }, showLinkModal() { diff --git a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 index 34419b477b1..308d3223eab 100644 --- a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 @@ -5,7 +5,7 @@ export default buildCategoryPanel('topic-template', { if (this.get('activeTab')) { const self = this; Ember.run.schedule('afterRender', function() { - self.$('.wmd-input').focus(); + self.$('.d-editor-input').focus(); }); } }.observes('activeTab') diff --git a/app/assets/javascripts/discourse/components/image-uploader.js.es6 b/app/assets/javascripts/discourse/components/image-uploader.js.es6 index 3fc6650a47e..08359fa8695 100644 --- a/app/assets/javascripts/discourse/components/image-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/image-uploader.js.es6 @@ -1,10 +1,10 @@ -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; import UploadMixin from "discourse/mixins/upload"; export default Em.Component.extend(UploadMixin, { classNames: ["image-uploader"], - @property('imageUrl') + @computed('imageUrl') backgroundStyle(imageUrl) { if (Em.isNone(imageUrl)) { return; } return `background-image: url(${imageUrl})`.htmlSafe(); diff --git a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 index 51725abc09e..ce2dc31c532 100644 --- a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 +++ b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 @@ -1,9 +1,9 @@ import StringBuffer from 'discourse/mixins/string-buffer'; import { iconHTML } from 'discourse/helpers/fa-icon'; -import { observes } from 'ember-addons/ember-computed-decorators'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend(StringBuffer, { - classNameBindings: [':popup-tip', 'good', 'bad', 'shownAt::hide'], + classNameBindings: [':popup-tip', 'good', 'bad', 'lastShownAt::hide'], animateAttribute: null, bouncePixels: 6, bounceDelay: 100, @@ -16,9 +16,14 @@ export default Ember.Component.extend(StringBuffer, { bad: Ember.computed.alias("validation.failed"), good: Ember.computed.not("bad"), - @observes("shownAt") + @computed('shownAt', 'validation.lastShownAt') + lastShownAt(shownAt, lastShownAt) { + return shownAt || lastShownAt; + }, + + @observes('lastShownAt') bounce() { - if (this.get("shownAt")) { + if (this.get("lastShownAt")) { var $elem = this.$(); if (!this.animateAttribute) { this.animateAttribute = $elem.css('left') === 'auto' ? 'right' : 'left'; diff --git a/app/assets/javascripts/discourse/components/post-gutter.js.es6 b/app/assets/javascripts/discourse/components/post-gutter.js.es6 index f4ca4cac6f7..c545f1c0b7b 100644 --- a/app/assets/javascripts/discourse/components/post-gutter.js.es6 +++ b/app/assets/javascripts/discourse/components/post-gutter.js.es6 @@ -2,7 +2,7 @@ const MAX_SHOWN = 5; import StringBuffer from 'discourse/mixins/string-buffer'; import { iconHTML } from 'discourse/helpers/fa-icon'; -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; const { get, isEmpty, Component } = Ember; @@ -12,7 +12,7 @@ export default Component.extend(StringBuffer, { rerenderTriggers: ['expanded'], // Roll up links to avoid duplicates - @property('links') + @computed('links') collapsed(links) { const seen = {}; const result = []; diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index b8328a87f3a..92b51cc9591 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -1,9 +1,8 @@ -import { setting } from 'discourse/lib/computed'; import DiscourseURL from 'discourse/lib/url'; import Quote from 'discourse/lib/quote'; import Draft from 'discourse/models/draft'; import Composer from 'discourse/models/composer'; -import computed from 'ember-addons/ember-computed-decorators'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; function loadDraft(store, opts) { opts = opts || {}; @@ -50,17 +49,17 @@ export default Ember.Controller.extend({ showEditReason: false, editReason: null, - maxTitleLength: setting('max_topic_title_length'), scopedCategoryId: null, similarTopics: null, similarTopicsMessage: null, lastSimilaritySearch: null, optionsVisible: false, - topic: null, + lastValidatedAt: null, - // TODO: Remove this, very bad - view: null, + isUploading: false, + + topic: null, _initializeSimilar: function() { this.set('similarTopics', []); @@ -109,7 +108,7 @@ export default Ember.Controller.extend({ }, // Import a quote from the post - importQuote() { + importQuote(toolbarEvent) { const postStream = this.get('topic.postStream'); let postId = this.get('model.post.id'); @@ -135,7 +134,7 @@ export default Ember.Controller.extend({ return this.store.find('post', postId).then(function(post) { const quote = Quote.build(post, post.get("raw"), {raw: true, full: true}); - composer.appendBlockAtCursor(quote); + toolbarEvent.addText(quote); composer.set('model.loading', false); }); } @@ -173,39 +172,10 @@ export default Ember.Controller.extend({ }, - appendText(text, opts) { - const c = this.get('model'); - if (c) { - opts = opts || {}; - const wmd = $('.wmd-input'), - val = wmd.val() || '', - position = opts.position === "cursor" ? wmd.caret() : val.length, - caret = c.appendText(text, position, opts); - - if (wmd[0]) { - Em.run.next(() => Discourse.Utilities.setCaretPosition(wmd[0], caret)); - } - } - }, - - appendTextAtCursor(text, opts) { - opts = opts || {}; - opts.position = "cursor"; - this.appendText(text, opts); - }, - - appendBlockAtCursor(text, opts) { - opts = opts || {}; - opts.position = "cursor"; - opts.block = true; - this.appendText(text, opts); - }, - categories: function() { return Discourse.Category.list(); }.property(), - toggle() { this.closeAutocomplete(); switch (this.get('model.composeState')) { @@ -225,7 +195,7 @@ export default Ember.Controller.extend({ return false; }, - disableSubmit: Ember.computed.or("model.loading", "view.isUploading"), + disableSubmit: Ember.computed.or("model.loading", "isUploading"), save(force) { const composer = this.get('model'); @@ -237,12 +207,7 @@ export default Ember.Controller.extend({ } if (composer.get('cantSubmitPost')) { - const now = Date.now(); - this.setProperties({ - 'view.showTitleTip': now, - 'view.showCategoryTip': now, - 'view.showReplyTip': now - }); + this.set('lastValidatedAt', Date.now()); return; } @@ -291,10 +256,18 @@ export default Ember.Controller.extend({ var staged = false; const disableJumpReply = Discourse.User.currentProp('disable_jump_reply'); - const promise = composer.save({ - imageSizes: this.get('view').imageSizes(), - editReason: this.get("editReason") - }).then(function(result) { + // TODO: This should not happen in model + const imageSizes = {}; + $('#reply-control .d-editor-preview img').each((i, e) => { + const $img = $(e); + const src = $img.prop('src'); + + if (src && src.length) { + imageSizes[src] = { width: $img.width(), height: $img.height() }; + } + }); + + const promise = composer.save({ imageSizes, editReason: this.get("editReason")}).then(function(result) { if (result.responseJson.action === "enqueued") { self.send('postWasEnqueued', result.responseJson); self.destroyDraft(); @@ -366,8 +339,8 @@ export default Ember.Controller.extend({ // We don't care about similar topics unless creating a topic if (!this.get('model.creatingTopic')) { return; } - let body = this.get('model.reply'); - const title = this.get('model.title'); + let body = this.get('model.reply') || ''; + const title = this.get('model.title') || ''; // Ensure the fields are of the minimum length if (body.length < Discourse.SiteSettings.min_body_similar_length) { return; } @@ -405,11 +378,6 @@ export default Ember.Controller.extend({ }); }, - saveDraft() { - const model = this.get('model'); - if (model) { model.saveDraft(); } - }, - /** Open the composer view @@ -502,7 +470,7 @@ export default Ember.Controller.extend({ composerModel.set('composeState', Discourse.Composer.OPEN); composerModel.set('isWarning', false); - if (opts.topicTitle && opts.topicTitle.length <= this.get('maxTitleLength')) { + if (opts.topicTitle && opts.topicTitle.length <= this.siteSettings.max_topic_title_length) { this.set('model.title', opts.topicTitle); } @@ -572,7 +540,6 @@ export default Ember.Controller.extend({ }); }, - shrink() { if (this.get('model.replyDirty')) { this.collapse(); @@ -581,22 +548,34 @@ export default Ember.Controller.extend({ } }, + _saveDraft() { + const model = this.get('model'); + if (model) { model.saveDraft(); }; + }, + + @observes('model.reply', 'model.title') + _shouldSaveDraft() { + Ember.run.debounce(this, this._saveDraft, 2000); + }, + + @computed('model.categoryId', 'lastValidatedAt') + categoryValidation(categoryId, lastValidatedAt) { + if( !this.siteSettings.allow_uncategorized_topics && !categoryId) { + return Discourse.InputValidation.create({ failed: true, reason: I18n.t('composer.error.category_missing'), lastShownAt: lastValidatedAt }); + } + }, + collapse() { - this.saveDraft(); + this._saveDraft(); this.set('model.composeState', Discourse.Composer.DRAFT); }, close() { - this.setProperties({ - model: null, - 'view.showTitleTip': false, - 'view.showCategoryTip': false, - 'view.showReplyTip': false - }); + this.setProperties({ model: null, lastValidatedAt: null }); }, closeAutocomplete() { - $('.wmd-input').autocomplete({ cancel: true }); + $('.d-editor-input').autocomplete({ cancel: true }); }, showOptions() { diff --git a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 index 620decb81da..2ad413846dd 100644 --- a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 @@ -1,6 +1,6 @@ import loadScript from 'discourse/lib/load-script'; import Quote from 'discourse/lib/quote'; -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ needs: ['topic', 'composer'], @@ -9,7 +9,7 @@ export default Ember.Controller.extend({ loadScript('defer/html-sanitizer-bundle'); }.on('init'), - @property('buffer', 'postId') + @computed('buffer', 'postId') post(buffer, postId) { if (!postId || Ember.isEmpty(buffer)) { return null; } @@ -135,7 +135,7 @@ export default Ember.Controller.extend({ const quotedText = Quote.build(post, buffer); composerOpts.quote = quotedText; if (composerController.get('content.viewOpen') || composerController.get('content.viewDraft')) { - composerController.appendBlockAtCursor(quotedText.trim()); + this.appEvents.trigger('composer:insert-text', quotedText.trim()); } else { composerController.open(composerOpts); } diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index e0e480953f1..95ebbe4b6f6 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -3,7 +3,6 @@ import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; import Topic from 'discourse/models/topic'; import Quote from 'discourse/lib/quote'; -import { setting } from 'discourse/lib/computed'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import computed from 'ember-addons/ember-computed-decorators'; @@ -24,8 +23,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'), isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"), - maxTitleLength: setting('max_topic_title_length'), - _titleChanged: function() { const title = this.get('model.title'); if (!Ember.isEmpty(title)) { diff --git a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 index f0a7d67fcd8..694722ccd76 100644 --- a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 +++ b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 @@ -1,14 +1,58 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export function uploadTranslate(key, options) { + options = options || {}; + if (Discourse.Utilities.allowsAttachments()) { key += "_with_attachments"; } + return I18n.t(`upload_selector.${key}`, options); +} export default Ember.Controller.extend(ModalFunctionality, { showMore: false, local: true, + imageUrl: null, + imageLink: null, remote: Ember.computed.not("local"), + @computed + uploadIcon() { + return Discourse.Utilities.allowsAttachments() ? "upload" : "picture-o"; + }, + + @computed('controller.local') + tip(local) { + const source = local ? "local" : "remote"; + const authorized_extensions = Discourse.Utilities.authorizesAllExtensions() ? "" : `(${Discourse.Utilities.authorizedExtensions()})`; + return uploadTranslate(`${source}_tip`, { authorized_extensions }); + }, + actions: { - useLocal() { this.setProperties({ local: true, showMore: false}); }, - useRemote() { this.set("local", false); }, - toggleShowMore() { this.toggleProperty("showMore"); } + upload() { + if (this.get('local')) { + $('#reply-control').fileupload('add', { fileInput: $('#filename-input') }); + } else { + const imageUrl = this.get('imageUrl') || ''; + const imageLink = this.get('imageLink') || ''; + const toolbarEvent = this.get('toolbarEvent'); + + if (this.get('showMore') && imageLink.length > 3) { + toolbarEvent.addText(`[![](${imageUrl})](${imageLink})`); + } else { + toolbarEvent.addText(imageUrl); + } + this.send('closeModal'); + } + }, + + useLocal() { + this.setProperties({ local: true, showMore: false}); + }, + useRemote() { + this.set("local", false); + }, + toggleShowMore() { + this.toggleProperty("showMore"); + } } }); diff --git a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 index 7120a3e3e0a..2ec1843c408 100644 --- a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 +++ b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 @@ -1,4 +1,3 @@ -import { showSelector } from "discourse/lib/emoji/emoji-toolbar"; import { onToolbarCreate } from 'discourse/components/d-editor'; export default { @@ -8,7 +7,6 @@ export default { const siteSettings = container.lookup('site-settings:main'); if (siteSettings.enable_emoji) { - onToolbarCreate(toolbar => { toolbar.addButton({ id: 'emoji', @@ -20,20 +18,6 @@ export default { }); }); - window.PagedownCustom.appendButtons.push({ - id: 'wmd-emoji-button', - description: I18n.t("composer.emoji"), - execute() { - showSelector({ - container, - onSelect(title) { - const composerController = container.lookup('controller:composer'); - composerController.appendTextAtCursor(`:${title}:`, {space: true}); - }, - }); - } - }); - // enable plugin emojis Discourse.Emoji.applyCustomEmojis(); } diff --git a/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 b/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 index 012fc98135f..746abc38f48 100644 --- a/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 +++ b/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 @@ -18,6 +18,6 @@ export default { const style = 'max-width:' + width + 'px;' + 'max-height:' + height + 'px;'; - $('').appendTo('head'); + $('').appendTo('head'); } }; diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js deleted file mode 100644 index 2dd0acaef3c..00000000000 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ /dev/null @@ -1,2190 +0,0 @@ -// needs Markdown.Converter.js at the moment - - -// To insert extra buttons: -// -// Before this file is required, define a PagedownCustom object. Give it an attribtue of insertButtons, which is an array -// of the buttons you want to insert. For example: -// -// window.PagedownCustom = { -// insertButtons: [ -// { -// id: 'wmd-bark', -// description: 'Bark', -// execute: function() { -// return alert('woof!'); -// } -// } -// ] -// }; -// -// To extend actions: -// -// window.PagedownCustom = { -// customActions: { -// "doBlockquote": function(chunk, postProcessing, oldDoBlockquote) { -// console.log('custom blockquote called!'); -// return oldDoBlockquote.call(this, chunk, postProcessing); -// } -// } -// }; - - -(function () { - - var util = {}, - position = {}, - ui = {}, - doc = window.document, - re = window.RegExp, - nav = window.navigator, - SETTINGS = { lineLength: 72 }; - - - var defaultsStrings = { - bold: "Strong Ctrl+B", - boldexample: "strong text", - - italic: "Emphasis Ctrl+I", - italicexample: "emphasized text", - - link: "Hyperlink Ctrl+L", - linkdescription: "enter link description here", - linkdialog: "

Insert Hyperlink

http://example.com/ \"optional title\"

", - - quote: "Blockquote
Ctrl+Q", - quoteexample: "Blockquote", - - code: "Code Sample
 Ctrl+K",
-        codeexample: "enter code here",
-
-        image: "Image  Ctrl+G",
-        imagedescription: "enter image description here",
-        imagedialog: "

Insert Image

http://example.com/images/diagram.jpg \"optional title\"

Need
free image hosting?

", - - olist: "Numbered List
    Ctrl+O", - ulist: "Bulleted List
      Ctrl+U", - litem: "List item", - - heading: "Heading

      /

      Ctrl+H", - headingexample: "Heading", - - hr: "Horizontal Rule
      Ctrl+R", - - undo: "Undo - Ctrl+Z", - redo: "Redo - Ctrl+Y", - redomac: "Redo - Ctrl+Shift+Z", - - help: "Markdown Editing Help" - }; - - - // ------------------------------------------------------------------- - // YOUR CHANGES GO HERE - // - // I've tried to localize the things you are likely to change to - // this area. - // ------------------------------------------------------------------- - - // The default text that appears in the dialog input box when entering - // links. - var imageDefaultText = "http://"; - var linkDefaultText = "http://"; - - // ------------------------------------------------------------------- - // END OF YOUR CHANGES - // ------------------------------------------------------------------- - - // options, if given, can have the following properties: - // options.helpButton = { handler: yourEventHandler } - // options.strings = { italicexample: "slanted text" } - // `yourEventHandler` is the click handler for the help button. - // If `options.helpButton` isn't given, not help button is created. - // `options.strings` can have any or all of the same properties as - // `defaultStrings` above, so you can just override some string displayed - // to the user on a case-by-case basis, or translate all strings to - // a different language. - // - // For backwards compatibility reasons, the `options` argument can also - // be just the `helpButton` object, and `strings.help` can also be set via - // `helpButton.title`. This should be considered legacy. - // - // The constructed editor object has the methods: - // - getConverter() returns the markdown converter object that was passed to the constructor - // - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op. - // - refreshPreview() forces the preview to be updated. This method is only available after run() was called. - Markdown.Editor = function (markdownConverter, idPostfix, options) { - - options = options || {}; - - if (typeof options.handler === "function") { //backwards compatible behavior - options = { helpButton: options }; - } - options.strings = options.strings || {}; - if (options.helpButton) { - options.strings.help = options.strings.help || options.helpButton.title; - } - var getString = function (identifier) { return options.strings[identifier] || defaultsStrings[identifier]; } - - idPostfix = idPostfix || ""; - - var hooks = this.hooks = new Markdown.HookCollection(); - hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed - hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text - hooks.addFalse("insertImageDialog"); /* called with one parameter: a callback to be called with the URL of the image. If the application creates - * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen - * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. - */ - - this.getConverter = function () { return markdownConverter; } - - var that = this, - panels; - - this.run = function () { - if (panels) - return; // already initialized - - panels = new PanelCollection(options.containerElement); - var commandManager = new CommandManager(hooks, getString); - var previewManager = new PreviewManager(markdownConverter, panels, function () { hooks.onPreviewRefresh(); }); - var undoManager, uiManager; - - if (false && !/\?noundo/.test(doc.location.href)) { - undoManager = new UndoManager(function () { - previewManager.refresh(); - if (uiManager) // not available on the first call - uiManager.setUndoRedoButtonStates(); - }, panels); - this.textOperation = function (f) { - undoManager.setCommandMode(); - f(); - that.refreshPreview(); - } - } - - uiManager = new UIManager(idPostfix, panels, undoManager, previewManager, commandManager, options.helpButton, getString, options); - uiManager.setUndoRedoButtonStates(); - - var forceRefresh = that.refreshPreview = function () { previewManager.refresh(true); }; - - forceRefresh(); - }; - - } - - // before: contains all the text in the input box BEFORE the selection. - // after: contains all the text in the input box AFTER the selection. - function Chunks() { } - - // startRegex: a regular expression to find the start tag - // endRegex: a regular expresssion to find the end tag - Chunks.prototype.findTags = function (startRegex, endRegex) { - - var chunkObj = this; - var regex; - - if (startRegex) { - - regex = util.extendRegExp(startRegex, "", "$"); - - this.before = this.before.replace(regex, - function (match) { - chunkObj.startTag = chunkObj.startTag + match; - return ""; - }); - - regex = util.extendRegExp(startRegex, "^", ""); - - this.selection = this.selection.replace(regex, - function (match) { - chunkObj.startTag = chunkObj.startTag + match; - return ""; - }); - } - - if (endRegex) { - - regex = util.extendRegExp(endRegex, "", "$"); - - this.selection = this.selection.replace(regex, - function (match) { - chunkObj.endTag = match + chunkObj.endTag; - return ""; - }); - - regex = util.extendRegExp(endRegex, "^", ""); - - this.after = this.after.replace(regex, - function (match) { - chunkObj.endTag = match + chunkObj.endTag; - return ""; - }); - } - }; - - // If remove is false, the whitespace is transferred - // to the before/after regions. - // - // If remove is true, the whitespace disappears. - Chunks.prototype.trimWhitespace = function (remove) { - var beforeReplacer, afterReplacer, that = this; - if (remove) { - beforeReplacer = afterReplacer = ""; - } else { - beforeReplacer = function (s) { that.before += s; return ""; } - afterReplacer = function (s) { that.after = s + that.after; return ""; } - } - - this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); - }; - - - Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) { - - if (nLinesBefore === undefined) { - nLinesBefore = 1; - } - - if (nLinesAfter === undefined) { - nLinesAfter = 1; - } - - nLinesBefore++; - nLinesAfter++; - - var regexText; - var replacementText; - - // chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985 - if (navigator.userAgent.match(/Chrome/)) { - "X".match(/()./); - } - - this.selection = this.selection.replace(/(^\n*)/, ""); - - this.startTag = this.startTag + re.$1; - - this.selection = this.selection.replace(/(\n*$)/, ""); - this.endTag = this.endTag + re.$1; - this.startTag = this.startTag.replace(/(^\n*)/, ""); - this.before = this.before + re.$1; - this.endTag = this.endTag.replace(/(\n*$)/, ""); - this.after = this.after + re.$1; - - if (this.before) { - - regexText = replacementText = ""; - - while (nLinesBefore--) { - regexText += "\\n?"; - replacementText += "\n"; - } - - if (findExtraNewlines) { - regexText = "\\n*"; - } - this.before = this.before.replace(new re(regexText + "$", ""), replacementText); - } - - if (this.after) { - - regexText = replacementText = ""; - - while (nLinesAfter--) { - regexText += "\\n?"; - replacementText += "\n"; - } - if (findExtraNewlines) { - regexText = "\\n*"; - } - - this.after = this.after.replace(new re(regexText, ""), replacementText); - } - }; - - // end of Chunks - - function firstByClass(doc, containerElement, className) { - var container = containerElement || doc; - var elements = container.getElementsByClassName(className); - if (elements && elements.length) { - return elements[0]; - } - } - - // A collection of the important regions on the page. - // Cached so we don't have to keep traversing the DOM. - function PanelCollection(containerElement) { - this.buttonBar = firstByClass(doc, containerElement, 'wmd-button-bar'); - this.preview = firstByClass(doc, containerElement, 'wmd-preview'); - this.input = firstByClass(doc, containerElement, 'wmd-input'); - }; - - // Returns true if the DOM element is visible, false if it's hidden. - // Checks if display is anything other than none. - util.isVisible = function (elem) { - return window.getComputedStyle(elem, null).getPropertyValue("display") !== "none"; - }; - - - // Adds a listener callback to a DOM element which is fired on a specified - // event. - util.addEvent = function (elem, event, listener) { - var wrapped = function() { - var wrappedArgs = Array.prototype.slice.call(arguments); - Ember.run(function() { - listener.apply(this, wrappedArgs); - }); - }; - elem.addEventListener(event, wrapped, false); - }; - - - // Removes a listener callback from a DOM element which is fired on a specified - // event. - util.removeEvent = function (elem, event, listener) { - elem.removeEventListener(event, listener, false); - }; - - // Converts \r\n and \r to \n. - util.fixEolChars = function (text) { - text = text.replace(/\r\n/g, "\n"); - text = text.replace(/\r/g, "\n"); - return text; - }; - - // Extends a regular expression. Returns a new RegExp - // using pre + regex + post as the expression. - // Used in a few functions where we have a base - // expression and we want to pre- or append some - // conditions to it (e.g. adding "$" to the end). - // The flags are unchanged. - // - // regex is a RegExp, pre and post are strings. - util.extendRegExp = function (regex, pre, post) { - - if (pre === null || pre === undefined) { - pre = ""; - } - if (post === null || post === undefined) { - post = ""; - } - - var pattern = regex.toString(); - var flags; - - // Replace the flags with empty space and store them. - pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) { - flags = flagsPart; - return ""; - }); - - // Remove the slash delimiters on the regular expression. - pattern = pattern.replace(/(^\/|\/$)/g, ""); - pattern = pre + pattern + post; - - return new re(pattern, flags); - } - - // UNFINISHED - // The assignment in the while loop makes jslint cranky. - // I'll change it to a better loop later. - position.getTop = function (elem, isInner) { - var result = elem.offsetTop; - if (!isInner) { - while (elem = elem.offsetParent) { - result += elem.offsetTop; - } - } - return result; - }; - - position.getHeight = function (elem) { - return elem.offsetHeight || elem.scrollHeight; - }; - - position.getWidth = function (elem) { - return elem.offsetWidth || elem.scrollWidth; - }; - - position.getPageSize = function () { - - var scrollWidth, scrollHeight; - var innerWidth, innerHeight; - - // It's not very clear which blocks work with which browsers. - if (self.innerHeight && self.scrollMaxY) { - scrollWidth = doc.body.scrollWidth; - scrollHeight = self.innerHeight + self.scrollMaxY; - } - else if (doc.body.scrollHeight > doc.body.offsetHeight) { - scrollWidth = doc.body.scrollWidth; - scrollHeight = doc.body.scrollHeight; - } - else { - scrollWidth = doc.body.offsetWidth; - scrollHeight = doc.body.offsetHeight; - } - - innerWidth = self.innerWidth; - innerHeight = self.innerHeight; - - var maxWidth = Math.max(scrollWidth, innerWidth); - var maxHeight = Math.max(scrollHeight, innerHeight); - return [maxWidth, maxHeight, innerWidth, innerHeight]; - }; - - // Handles pushing and popping TextareaStates for undo/redo commands. - // I should rename the stack variables to list. - function UndoManager(callback, panels) { - - var undoObj = this; - var undoStack = []; // A stack of undo states - var stackPtr = 0; // The index of the current state - var mode = "none"; - var lastState; // The last state - var timer; // The setTimeout handle for cancelling the timer - var inputStateObj; - - // Set the mode for later logic steps. - var setMode = function (newMode, noSave) { - if (mode != newMode) { - mode = newMode; - if (!noSave) { - saveState(); - } - } - - if (mode != "moving") { - timer = setTimeout(refreshState, 1); - } - else { - inputStateObj = null; - } - }; - - var refreshState = function (isInitialState) { - inputStateObj = new TextareaState(panels, isInitialState); - timer = undefined; - }; - - this.setCommandMode = function () { - mode = "command"; - saveState(); - timer = setTimeout(refreshState, 0); - }; - - this.canUndo = function () { - return stackPtr > 1; - }; - - this.canRedo = function () { - if (undoStack[stackPtr + 1]) { - return true; - } - return false; - }; - - // Removes the last state and restores it. - this.undo = function () { - - if (undoObj.canUndo()) { - if (lastState) { - // What about setting state -1 to null or checking for undefined? - lastState.restore(); - lastState = null; - } - else { - undoStack[stackPtr] = new TextareaState(panels); - undoStack[--stackPtr].restore(); - - if (callback) { - callback(); - } - } - } - - mode = "none"; - panels.input.focus(); - refreshState(); - }; - - // Redo an action. - this.redo = function () { - - if (undoObj.canRedo()) { - - undoStack[++stackPtr].restore(); - - if (callback) { - callback(); - } - } - - mode = "none"; - panels.input.focus(); - refreshState(); - }; - - // Push the input area state to the stack. - var saveState = function () { - var currState = inputStateObj || new TextareaState(panels); - - if (!currState) { - return false; - } - if (mode == "moving") { - if (!lastState) { - lastState = currState; - } - return; - } - if (lastState) { - if (undoStack[stackPtr - 1].text != lastState.text) { - undoStack[stackPtr++] = lastState; - } - lastState = null; - } - undoStack[stackPtr++] = currState; - undoStack[stackPtr + 1] = null; - if (callback) { - callback(); - } - }; - - var handleCtrlYZ = function (event) { - - var handled = false; - - if ((event.ctrlKey || event.metaKey) && !event.altKey) { - - var keyCode = event.charCode; - var keyCodeChar = String.fromCharCode(keyCode); - - switch (keyCodeChar.toLowerCase()) { - - case "y": - if (!event.shiftKey) { - undoObj.redo(); - handled = true; - } - break; - - case "z": - if (!event.shiftKey) { - undoObj.undo(); - } - else { - undoObj.redo(); - } - handled = true; - break; - } - } - - if (handled) { - if (event.preventDefault) { - event.preventDefault(); - } - if (window.event) { - window.event.returnValue = false; - } - return; - } - }; - - // Set the mode depending on what is going on in the input area. - var handleModeChange = function (event) { - - if (!event.ctrlKey && !event.metaKey) { - - var keyCode = event.keyCode; - - if ((keyCode >= 33 && keyCode <= 40) || (keyCode >= 63232 && keyCode <= 63235)) { - // 33 - 40: page up/dn and arrow keys - // 63232 - 63235: page up/dn and arrow keys on safari - setMode("moving"); - } - else if (keyCode == 8 || keyCode == 46 || keyCode == 127) { - // 8: backspace - // 46: delete - // 127: delete - setMode("deleting"); - } - else if (keyCode == 13) { - // 13: Enter - setMode("newlines"); - } - else if (keyCode == 27) { - // 27: escape - setMode("escape"); - } - else if ((keyCode < 16 || keyCode > 20) && keyCode != 91) { - // 16-20 are shift, etc. - // 91: left window key - // I think this might be a little messed up since there are - // a lot of nonprinting keys above 20. - setMode("typing"); - } - } - }; - - var setEventHandlers = function () { - util.addEvent(panels.input, "keypress", function (event) { - // keyCode 89: y - // keyCode 90: z - if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.keyCode == 89 || event.keyCode == 90)) { - event.preventDefault(); - } - }); - - var handlePaste = function () { - if (inputStateObj && inputStateObj.text != panels.input.value) { - if (timer == undefined) { - mode = "paste"; - saveState(); - refreshState(); - } - } - }; - - util.addEvent(panels.input, "keydown", handleCtrlYZ); - util.addEvent(panels.input, "keydown", handleModeChange); - util.addEvent(panels.input, "mousedown", function () { - setMode("moving"); - }); - - panels.input.onpaste = handlePaste; - panels.input.ondrop = handlePaste; - }; - - var init = function () { - setEventHandlers(); - refreshState(true); - saveState(); - }; - - init(); - } - - // end of UndoManager - - // The input textarea state/contents. - // This is used to implement undo/redo by the undo manager. - function TextareaState(panels, isInitialState) { - - // Aliases - var stateObj = this; - var inputArea = panels.input; - this.init = function () { - if (!util.isVisible(inputArea)) { - return; - } - if (!isInitialState && doc.activeElement && doc.activeElement !== inputArea) { // this happens when tabbing out of the input box - return; - } - - this.setInputAreaSelectionStartEnd(); - this.scrollTop = inputArea.scrollTop; - if (!this.text && inputArea.selectionStart || inputArea.selectionStart === 0) { - this.text = inputArea.value; - } - - } - - // Sets the selected text in the input box after we've performed an - // operation. - this.setInputAreaSelection = function () { - - if (!util.isVisible(inputArea)) { - return; - } - - if (inputArea.selectionStart !== undefined) { - - inputArea.focus(); - inputArea.selectionStart = stateObj.start; - inputArea.selectionEnd = stateObj.end; - inputArea.scrollTop = stateObj.scrollTop; - } - else if (doc.selection) { - - if (doc.activeElement && doc.activeElement !== inputArea) { - return; - } - - inputArea.focus(); - var range = inputArea.createTextRange(); - range.moveStart("character", -inputArea.value.length); - range.moveEnd("character", -inputArea.value.length); - range.moveEnd("character", stateObj.end); - range.moveStart("character", stateObj.start); - range.select(); - } - }; - - this.setInputAreaSelectionStartEnd = function () { - - if (inputArea.selectionStart || inputArea.selectionStart === 0) { - stateObj.start = inputArea.selectionStart; - stateObj.end = inputArea.selectionEnd; - } - else if (doc.selection) { - - stateObj.text = util.fixEolChars(inputArea.value); - - var range = doc.selection.createRange(); - - var fixedRange = util.fixEolChars(range.text); - var marker = "\x07"; - var markedRange = marker + fixedRange + marker; - range.text = markedRange; - var inputText = util.fixEolChars(inputArea.value); - - range.moveStart("character", -markedRange.length); - range.text = fixedRange; - - stateObj.start = inputText.indexOf(marker); - stateObj.end = inputText.lastIndexOf(marker) - marker.length; - - var len = stateObj.text.length - util.fixEolChars(inputArea.value).length; - - if (len) { - range.moveStart("character", -fixedRange.length); - while (len--) { - fixedRange += "\n"; - stateObj.end += 1; - } - range.text = fixedRange; - } - - - this.setInputAreaSelection(); - } - }; - - // Restore this state into the input area. - this.restore = function () { - - if (stateObj.text != undefined && stateObj.text != inputArea.value) { - inputArea.value = stateObj.text; - } - this.setInputAreaSelection(); - inputArea.scrollTop = stateObj.scrollTop; - }; - - // Gets a collection of HTML chunks from the inptut textarea. - this.getChunks = function () { - - var chunk = new Chunks(); - chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start)); - chunk.startTag = ""; - chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end)); - chunk.endTag = ""; - chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end)); - chunk.scrollTop = stateObj.scrollTop; - - return chunk; - }; - - // Sets the TextareaState properties given a chunk of markdown. - this.setChunks = function (chunk) { - - chunk.before = chunk.before + chunk.startTag; - chunk.after = chunk.endTag + chunk.after; - - this.start = chunk.before.length; - this.end = chunk.before.length + chunk.selection.length; - this.text = chunk.before + chunk.selection + chunk.after; - this.scrollTop = chunk.scrollTop; - }; - this.init(); - }; - - function PreviewManager(converter, panels, previewRefreshCallback) { - - var managerObj = this; - var timeout; - var elapsedTime; - var oldInputText; - var maxDelay = 3000; - var startType = "delayed"; // The other legal value is "manual" - - var paneContentHeight = function(pane) { - var $pane = $(pane); - var paneVerticalPadding = parseInt($pane.css("padding-top")) + parseInt($pane.css("padding-bottom")); - - return pane.scrollHeight - paneVerticalPadding; - }; - - var syncScroll = function(isEdit) { - var $preview = $(panels.preview); - var $input = $(panels.input); - - if($input.scrollTop() === 0){ - $preview.scrollTop(0); - return; - } - - if(($input.height() + $input.scrollTop() + 100) > panels.input.scrollHeight){ - // cheat, special case for bottom - $preview.scrollTop(panels.preview.scrollHeight); - return; - } - - var scrollPosition = $(panels.input).scrollTop(); - var factor = panels.preview.scrollHeight / panels.input.scrollHeight; - - var desired = scrollPosition * factor; - $preview.scrollTop(desired + 50); - }; - - var setupScrollSync = function() { - var sync = _.throttle(syncScroll, 20); - $(panels.input).scroll(function() { - sync(); - }); - }; - - // Adds event listeners to elements - var setupEvents = function (inputElem, listener) { - util.addEvent(inputElem, "input", listener); - inputElem.onpaste = listener; - inputElem.ondrop = listener; - - util.addEvent(inputElem, "keypress", listener); - util.addEvent(inputElem, "keydown", listener); - }; - - var getDocScrollTop = function () { - - var result = 0; - - if (window.innerHeight) { - result = window.pageYOffset; - } - else - if (doc.documentElement && doc.documentElement.scrollTop) { - result = doc.documentElement.scrollTop; - } - else - if (doc.body) { - result = doc.body.scrollTop; - } - - return result; - }; - - var makePreviewHtml = function () { - - // If there is no registered preview panel - // there is nothing to do. - if (!panels.preview) - return; - - - var text = panels.input.value; - if (text && text == oldInputText) { - return; // Input text hasn't changed. - } - else { - oldInputText = text; - } - - var prevTime = new Date().getTime(); - - var previewText; - previewText = converter.makeHtml(text); - - // Calculate the processing time of the HTML creation. - // It's used as the delay time in the event listener. - var currTime = new Date().getTime(); - elapsedTime = currTime - prevTime; - - Ember.run(function() { - pushPreviewHtml(previewText); - syncScroll(true); - }); - }; - - // makePreviewHtml = window.probes.measure(makePreviewHtml, { - // before: function(){ window.probes.clear(); }, - // name: "makePreview", - // after: function(p) { window.probes.clear(); console.log("Total time to preview: " + p.time); } - // }); - - - // TODO allow us to inject this in (its our debouncer) - var debounce = function(func,wait,trickle) { - var timeout = null; - return function() { - var context = this; - var args = arguments; - - later = function(){ - timeout = null; - func.apply(context, args); - }; - - if (timeout!=null && trickle) { - return; - } - - var currentWait; - if (typeof wait == "function") { - currentWait = wait(); - } else { - currentWait = wait; - } - - if (timeout) { Ember.run.cancel(timeout); } - timeout = Ember.run.later(later, currentWait); - } - } - - makePreviewHtml = debounce(makePreviewHtml, function(){ - return Math.min(Math.max((elapsedTime || 1) * 10, 80),1000); - }, true); - - - // setTimeout is already used. Used as an event listener. - var applyTimeout = function () { - - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - - if (startType !== "manual") { - - var delay = 0; - - if (startType === "delayed") { - delay = elapsedTime; - } - - if (delay > maxDelay) { - delay = maxDelay; - } - timeout = setTimeout(makePreviewHtml, delay); - } - }; - - var getScaleFactor = function (panel) { - if (panel.scrollHeight <= panel.clientHeight) { - return 1; - } - return panel.scrollTop / (panel.scrollHeight - panel.clientHeight); - }; - - this.refresh = function (requiresRefresh) { - if (requiresRefresh) { - oldInputText = ""; - makePreviewHtml(); - } - else { - applyTimeout(); - } - }; - - this.processingTime = function () { - return elapsedTime; - }; - - var isFirstTimeFilled = true; - - var nonSuckyBrowserPreviewSet = function (previewText) { - panels.preview.innerHTML = previewText; - } - - var previewSetter; - - var previewSet = function (previewText) { - - if (previewSetter) - return previewSetter(previewText); - - nonSuckyBrowserPreviewSet(previewText); - previewSetter = nonSuckyBrowserPreviewSet; - }; - - var pushPreviewHtml = function (previewText) { - - var emptyTop = position.getTop(panels.input) - getDocScrollTop(); - - if (panels.preview) { - previewSet(previewText); - previewRefreshCallback(); - } - - if (isFirstTimeFilled) { - isFirstTimeFilled = false; - return; - } - - var fullTop = position.getTop(panels.input) - getDocScrollTop(); - - window.scrollBy(0, fullTop - emptyTop); - - }; - - var init = function () { - - // TODO: make option to disable. We don't need this in discourse - // setupEvents(panels.input, applyTimeout); - - setupScrollSync(); - makePreviewHtml(); - }; - - init(); - }; - - // Creates the background behind the hyperlink text entry box. - // And download dialog - // Most of this has been moved to CSS but the div creation and - // browser-specific hacks remain here. - ui.createBackground = function () { - - var background = doc.createElement("div"), - style = background.style; - - background.className = "wmd-prompt-background"; - - style.position = "absolute"; - style.top = "0"; - style.zIndex = "2000"; - style.opacity = "0.5"; - - var pageSize = position.getPageSize(); - style.height = pageSize[1] + "px"; - - style.left = "0"; - style.width = "100%"; - - doc.body.appendChild(background); - return background; - }; - - // This simulates a modal dialog box and asks for the URL when you - // click the hyperlink or image buttons. - // - // text: The html for the input box. - // defaultInputText: The default value that appears in the input box. - // callback: The function which is executed when the prompt is dismissed, either via OK or Cancel. - // It receives a single argument; either the entered text (if OK was chosen) or null (if Cancel - // was chosen). - ui.prompt = function (text, defaultInputText, callback) { - - // These variables need to be declared at this level since they are used - // in multiple functions. - var dialog; // The dialog box. - var input; // The text box where you enter the hyperlink. - - - if (defaultInputText === undefined) { - defaultInputText = ""; - } - - // Used as a keydown event handler. Esc dismisses the prompt. - // Key code 27 is ESC. - var checkEscape = function (key) { - var code = (key.charCode || key.keyCode); - if (code === 27) { - close(true); - } - }; - - // Dismisses the hyperlink input box. - // isCancel is true if we don't care about the input text. - // isCancel is false if we are going to keep the text. - var close = function (isCancel) { - util.removeEvent(doc.body, "keydown", checkEscape); - var text = input.value; - - if (isCancel) { - text = null; - } - else { - // Fixes common pasting errors. - text = text.replace(/^http:\/\/(https?|ftp):\/\//, '$1://'); - if (!/^(?:https?|ftp):\/\//.test(text)) - text = 'http://' + text; - } - - dialog.parentNode.removeChild(dialog); - - callback(text); - return false; - }; - - - - // Create the text input box form/window. - var createDialog = function () { - - // The main dialog box. - dialog = doc.createElement("div"); - dialog.className = "wmd-prompt-dialog"; - dialog.style.padding = "10px;"; - dialog.style.position = "fixed"; - dialog.style.width = "400px"; - dialog.style.zIndex = "2001"; - - // The dialog text. - var question = doc.createElement("div"); - question.innerHTML = text; - question.style.padding = "5px"; - dialog.appendChild(question); - - // The web form container for the text box and buttons. - var form = doc.createElement("form"), - style = form.style; - form.onsubmit = function () { return close(false); }; - style.padding = "0"; - style.margin = "0"; - style.cssFloat = "left"; - style.width = "100%"; - style.textAlign = "center"; - style.position = "relative"; - dialog.appendChild(form); - - // The input text box - input = doc.createElement("input"); - input.type = "text"; - input.value = defaultInputText; - style = input.style; - style.display = "block"; - style.width = "80%"; - style.marginLeft = style.marginRight = "auto"; - form.appendChild(input); - - // The ok button - var okButton = doc.createElement("input"); - okButton.type = "button"; - okButton.onclick = function () { return close(false); }; - okButton.value = "OK"; - style = okButton.style; - style.margin = "10px"; - style.display = "inline"; - style.width = "7em"; - - - // The cancel button - var cancelButton = doc.createElement("input"); - cancelButton.type = "button"; - cancelButton.onclick = function () { return close(true); }; - cancelButton.value = "Cancel"; - style = cancelButton.style; - style.margin = "10px"; - style.display = "inline"; - style.width = "7em"; - - form.appendChild(okButton); - form.appendChild(cancelButton); - - util.addEvent(doc.body, "keydown", checkEscape); - dialog.style.top = "50%"; - dialog.style.left = "50%"; - dialog.style.display = "block"; - doc.body.appendChild(dialog); - - // This has to be done AFTER adding the dialog to the form if you - // want it to be centered. - dialog.style.marginTop = -(position.getHeight(dialog) / 2) + "px"; - dialog.style.marginLeft = -(position.getWidth(dialog) / 2) + "px"; - - }; - - // Why is this in a zero-length timeout? - // Is it working around a browser bug? - setTimeout(function () { - - createDialog(); - - var defTextLen = defaultInputText.length; - if (input.selectionStart !== undefined) { - input.selectionStart = 0; - input.selectionEnd = defTextLen; - } - else if (input.createTextRange) { - var range = input.createTextRange(); - range.collapse(false); - range.moveStart("character", -defTextLen); - range.moveEnd("character", defTextLen); - range.select(); - } - - input.focus(); - }, 0); - }; - - function UIManager(postfix, panels, undoManager, previewManager, commandManager, helpOptions, getString, options) { - - var inputBox = panels.input, - buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements. - - makeSpritedButtonRow(options); - - var keyEvent = "keydown"; - - var extendedEvents = []; - - if(window.PagedownCustom){ - window.PagedownCustom.appendButtons.concat(window.PagedownCustom.insertButtons).forEach(function(button){ - if(button.shortcut){ - extendedEvents.push([button.shortcut, button.execute]); - } - }); - } - - util.addEvent(inputBox, keyEvent, function (key) { - - // Check to see if we have a button key and, if so execute the callback. - if ((key.ctrlKey || key.metaKey) && !key.altKey && !key.shiftKey) { - - var keyCode = key.charCode || key.keyCode; - var keyCodeStr = String.fromCharCode(keyCode).toLowerCase(); - - for(var i=0; i 0)) { - for (var i=0; i 0)) { - for (var i=0; i< buttons.length; i++) { - var b = buttons[i]; - makeButton(b.id, b.description, b.execute) - } - } - } - - // If we have any buttons to append, do it! - if (typeof PagedownCustom != "undefined") { - createExtraButtons(PagedownCustom.appendButtons); - } - - createExtraButtons(options.appendButtons); - - //makeSpacer(3); - //buttons.undo = makeButton("wmd-undo-button", getString("undo"), null); - //buttons.undo.execute = function (manager) { if (manager) manager.undo(); }; - - // var redoTitle = /win/.test(nav.platform.toLowerCase()) ? - // getString("redo") : - // getString("redomac"); // mac and other non-Windows platforms - - //buttons.redo = makeButton("wmd-redo-button", redoTitle, null); - //buttons.redo.execute = function (manager) { if (manager) manager.redo(); }; - - // if (helpOptions) { - // var helpButton = document.createElement("li"); - // var helpButtonImage = document.createElement("span"); - // helpButton.appendChild(helpButtonImage); - // helpButton.className = "wmd-button wmd-help-button"; - // helpButton.id = "wmd-help-button" + postfix; - // helpButton.isHelp = true; - // helpButton.style.right = "0px"; - // helpButton.title = getString("help"); - // helpButton.onclick = helpOptions.handler; - // - // setupButton(helpButton, true); - // buttonRow.appendChild(helpButton); - // buttons.help = helpButton; - // } - - // setUndoRedoButtonStates(); - } - - function setUndoRedoButtonStates() { - if (undoManager) { - setupButton(buttons.undo, undoManager.canUndo()); - setupButton(buttons.redo, undoManager.canRedo()); - } - }; - - this.setUndoRedoButtonStates = setUndoRedoButtonStates; - - } - - function CommandManager(pluginHooks, getString) { - this.hooks = pluginHooks; - this.getString = getString; - } - - var commandProto = CommandManager.prototype; - - // The markdown symbols - 4 spaces = code, > = blockquote, etc. - commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)"; - - // Remove markdown symbols from the chunk selection. - commandProto.unwrap = function (chunk) { - var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g"); - chunk.selection = chunk.selection.replace(txt, "$1 $2"); - }; - - commandProto.wrap = function (chunk, len) { - this.unwrap(chunk); - var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"), - that = this; - - chunk.selection = chunk.selection.replace(regex, function (line, marked) { - if (new re("^" + that.prefixes, "").test(line)) { - return line; - } - return marked + "\n"; - }); - - chunk.selection = chunk.selection.replace(/\s+$/, ""); - }; - - commandProto.doBold = function (chunk, postProcessing) { - return this.doSurroundLines(chunk, postProcessing, 2, this.getString("boldexample")); - }; - - commandProto.doItalic = function (chunk, postProcessing) { - return this.doSurroundLines(chunk, postProcessing, 1, this.getString("italicexample")); - }; - - commandProto.doSurroundLines = function(realChunk, postProcessing, nStars, fallbackText) { - realChunk.trimWhitespace(); - - // Look for stars before and after, absorb them into the selection. - var starsBefore = /(\**$)/.exec(realChunk.before)[0]; - var starsAfter = /(^\**)/.exec(realChunk.after)[0]; - - realChunk.before = realChunk.before.replace(/(\**$)/, ""); - realChunk.after = realChunk.after.replace(/(^\**)/, ""); - - var lines = (starsBefore + realChunk.selection + starsAfter).split("\n"); - - // Don't show the fallback text if more than one line is selected, - // it's probably a break between paragraphs. - if (lines.length > 1) { - fallbackText = ""; - } - - for(var i=0; i 1) { - lines[i] = newChunk.before + newChunk.selection + newChunk.after; - } else { - realChunk.startTag = newChunk.before; - realChunk.endTag = newChunk.after; - lines[i] = newChunk.selection; - } - } - - realChunk.selection = lines.join("\n"); - }; - - // chunk: The selected region that will be enclosed with * or ** - // nStars: 1 for italics, 2 for bold - // fallbackText: If you just click the button without highlighting text, this gets inserted - commandProto.doSurroundLine = function (chunk, postProcessing, nStars, fallbackText) { - // Get rid of whitespace - chunk.trimWhitespace(); - - var minStars = Math.min(chunk.before.length, chunk.after.length); - - // Remove stars if we have to since the button acts as a toggle. - if ((minStars >= nStars) && (minStars != 2 || nStars != 1)) { - chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), ""); - chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), ""); - } - else { - // In most cases, if you don't have any selected text and click the button - // you'll get a selected, marked up region with the default text inserted. - if (!chunk.selection && !chunk.after) { - chunk.selection = fallbackText; - } - - // Only operate if it's not a blank line - if (chunk.selection) { - // Add the true markup. - var markup = nStars === 1 ? "*" : "**"; - chunk.before = chunk.before + markup; - chunk.after = markup + chunk.after; - } - } - }; - - commandProto.stripLinkDefs = function (text, defsToAdd) { - - text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm, - function (totalMatch, id, link, newlines, title) { - defsToAdd[id] = totalMatch.replace(/\s*$/, ""); - if (newlines) { - // Strip the title and return that separately. - defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, ""); - return newlines + title; - } - return ""; - }); - - return text; - }; - - commandProto.addLinkDef = function (chunk, linkDef) { - - var refNumber = 0; // The current reference number - var defsToAdd = {}; // - // Start with a clean slate by removing all previous link definitions. - chunk.before = this.stripLinkDefs(chunk.before, defsToAdd); - chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd); - chunk.after = this.stripLinkDefs(chunk.after, defsToAdd); - - var defs = ""; - var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g; - - var addDefNumber = function (def) { - refNumber++; - def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:"); - defs += "\n" + def; - }; - - // note that - // a) the recursive call to getLink cannot go infinite, because by definition - // of regex, inner is always a proper substring of wholeMatch, and - // b) more than one level of nesting is neither supported by the regex - // nor making a lot of sense (the only use case for nesting is a linked image) - var getLink = function (wholeMatch, before, inner, afterInner, id, end) { - inner = inner.replace(regex, getLink); - if (defsToAdd[id]) { - addDefNumber(defsToAdd[id]); - return before + inner + afterInner + refNumber + end; - } - return wholeMatch; - }; - - chunk.before = chunk.before.replace(regex, getLink); - - if (linkDef) { - addDefNumber(linkDef); - } - else { - chunk.selection = chunk.selection.replace(regex, getLink); - } - - var refOut = refNumber; - - chunk.after = chunk.after.replace(regex, getLink); - - if (chunk.after) { - chunk.after = chunk.after.replace(/\n*$/, ""); - } - if (!chunk.after) { - chunk.selection = chunk.selection.replace(/\n*$/, ""); - } - - chunk.after += "\n\n" + defs; - - return refOut; - }; - - // takes the line as entered into the add link/as image dialog and makes - // sure the URL and the optinal title are "nice". - function properlyEncoded(linkdef) { - return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) { - link = link.replace(/ /g, '%20').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); - if (title) { - title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, ""); - title = title.replace(/"/g, "quot;").replace(/\(/g, "(").replace(/\)/g, ")").replace(//g, ">"); - } - return title ? link + ' "' + title + '"' : link; - }); - } - - commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) { - - chunk.trimWhitespace(); - chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); - var background; - - if (chunk.endTag.length > 1 && chunk.startTag.length > 0) { - - chunk.startTag = chunk.startTag.replace(/!?\[/, ""); - chunk.endTag = ""; - this.addLinkDef(chunk, null); - - } - else { - - // We're moving start and end tag back into the selection, since (as we're in the else block) we're not - // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the - // link text. linkEnteredCallback takes care of escaping any brackets. - chunk.selection = chunk.startTag + chunk.selection + chunk.endTag; - chunk.startTag = chunk.endTag = ""; - - if (/\n\n/.test(chunk.selection)) { - this.addLinkDef(chunk, null); - return; - } - var that = this; - // The function to be executed when you enter a link and press OK or Cancel. - // Marks up the link and adds the ref. - var linkEnteredCallback = function (link) { - - background.parentNode.removeChild(background); - - if (link !== null) { - // ( $1 - // [^\\] anything that's not a backslash - // (?:\\\\)* an even number (this includes zero) of backslashes - // ) - // (?= followed by - // [[\]] an opening or closing bracket - // ) - // - // In other words, a non-escaped bracket. These have to be escaped now to make sure they - // don't count as the end of the link or similar. - // Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets), - // the bracket in one match may be the "not a backslash" character in the next match, so it - // should not be consumed by the first match. - // The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the - // start of the string, so this also works if the selection begins with a bracket. We cannot solve - // this by anchoring with ^, because in the case that the selection starts with two brackets, this - // would mean a zero-width match at the start. Since zero-width matches advance the string position, - // the first bracket could then not act as the "not a backslash" for the second. - chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); - - var linkDef = " [999]: " + properlyEncoded(link); - - var num = that.addLinkDef(chunk, linkDef); - chunk.startTag = isImage ? "![" : "["; - chunk.endTag = "][" + num + "]"; - - if (!chunk.selection) { - if (isImage) { - chunk.selection = that.getString("imagedescription"); - } - else { - chunk.selection = that.getString("linkdescription"); - } - } - } - postProcessing(); - }; - - background = ui.createBackground(); - - if (isImage) { - if (!this.hooks.insertImageDialog(linkEnteredCallback)) - ui.prompt(this.getString("imagedialog"), imageDefaultText, linkEnteredCallback); - } - else { - ui.prompt(this.getString("linkdialog"), linkDefaultText, linkEnteredCallback); - } - return true; - } - }; - - commandProto.doBlockquote = function (chunk, postProcessing) { - - chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/, - function (totalMatch, newlinesBefore, text, newlinesAfter) { - chunk.before += newlinesBefore; - chunk.after = newlinesAfter + chunk.after; - return text; - }); - - chunk.before = chunk.before.replace(/(>[ \t]*)$/, - function (totalMatch, blankLine) { - chunk.selection = blankLine + chunk.selection; - return ""; - }); - - chunk.selection = chunk.selection.replace(/^(\s|>)+$/, ""); - chunk.selection = chunk.selection || this.getString("quoteexample"); - - // The original code uses a regular expression to find out how much of the - // text *directly before* the selection already was a blockquote: - - /* - if (chunk.before) { - chunk.before = chunk.before.replace(/\n?$/, "\n"); - } - chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/, - function (totalMatch) { - chunk.startTag = totalMatch; - return ""; - }); - */ - - // This comes down to: - // Go backwards as many lines a possible, such that each line - // a) starts with ">", or - // b) is almost empty, except for whitespace, or - // c) is preceeded by an unbroken chain of non-empty lines - // leading up to a line that starts with ">" and at least one more character - // and in addition - // d) at least one line fulfills a) - // - // Since this is essentially a backwards-moving regex, it's susceptible to - // catstrophic backtracking and can cause the browser to hang; - // see e.g. http://meta.stackoverflow.com/questions/9807. - // - // Hence we replaced this by a simple state machine that just goes through the - // lines and checks for a), b), and c). - - var match = "", - leftOver = "", - line; - if (chunk.before) { - var lines = chunk.before.replace(/\n$/, "").split("\n"); - var inChain = false; - for (var i = 0; i < lines.length; i++) { - var good = false; - line = lines[i]; - inChain = inChain && line.length > 0; // c) any non-empty line continues the chain - if (/^>/.test(line)) { // a) - good = true; - if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain - inChain = true; - } else if (/^[ \t]*$/.test(line)) { // b) - good = true; - } else { - good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain - } - if (good) { - match += line + "\n"; - } else { - leftOver += match + line; - match = "\n"; - } - } - if (!/(^|\n)>/.test(match)) { // d) - leftOver += match; - match = ""; - } - } - - chunk.startTag = match; - chunk.before = leftOver; - - // end of change - - if (chunk.after) { - chunk.after = chunk.after.replace(/^\n?/, "\n"); - } - - chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/, - function (totalMatch) { - chunk.endTag = totalMatch; - return ""; - } - ); - - var replaceBlanksInTags = function (useBracket) { - - var replacement = useBracket ? "> " : ""; - - if (chunk.startTag) { - chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/, - function (totalMatch, markdown) { - return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; - }); - } - if (chunk.endTag) { - chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/, - function (totalMatch, markdown) { - return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; - }); - } - }; - - if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) { - this.wrap(chunk, SETTINGS.lineLength - 2); - chunk.selection = chunk.selection.replace(/^/gm, "> "); - replaceBlanksInTags(true); - chunk.skipLines(); - } else { - chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, ""); - this.unwrap(chunk); - replaceBlanksInTags(false); - - if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) { - chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n"); - } - - if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) { - chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n"); - } - } - - chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection); - - if (!/\n/.test(chunk.selection)) { - chunk.selection = chunk.selection.replace(/^(> *)/, - function (wholeMatch, blanks) { - chunk.startTag += blanks; - return ""; - }); - } - }; - - commandProto.doCode = function (chunk, postProcessing) { - - var hasTextBefore = /\S[ ]*$/.test(chunk.before); - var hasTextAfter = /^[ ]*\S/.test(chunk.after); - - // Use 'four space' markdown if the selection is on its own - // line or is multiline. - if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) { - - chunk.before = chunk.before.replace(/[ ]{4}$/, - function (totalMatch) { - chunk.selection = totalMatch + chunk.selection; - return ""; - }); - - var nLinesBack = 1; - var nLinesForward = 1; - - if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { - nLinesBack = 0; - } - if (/^\n(\t|[ ]{4,})/.test(chunk.after)) { - nLinesForward = 0; - } - - chunk.skipLines(nLinesBack, nLinesForward); - - if (!chunk.selection) { - chunk.startTag = " "; - chunk.selection = this.getString("codeexample"); - } - else { - if (/^[ ]{0,3}\S/m.test(chunk.selection)) { - if (/\n/.test(chunk.selection)) - chunk.selection = chunk.selection.replace(/^/gm, " "); - else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior - chunk.before += " "; - } - else { - chunk.selection = chunk.selection.replace(/^(?:[ ]{4}|[ ]{0,3}\t)/gm, ""); - } - } - } - else { - // Use backticks (`) to delimit the code block. - - chunk.trimWhitespace(); - chunk.findTags(/`/, /`/); - - if (!chunk.startTag && !chunk.endTag) { - chunk.startTag = chunk.endTag = "`"; - if (!chunk.selection) { - chunk.selection = this.getString("codeexample"); - } - } - else if (chunk.endTag && !chunk.startTag) { - chunk.before += chunk.endTag; - chunk.endTag = ""; - } - else { - chunk.startTag = chunk.endTag = ""; - } - } - }; - - commandProto.doList = function (chunk, postProcessing, isNumberedList) { - - // These are identical except at the very beginning and end. - // Should probably use the regex extension function to make this clearer. - var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/; - var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/; - - // The default bullet is a dash but others are possible. - // This has nothing to do with the particular HTML bullet, - // it's just a markdown bullet. - var bullet = "-"; - - // The number in a numbered list. - var num = 1; - - // Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list. - var getItemPrefix = function () { - var prefix; - if (isNumberedList) { - prefix = " " + num + ". "; - num++; - } - else { - prefix = " " + bullet + " "; - } - return prefix; - }; - - // Fixes the prefixes of the other list items. - var getPrefixedItem = function (itemText) { - - // The numbering flag is unset when called by autoindent. - if (isNumberedList === undefined) { - isNumberedList = /^\s*\d/.test(itemText); - } - - // Renumber/bullet the list element. - itemText = itemText.replace(/^[ ]{0,3}([*+-]|\d+[.])\s/gm, - function (_) { - return getItemPrefix(); - }); - - return itemText; - }; - - chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null); - - if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) { - chunk.before += chunk.startTag; - chunk.startTag = ""; - } - - if (chunk.startTag) { - - var hasDigits = /\d+[.]/.test(chunk.startTag); - chunk.startTag = ""; - chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n"); - this.unwrap(chunk); - chunk.skipLines(); - - if (hasDigits) { - // Have to renumber the bullet points if this is a numbered list. - chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem); - } - if (isNumberedList == hasDigits) { - return; - } - } - - var nLinesUp = 1; - - chunk.before = chunk.before.replace(previousItemsRegex, - function (itemText) { - if (/^\s*([*+-])/.test(itemText)) { - bullet = re.$1; - } - nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; - return getPrefixedItem(itemText); - }); - - if (!chunk.selection) { - chunk.selection = this.getString("litem"); - } - - var prefix = getItemPrefix(); - - var nLinesDown = 1; - - chunk.after = chunk.after.replace(nextItemsRegex, - function (itemText) { - nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; - return getPrefixedItem(itemText); - }); - - chunk.trimWhitespace(true); - chunk.skipLines(nLinesUp, nLinesDown, true); - chunk.startTag = prefix; - var spaces = prefix.replace(/./g, " "); - this.wrap(chunk, SETTINGS.lineLength - spaces.length); - chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces); - - }; - - commandProto.doHeading = function (chunk, postProcessing) { - - // Remove leading/trailing whitespace and reduce internal spaces to single spaces. - chunk.selection = chunk.selection.replace(/\s+/g, " "); - chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, ""); - - // If we clicked the button with no selected text, we just - // make a level 2 hash header around some default text. - if (!chunk.selection) { - chunk.startTag = "## "; - - if(chunk.before === "" || !chunk.before.match(/\n$/)){ - chunk.startTag = "\n## "; - } - - chunk.selection = this.getString("headingexample"); - chunk.endTag = " ##"; - - if(chunk.after === "" || !chunk.after.match(/^\n/)){ - chunk.endTag = " ##\n"; - } - - return; - } - - var headerLevel = 0; // The existing header level of the selected text. - - // Remove any existing hash heading markdown and save the header level. - chunk.findTags(/#+[ ]*/, /[ ]*#+/); - if (/#+/.test(chunk.startTag)) { - headerLevel = re.lastMatch.length; - } - chunk.startTag = chunk.endTag = ""; - - // Try to get the current header level by looking for - and = in the line - // below the selection. - chunk.findTags(null, /\s?(-+|=+)/); - if (/=+/.test(chunk.endTag)) { - headerLevel = 1; - } - if (/-+/.test(chunk.endTag)) { - headerLevel = 2; - } - - // Skip to the next line so we can create the header markdown. - chunk.startTag = chunk.endTag = ""; - chunk.skipLines(1, 1); - - // We make a level 2 header if there is no current header. - // If there is a header level, we substract one from the header level. - // If it's already a level 1 header, it's removed. - var headerLevelToCreate = headerLevel == 0 ? 2 : headerLevel - 1; - - if (headerLevelToCreate > 0) { - - // The button only creates level 1 and 2 underline headers. - // Why not have it iterate over hash header levels? Wouldn't that be easier and cleaner? - var headerChar = headerLevelToCreate >= 2 ? "-" : "="; - var len = chunk.selection.length; - if (len > SETTINGS.lineLength) { - len = SETTINGS.lineLength; - } - chunk.endTag = "\n"; - while (len--) { - chunk.endTag += headerChar; - } - } - }; - - commandProto.doHorizontalRule = function (chunk, postProcessing) { - chunk.startTag = "----------\n"; - chunk.selection = ""; - chunk.skipLines(2, 1, true); - } - - -})(); diff --git a/app/assets/javascripts/discourse/lib/markdown.js b/app/assets/javascripts/discourse/lib/markdown.js index 888f282d182..82208382cf5 100644 --- a/app/assets/javascripts/discourse/lib/markdown.js +++ b/app/assets/javascripts/discourse/lib/markdown.js @@ -1,5 +1,3 @@ -/*global Markdown, console */ - /** Contains methods to help us with markdown formatting. @@ -152,58 +150,6 @@ Discourse.Markdown = { return this.markdownConverter(opts).makeHtml(raw); }, - createEditor: function(options) { - options = options || {}; - - // By default we always sanitize content in the editor - options.sanitize = true; - - var markdownConverter = Discourse.Markdown.markdownConverter(options); - - var editorOptions = { - containerElement: options.containerElement, - strings: { - bold: I18n.t("composer.bold_title") + " Ctrl+B", - boldexample: I18n.t("composer.bold_text"), - - italic: I18n.t("composer.italic_title") + " Ctrl+I", - italicexample: I18n.t("composer.italic_text"), - - link: I18n.t("composer.link_title") + " Ctrl+L", - linkdescription: I18n.t("composer.link_description"), - linkdialog: "

      " + I18n.t("composer.link_dialog_title") + "

      http://example.com/ \"" + - I18n.t("composer.link_optional_text") + "\"

      ", - - quote: I18n.t("composer.quote_title") + "
      Ctrl+Q", - quoteexample: I18n.t("composer.quote_text"), - - code: I18n.t("composer.code_title") + "
       Ctrl+K",
      -        codeexample: I18n.t("composer.code_text"),
      -
      -        image: I18n.t("composer.upload_title") + " - Ctrl+G",
      -        imagedescription: I18n.t("composer.upload_description"),
      -
      -        olist: I18n.t("composer.olist_title") + " 
        Ctrl+O", - ulist: I18n.t("composer.ulist_title") + "
          Ctrl+U", - litem: I18n.t("composer.list_item"), - - heading: I18n.t("composer.heading_title") + "

          /

          Ctrl+H", - headingexample: I18n.t("composer.heading_text"), - - hr: I18n.t("composer.hr_title") + "
          Ctrl+R", - - undo: I18n.t("composer.undo_title") + " - Ctrl+Z", - redo: I18n.t("composer.redo_title") + " - Ctrl+Y", - redomac: I18n.t("composer.redo_title") + " - Ctrl+Shift+Z", - - help: I18n.t("composer.help") - }, - appendButtons: options.appendButtons - }; - - return new Markdown.Editor(markdownConverter, undefined, editorOptions); - }, - /** Checks to see if a URL is allowed in the cooked content diff --git a/app/assets/javascripts/discourse/lib/show-modal.js.es6 b/app/assets/javascripts/discourse/lib/show-modal.js.es6 index 01cc0240213..485638cc476 100644 --- a/app/assets/javascripts/discourse/lib/show-modal.js.es6 +++ b/app/assets/javascripts/discourse/lib/show-modal.js.es6 @@ -1,4 +1,4 @@ -export default (name, opts) => { +export default function(name, opts) { opts = opts || {}; if (opts.__type) { diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index f398e791033..f01cec58164 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -215,10 +215,6 @@ Discourse.Utilities = { } }, - getUploadPlaceholder: function() { - return "[" + I18n.t("uploading") + "]() "; - }, - isAnImage: function(path) { return (/\.(png|jpe?g|gif|bmp|tiff?|svg|webp)$/i).test(path); }, diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index f566ef943a6..352c6f1989f 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -173,11 +173,6 @@ const Composer = RestModel.extend({ }.property('action', 'post', 'topic', 'topic.title'), - toggleText: function() { - return this.get('showPreview') ? I18n.t('composer.hide_preview') : I18n.t('composer.show_preview'); - }.property('showPreview'), - - hidePreview: Em.computed.not('showPreview'), // whether to disable the post button cantSubmitPost: function() { @@ -311,8 +306,6 @@ const Composer = RestModel.extend({ }.property('reply'), _setupComposer: function() { - const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); - this.set('showPreview', val === 'true'); this.set('archetypeId', this.site.get('default_archetype')); }.on('init'), @@ -364,11 +357,6 @@ const Composer = RestModel.extend({ return before.length + text.length; }, - togglePreview() { - this.toggleProperty('showPreview'); - this.keyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); - }, - applyTopicTemplate(oldCategoryId, categoryId) { if (this.get('action') !== CREATE_TOPIC) { return; } let reply = this.get('reply'); @@ -680,7 +668,7 @@ const Composer = RestModel.extend({ }, getCookedHtml() { - return $('#reply-control .wmd-preview').html().replace(/<\/span>/g, ''); + return $('#reply-control .d-editor-preview').html().replace(/<\/span>/g, ''); }, saveDraft() { diff --git a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 index d728c93f37d..382d7749da0 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -6,24 +6,29 @@ export default { initialize(container, application) { const $html = $('html'), touch = $html.hasClass('touch') || (Modernizr.prefixed("MaxTouchPoints", navigator) > 1), - caps = Ember.Object.create(); + caps = {touch}; // Store the touch ability in our capabilities object - caps.set('touch', touch); $html.addClass(touch ? 'discourse-touch' : 'discourse-no-touch'); // Detect Devices if (navigator) { const ua = navigator.userAgent; if (ua) { - caps.set('android', ua.indexOf('Android') !== -1); - caps.set('winphone', ua.indexOf('Windows Phone') !== -1); + caps.isAndroid = ua.indexOf('Android') !== -1; + caps.isWinphone = ua.indexOf('Windows Phone') !== -1; + + caps.isOpera = !!window.opera || ua.indexOf(' OPR/') >= 0; + caps.isFirefox = typeof InstallTrigger !== 'undefined'; + caps.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; + caps.isChrome = !!window.chrome && !caps.isOpera; + caps.canPasteImages = caps.isChrome || caps.isFirefox; } } // We consider high res a device with 1280 horizontal pixels. High DPI tablets like // iPads should report as 1024. - caps.set('highRes', window.screen.width >= 1280); + caps.highRes = window.screen.width >= 1280; // Inject it application.register('capabilities:main', caps, { instantiate: false }); diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index e3280b18206..7175aa86437 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -89,13 +89,11 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, { }, showNotActivated(props) { - const controller = showModal('not-activated', {title: 'log_in' }); - controller.setProperties(props); + showModal('not-activated', {title: 'log_in' }).setProperties(props); }, - showUploadSelector(composerView) { - showModal('uploadSelector'); - this.controllerFor('upload-selector').setProperties({ composerView: composerView }); + showUploadSelector(toolbarEvent) { + showModal('uploadSelector').setProperties({ toolbarEvent, imageUrl: null, imageLink: null }); }, showKeyboardShortcutsHelp() { diff --git a/app/assets/javascripts/discourse/templates/components/composer-editor.hbs b/app/assets/javascripts/discourse/templates/components/composer-editor.hbs new file mode 100644 index 00000000000..23acc3df15c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/composer-editor.hbs @@ -0,0 +1,30 @@ +{{d-editor tabindex="4" + value=composer.reply + placeholder="composer.reply_placeholder" + previewUpdated="previewUpdated" + markdownOptions=markdownOptions + extraButtons="extraButtons" + importQuote="importQuote" + showOptions="showOptions" + showUploadModal="showUploadModal" + validation=validation + loading=composer.loading}} + +
          + {{#if site.mobileView}} + + {{i18n 'upload'}} + {{else}} + {{{toggleText}}} + {{/if}} + {{#if isUploading}} +
          + {{loading-spinner size="small"}} {{i18n 'upload_selector.uploading'}} + {{uploadProgress}}% + {{fa-icon "times"}} +
          + {{/if}} +
          + {{draftStatus}} +
          +
          diff --git a/app/assets/javascripts/discourse/templates/components/composer-title.hbs b/app/assets/javascripts/discourse/templates/components/composer-title.hbs new file mode 100644 index 00000000000..1294aa29661 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/composer-title.hbs @@ -0,0 +1,8 @@ +{{text-field value=composer.title + tabindex="2" + id="reply-title" + maxLength=siteSettings.max_topic_title_length + placeholderKey="composer.title_placeholder" + disabled=composer.loading}} + +{{popup-input-tip validation=validation}} diff --git a/app/assets/javascripts/discourse/templates/components/d-editor.hbs b/app/assets/javascripts/discourse/templates/components/d-editor.hbs index 5375d94c188..64ea8909867 100644 --- a/app/assets/javascripts/discourse/templates/components/d-editor.hbs +++ b/app/assets/javascripts/discourse/templates/components/d-editor.hbs @@ -17,10 +17,17 @@ {{/unless}} {{/each}} +
          - {{textarea value=value class="d-editor-input"}} +
          + {{conditional-loading-spinner condition=loading}} + {{textarea tabindex=tabindex value=value class="d-editor-input" placeholder=placeholderTranslated}} + {{popup-input-tip validation=validation}} +
          -
          - {{{preview}}} +
          +
          + {{{preview}}} +
          diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 1ddddf852b9..aac6c07f10a 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -56,15 +56,12 @@ {{/if}} {{/if}} -
          - {{text-field value=model.title tabindex="2" id="reply-title" maxLength=maxTitleLength placeholderKey="composer.title_placeholder"}} - {{popup-input-tip validation=view.titleValidation shownAt=view.showTitleTip}} -
          + {{composer-title composer=model lastValidatedAt=lastValidatedAt}} {{#if model.showCategoryChooser}}
          {{category-chooser valueAttribute="id" value=model.categoryId scopedCategoryId=scopedCategoryId tabindex="3"}} - {{popup-input-tip validation=view.categoryValidation shownAt=view.showCategoryTip}} + {{popup-input-tip validation=categoryValidation}}
          {{#if model.archetype.hasOptions}} @@ -77,35 +74,15 @@ {{plugin-outlet "composer-fields"}} -
          -
          -
          -
          - {{conditional-loading-spinner condition=model.loading}} - {{composer-text-area tabindex="4" value=model.reply}} - {{popup-input-tip validation=view.replyValidation shownAt=view.showReplyTip}} -
          - -
          -
          -
          -
          - {{#if site.mobileView}} - - {{i18n 'upload'}} - {{else}} - {{{model.toggleText}}} - {{/if}} - {{#if view.isUploading}} -
          - {{loading-spinner size="small"}} {{i18n 'upload_selector.uploading'}} {{view.uploadProgress}}% {{fa-icon "times"}} -
          - {{/if}} -
          - {{model.draftStatus}} -
          -
          -
          + {{composer-editor topic=topic + composer=model + lastValidatedAt=lastValidatedAt + canWhisper=canWhisper + draftStatus=model.draftStatus + isUploading=isUploading + importQuote="importQuote" + showOptions="showOptions" + showUploadSelector="showUploadSelector"}} {{#if currentUser}}
          diff --git a/app/assets/javascripts/discourse/templates/modal/upload_selector.hbs b/app/assets/javascripts/discourse/templates/modal/upload-selector.hbs similarity index 63% rename from app/assets/javascripts/discourse/templates/modal/upload_selector.hbs rename to app/assets/javascripts/discourse/templates/modal/upload-selector.hbs index 254e02648b1..943ad5004a1 100644 --- a/app/assets/javascripts/discourse/templates/modal/upload_selector.hbs +++ b/app/assets/javascripts/discourse/templates/modal/upload-selector.hbs @@ -5,7 +5,7 @@ {{#if local}}

          - {{unbound view.tip}} + {{tip}}
          {{/if}}
          @@ -14,31 +14,34 @@ {{#if remote}}
          -
          - {{unbound view.tip}} + {{input value=imageUrl placeholder="http://example.com/image.png"}} + {{tip}}
          {{/if}} {{#if showMore}}
          -
          + {{input value=imageLink laceholder="http://example.com"}} {{i18n 'upload_selector.image_link'}}
          {{/if}}
          -

          {{unbound view.hint}}

          +

          + {{#if capabilities.canPasteImages}} + {{i18n 'upload_selector.hint'}} + {{else}} + {{i18n 'upload_selector.hint_for_supported_browsers'}} + {{/if}} +

          diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index a93086ad187..ac3a9a9bc91 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -15,9 +15,9 @@ {{#if editingTopic}} {{#if model.isPrivateMessage}} {{fa-icon "envelope"}} - {{autofocus-text-field id="edit-title" value=buffered.title maxLength=maxTitleLength}} + {{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}} {{else}} - {{autofocus-text-field id="edit-title" value=buffered.title maxLength=maxTitleLength}} + {{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}}
          {{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}} {{/if}} diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index a46a9e1e8ab..54da8125b9c 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -1,57 +1,25 @@ -import userSearch from 'discourse/lib/user-search'; import afterTransition from 'discourse/lib/after-transition'; -import loadScript from 'discourse/lib/load-script'; 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'; -import { showSelector } from 'discourse/lib/emoji/emoji-toolbar'; +import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators'; +import Composer from 'discourse/models/composer'; const ComposerView = Ember.View.extend(Ember.Evented, { _lastKeyTimeout: null, - templateName: 'composer', elementId: 'reply-control', - classNameBindings: ['model.creatingPrivateMessage:private-message', + classNameBindings: ['composer.creatingPrivateMessage:private-message', 'composeState', - 'model.loading', - 'model.canEditTitle:edit-title', - 'postMade', - 'model.creatingTopic:topic', - 'model.showPreview', - 'model.hidePreview'], + 'composer.loading', + 'composer.canEditTitle:edit-title', + 'composer.createdPost:created-post', + 'composer.creatingTopic:topic'], - model: Em.computed.alias('controller.model'), + composer: 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'), + @computed('composer.composeState') + composeState(composeState) { + return composeState || Composer.CLOSED; + }, movePanels(sizePx) { $('#main-outlet').css('padding-bottom', sizePx); @@ -60,44 +28,41 @@ const ComposerView = Ember.View.extend(Ember.Evented, { this.appEvents.trigger("composer:resized"); }, - resize: function() { + @observes('composeState', 'composer.action') + resize() { Ember.run.scheduleOnce('afterRender', () => { - let h = $('#reply-control').height() || 0; + const 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); + const fieldPos = $fields.position(); + if (fieldPos) { + this.$('.wmd-controls').css('top', $fields.height() + fieldPos.top + 5); } // get the submit panel height - pos = this.$('.submit-panel').position(); - if (pos) { - this.$('.wmd-controls').css('bottom', h - pos.top + 7); + const submitPos = this.$('.submit-panel').position(); + if (submitPos) { + this.$('.wmd-controls').css('bottom', h - submitPos.top + 7); } - }); - }.observes('model.composeState', 'model.action'), + }, keyUp() { const controller = this.get('controller'); controller.checkReplyLength(); - this.get('controller.model').typing(); + this.get('composer').typing(); const lastKeyUp = new Date(); - this.set('lastKeyUp', lastKeyUp); + this._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; + this._lastKeyTimeout = Ember.run.later(() => { + if (lastKeyUp !== this._lastKeyUp) { return; } // Search for similar topics if the user pauses typing controller.findSimilarTopics(); @@ -106,7 +71,6 @@ const ComposerView = Ember.View.extend(Ember.Evented, { keyDown(e) { if (e.which === 27) { - // ESC this.get('controller').send('hitEsc'); return false; } else if (e.which === 13 && (e.ctrlKey || e.metaKey)) { @@ -116,557 +80,25 @@ const ComposerView = Ember.View.extend(Ember.Evented, { } }, - _enableResizing: function() { + @on('didInsertElement') + _enableResizing() { const $replyControl = $('#reply-control'); - - const runResize = () => { - Ember.run(() => this.resize()); - }; + const resize = () => Ember.run(() => this.resize()); $replyControl.DivResizer({ - maxHeight(winHeight) { - return winHeight - headerHeight(); - }, - resize: runResize, - onDrag: (sizePx) => this.movePanels(sizePx) + resize, + maxHeight: winHeight => winHeight - headerHeight(), + onDrag: sizePx => this.movePanels(sizePx) }); - afterTransition($replyControl, runResize); - this.set('controller.view', this); - + afterTransition($replyControl, resize); 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 container = this.container; - const template = container.lookup('template:emoji-selector-autocomplete.raw'); - const controller = this.get('controller'); - - this.$('.wmd-input').autocomplete({ - template: template, - key: ":", - - transformComplete(v) { - if (v.code) { - return `${v.code}:`; - } else { - showSelector({ - container, - onSelect(title) { - controller.appendTextAtCursor(title + ':', {space: false}); - } - }); - return ""; - } - }, - - dataSource(term) { - return new Ember.RSVP.Promise(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(list => list.map(code => { - return {code, src: Discourse.Emoji.urlFor(code)}; - })).then(list => { - if (list.length) { - list.push({ label: I18n.t("composer.more_emoji") }); - } - return list; - }); - } - }); - }, - - 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; - const controller = this.get('controller'); - - 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: controller.get('controllers.topic.model.id'), - includeGroups: true - }); - }, - key: "@", - transformComplete(v) { - return v.username ? v.username : v.usernames.join(", @"); - } - }); - - - const options = { - containerElement: this.element, - lookupAvatarByPostNumber(postNumber, topicId) { - const posts = controller.get('controllers.topic.model.postStream.posts'); - if (posts && topicId === controller.get('controllers.topic.model.id')) { - const quotedPost = posts.findProperty("post_number", postNumber); - if (quotedPost) { - return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template')); - } - } - } - }; - - const showOptions = controller.get('canWhisper'); - if (showOptions) { - options.appendButtons = [{ - id: 'wmd-composer-options', - description: I18n.t("composer.options"), - execute() { - const toolbarPos = self.$('.wmd-controls').position(); - const pos = self.$('.wmd-composer-options').position(); - - const location = { - position: "absolute", - left: toolbarPos.left + pos.left, - top: toolbarPos.top + pos.top, - }; - controller.send('showOptions', location); - } - }]; - } - - this.editor = Discourse.Markdown.createEditor(options); - - // 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); - 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 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 => { - // reset upload state - reset(); - // replace upload placeholder - if (upload && upload.url) { - if (!cancelledByTheUser) { - const uploadPlaceholder = Discourse.Utilities.getUploadPlaceholder(), - markdown = Discourse.Utilities.getUploadMarkdown(upload); - this.replaceMarkdown(uploadPlaceholder, markdown); - } - } else { - Discourse.Utilities.displayErrorForUpload(upload); - } - }); - - $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 - controller.send("closeModal"); - // deal with cancellation - cancelledByTheUser = false; - // add upload placeholder - const uploadPlaceholder = Discourse.Utilities.getUploadPlaceholder(); - this.addMarkdown(uploadPlaceholder); - - 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) { - // signal the upload was cancelled by the user - cancelledByTheUser = true; - // immediately remove upload placeholder - this.replaceMarkdown(uploadPlaceholder, ""); - // 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 upload state - reset(); - - if (!cancelledByTheUser) { - // remove upload placeholder when there's a failure - const uploadPlaceholder = Discourse.Utilities.getUploadPlaceholder(); - this.replaceMarkdown(uploadPlaceholder, ""); - // display the error - 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 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 = function() { - // 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(function(blob) { - $uploadTarget.fileupload('add', {files: blob}); - }); - }; - image.src = imageSrc; - } else { - restoreSelection(""); - } - } - - contentEditableDiv.html(''); - }, 100); - } - }); - } - - if (Discourse.Mobile.mobileView) { - $(".mobile-file-upload").on("click.uploader", function () { - // redirect the click on the hidden file input - $("#mobile-uploader").click(); - }); - } - - // need to wait a bit for the "slide up" transition of the composer - // we could use .on("transitionend") but it's not firing when the transition isn't completed :( - Em.run.later(function() { - self.resize(); - self.refreshPreview(); - if ($replyTitle.length) { - $replyTitle.putCursorAtEnd(); - } else { - $wmdInput.putCursorAtEnd(); - } - self.appEvents.trigger("composer:opened"); - }, 400); - }, - - addMarkdown(text) { - const ctrl = this.$('.wmd-input').get(0), - reply = this.get('model.reply'), - caretPosition = Discourse.Utilities.caretPosition(ctrl); - - this.set('model.reply', reply.substring(0, caretPosition) + text + reply.substring(caretPosition, reply.length)); - - Em.run.schedule('afterRender', () => Discourse.Utilities.setCaretPosition(ctrl, caretPosition + text.length)); - }, - - replaceMarkdown(old, text) { - const ctrl = this.$(".wmd-input").get(0), - reply = this.get("model.reply"), - beforeCaretPosition = Discourse.Utilities.caretPosition(ctrl), - afterCaretPosition = beforeCaretPosition <= reply.indexOf(old) ? beforeCaretPosition : beforeCaretPosition - old.length + text.length; - - this.set("model.reply", reply.replace(old, text)); - - Ember.run.schedule("afterRender", () => Discourse.Utilities.setCaretPosition(ctrl, afterCaretPosition)); - }, - - // Uses javascript to get the image sizes from the preview, if present - imageSizes() { - const result = {}; - this.$('.wmd-preview img').each(function(i, e) { - const $img = $(e), - src = $img.prop('src'); - - if (src && src.length) { - result[src] = { width: $img.width(), height: $img.height() }; - } - }); - return result; - }, - - childDidInsertElement() { - this.initEditor(); - - // Disable links in the preview - this.$('.wmd-preview').on('click.preview', (e) => { - e.preventDefault(); - return false; - }); - }, - - childWillDestroyElement() { - this._unbindUploadTarget(); - - this.$('.wmd-preview').off('click.preview'); - - const self = this; - - Em.run.next(() => { - $('#main-outlet').css('padding-bottom', 0); - // need to wait a bit for the "slide down" transition of the composer - Em.run.later(() => { - if (self.get('composeState') !== Discourse.Composer.CLOSED) { - $('#main-outlet').css('padding-bottom', $('#reply-control').height()); - } - - this.appEvents.trigger("composer:closed"); - }, 400); - }); - }, - - _unbindUploadTarget() { - this.messageBus.unsubscribe("/uploads/composer"); - const $uploadTarget = $("#reply-control"); - try { $uploadTarget.fileupload("destroy"); } - catch (e) { /* wasn't initialized yet */ } - $uploadTarget.off(); - }, - - titleValidation: function() { - const titleLength = this.get('model.titleLength'), - missingChars = this.get('model.missingTitleCharacters'); - let 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() { - const postType = this.get('model.post.post_type'); - if (postType === this.site.get('post_types.small_action')) { return; } - - const replyLength = this.get('model.replyLength'), - missingChars = this.get('model.missingReplyCharacters'); - - let 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')}); - const tl = Discourse.User.currentProp("trust_level"); - if (tl === 0 || tl === 1) { - reason += "
          " + I18n.t('composer.error.try_like'); - } - } - - if (reason) { - return Discourse.InputValidation.create({ failed: true, reason }); - } - }.property('model.reply', 'model.replyLength', 'model.missingReplyCharacters', 'model.minimumPostLength'), + } }); RSVP.EventTarget.mixin(ComposerView); - export default ComposerView; diff --git a/app/assets/javascripts/discourse/views/quote-button.js.es6 b/app/assets/javascripts/discourse/views/quote-button.js.es6 index 8f4c5ab66dc..da9ba9040c8 100644 --- a/app/assets/javascripts/discourse/views/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/views/quote-button.js.es6 @@ -36,9 +36,8 @@ export default Ember.View.extend({ // the quote reply widget // // Same hack applied to Android cause it has unreliable touchend - const caps = this.capabilities; - const android = caps.get('android'); - if (caps.get('winphone') || android) { + const isAndroid = this.capabilities.isAndroid; + if (this.capabilities.isWinphone || isAndroid) { onSelectionChanged = _.debounce(onSelectionChanged, 500); } @@ -72,7 +71,7 @@ export default Ember.View.extend({ // Android is dodgy, touchend often will not fire // https://code.google.com/p/android/issues/detail?id=19827 - if (!android) { + if (!isAndroid) { $(document) .on('touchstart.quote-button', function(){ view.set('isTouchInProgress', true); diff --git a/app/assets/javascripts/discourse/views/topic-map-container.js.es6 b/app/assets/javascripts/discourse/views/topic-map-container.js.es6 index e13a1ae4101..5bd42eb3675 100644 --- a/app/assets/javascripts/discourse/views/topic-map-container.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-map-container.js.es6 @@ -1,5 +1,5 @@ import ContainerView from 'discourse/views/container'; -import { default as property, observes, on } from 'ember-addons/ember-computed-decorators'; +import { default as computed, observes, on } from 'ember-addons/ember-computed-decorators'; export default ContainerView.extend({ classNameBindings: ['hidden', ':topic-map'], @@ -9,7 +9,7 @@ export default ContainerView.extend({ Ember.run.once(this, 'rerender'); }, - @property + @computed hidden() { if (!this.get('post.firstPost')) return true; diff --git a/app/assets/javascripts/discourse/views/topic-progress.js.es6 b/app/assets/javascripts/discourse/views/topic-progress.js.es6 index b539b618b4f..6bb2604ac04 100644 --- a/app/assets/javascripts/discourse/views/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-progress.js.es6 @@ -76,7 +76,7 @@ export default Ember.View.extend({ _focusWhenOpened: function() { // Don't focus on mobile or touch - if (Discourse.Mobile.mobileView || this.capabilities.get('touch')) { + if (Discourse.Mobile.mobileView || this.capabilities.touch) { return; } diff --git a/app/assets/javascripts/discourse/views/upload-selector.js.es6 b/app/assets/javascripts/discourse/views/upload-selector.js.es6 index 4bf80f2aad8..7f6af6752bc 100644 --- a/app/assets/javascripts/discourse/views/upload-selector.js.es6 +++ b/app/assets/javascripts/discourse/views/upload-selector.js.es6 @@ -1,74 +1,33 @@ import ModalBodyView from "discourse/views/modal-body"; +import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators'; +import { uploadTranslate } from 'discourse/controllers/upload-selector'; -function uploadTranslate(key, options) { - const opts = options || {}; - if (Discourse.Utilities.allowsAttachments()) { key += "_with_attachments"; } - return I18n.t("upload_selector." + key, opts); -} export default ModalBodyView.extend({ - templateName: 'modal/upload_selector', + templateName: 'modal/upload-selector', classNames: ['upload-selector'], - // cf. http://stackoverflow.com/a/9851769/11983 - isOpera: !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0, - isFirefox: typeof InstallTrigger !== 'undefined', - isSafari: Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0, - isChrome: !!window.chrome && !this.isOpera, + @computed() + title() { + return uploadTranslate("title"); + }, - title: function() { return uploadTranslate("title"); }.property(), - uploadIcon: function() { return Discourse.Utilities.allowsAttachments() ? "fa-upload" : "fa-picture-o"; }.property(), - - touchStart: function(evt) { + touchStart(evt) { // HACK: workaround Safari iOS being really weird and not shipping click events - if (this.isSafari && evt.target.id === "filename-input") { + if (this.capabilities.isSafari && evt.target.id === "filename-input") { this.$('#filename-input').click(); } }, - tip: function() { - const source = this.get("controller.local") ? "local" : "remote"; - const authorized_extensions = Discourse.Utilities.authorizesAllExtensions() ? "" : `(${Discourse.Utilities.authorizedExtensions()})`; - return uploadTranslate(source + "_tip", { authorized_extensions }); - }.property("controller.local"), - - hint: function() { - const isSupported = this.isChrome || this.isFirefox; - // chrome is the only browser that support copy & paste of images. - return I18n.t("upload_selector.hint" + (isSupported ? "_for_supported_browsers" : "")); - }.property(), - - _selectOnInsert: function() { - this.selectedChanged(); - }.on('didInsertElement'), - - selectedChanged: function() { - const self = this; - Em.run.next(function() { + @on('didInsertElement') + @observes('controller.local') + selectedChanged() { + Ember.run.next(() => { // *HACK* to select the proper radio button - var value = self.get('controller.local') ? 'local' : 'remote'; + const value = this.get('controller.local') ? 'local' : 'remote'; $('input:radio[name="upload"]').val([value]); - // focus the input $('.inputs input:first').focus(); }); - }.observes('controller.local'), - - actions: { - upload: function() { - if (this.get("controller.local")) { - $('#reply-control').fileupload('add', { fileInput: $('#filename-input') }); - } else { - const imageUrl = $('#fileurl-input').val(), - imageLink = $('#link-input').val(), - composerView = this.get('controller.composerView'); - if (this.get("controller.showMore") && imageLink.length > 3) { - composerView.addMarkdown("[![](" + imageUrl +")](" + imageLink + ")"); - } else { - composerView.addMarkdown(imageUrl); - } - this.get('controller').send('closeModal'); - } - } } }); diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index fbb72ad3f31..853d0d5e241 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -1,9 +1,6 @@ //= require ./discourse/mixins/ajax //= require ./discourse -// Pagedown customizations -//= require ./pagedown_custom.js - // Stuff we need to load first //= require_tree ./ember-addons/utils //= require ./ember-addons/decorator-alias @@ -77,6 +74,7 @@ //= require ./discourse/lib/emoji/emoji //= require ./discourse/lib/emoji/emoji-groups //= require ./discourse/lib/emoji/emoji-toolbar +//= require ./discourse/components/d-editor //= require ./discourse/views/composer //= require ./discourse/lib/show-modal //= require ./discourse/lib/screen-track diff --git a/app/assets/javascripts/pagedown_custom.js b/app/assets/javascripts/pagedown_custom.js deleted file mode 100644 index 8dec5168116..00000000000 --- a/app/assets/javascripts/pagedown_custom.js +++ /dev/null @@ -1,37 +0,0 @@ -window.PagedownCustom = { - insertButtons: [ - { - id: 'wmd-quote-post', - description: I18n.t("composer.quote_post_title"), - execute: function() { - return Discourse.__container__.lookup('controller:composer').send('importQuote'); - } - } - ], - - appendButtons: [], - - customActions: { - "doBlockquote": function(chunk, postProcessing, oldDoBlockquote) { - - // When traditional linebreaks are set, use the default Pagedown implementation - if (Discourse.SiteSettings.traditional_markdown_linebreaks) { - return oldDoBlockquote.call(this, chunk, postProcessing); - } - - // Our custom blockquote for non-traditional markdown linebreaks - var result = []; - chunk.selection.split(/\n/).forEach(function (line) { - var newLine = ""; - if (line.indexOf("> ") === 0) { - newLine += line.substr(2); - } else { - if (/\S/.test(line)) { newLine += "> " + line; } - } - result.push(newLine); - }); - chunk.selection = result.join("\n"); - - } - } -}; diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index d4ab16ec4f7..55f37c06b73 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -1188,7 +1188,7 @@ table.api-keys { height: 200px; } - .wmd-input { + .d-editor-input { width: 98%; height: 200px; } diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 56bfa280761..a2766118669 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -38,7 +38,7 @@ } } -.textarea-wrapper .spinner { +.d-editor-textarea-wrapper .spinner { z-index: 1000; margin-top: 5em; } @@ -99,10 +99,6 @@ div.ac-wrap { } } -#reply-control.topic #wmd-quote-post { - display: none; -} - .auto-close-fields { div:not(:first-child) { margin-top: 10px; @@ -175,7 +171,7 @@ div.ac-wrap { // this removes the topmost margin from the first element in the topic post // if we don't do this, all posts would have extra space at the top -.wmd-preview > *:first-child { +.d-editor-preview > *:first-child { margin-top: 0; } .cooked > *:first-child { diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index 2580ff1effe..95ccddffbc6 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -149,7 +149,7 @@ body { background-color: dark-light-choose(scale-color($danger, $lightness: 80%), scale-color($danger, $lightness: -60%)); } - .wmd-input { + .d-editor-input { resize: none; } diff --git a/app/assets/stylesheets/common/base/pagedown.scss b/app/assets/stylesheets/common/base/pagedown.scss deleted file mode 100644 index 1ee211aa503..00000000000 --- a/app/assets/stylesheets/common/base/pagedown.scss +++ /dev/null @@ -1,145 +0,0 @@ -// styles that apply to the PageDown editor -// http://code.google.com/p/pagedown/ - -.wmd-panel { - margin-left: 25%; - margin-right: 25%; - width: 50%; - min-width: 500px; -} - -.wmd-button-bar { - width: 100%; -} - -.wmd-button-row { - margin: 5px; - padding: 0; - height: 20px; - overflow: hidden; -} - -.wmd-spacer { - width: 1px; - height: 20px; - margin-right: 8px; - margin-left: 5px; - background-color: dark-light-diff($primary, $secondary, 90%, -60%); - display: inline-block; - float: left; -} - -.wmd-button { - margin-right: 5px; - border: 0; - position: relative; - float: left; - font-family: FontAwesome; - font-weight: normal; - font-style: normal; - text-decoration: inherit; - display: inline; - width: auto; - height: auto; - line-height: normal; - vertical-align: baseline; - background-image: none !important; - background-position: 0 0; - background-repeat: repeat; - background: transparent; - padding: 4px; -} - -.wmd-button:hover { - background-color: dark-light-diff($primary, $secondary, 90%, -60%); -} - - -.wmd-bold-button:before { - content: "\f032"; -} - -.wmd-italic-button:before { - content: "\f033"; -} - -.wmd-link-button:before { - content: "\f0c1"; -} - -.wmd-quote-button:before { - content: "\f10e"; -} - -.wmd-code-button:before { - content: "\f121"; -} - -.wmd-image-button:before { - content: "\f093"; -} - -.wmd-image-button.image-only:before { - content: "\f03e"; -} - -.wmd-olist-button:before { - content: "\f0cb"; -} - -.wmd-ulist-button:before { - content: "\f0ca"; -} - -.wmd-heading-button:before { - content: "\f031"; -} - -.wmd-hr-button:before { - content: "\f068"; -} - -.wmd-undo-button:before { - content: "\f0e2"; -} - -.wmd-redo-button:before { - content: "\f01e"; -} - -.wmd-quote-post:before { - content: "\f0e5"; -} - -.wmd-composer-options:before { - content: "\f013"; -} - -.wmd-prompt-background { - background-color: #111; - box-shadow: 0 3px 7px rgba(0,0,0, .8); -} - -.wmd-prompt-dialog { - border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - background-color: dark-light-diff($primary, $secondary, 90%, -60%); -} - -.wmd-prompt-dialog > div { - font-size: 0.8em; - font-family: arial, helvetica, sans-serif; -} - -.wmd-prompt-dialog > form > input[type="text"] { - border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - color: $primary; -} - -.wmd-prompt-dialog > form > input[type="button"] { - border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - background: dark-light-choose(initial, blend-primary-secondary(50%)); - color: dark-light-choose(inherit, $secondary); - font-family: trebuchet MS, helvetica, sans-serif; - font-size: 0.8em; - font-weight: bold; -} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index dab4f08f13f..02cacad3280 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -26,7 +26,7 @@ } // global styles for the cooked HTML content in posts (and preview) -.cooked, .wmd-preview { +.cooked, .d-editor-preview { word-wrap: break-word; h1, h2, h3, h4, h5, h6 { margin: 30px 0 10px; } h1 { line-height: 1em; } /* normalize.css sets h1 font size but not line height */ @@ -36,7 +36,7 @@ } -.cooked, .wmd-preview { +.cooked, .d-editor-preview { video { max-width: 100%; } diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 18faf89732f..c3a5f73094e 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -278,14 +278,14 @@ background-color: dark-light-diff($primary, $secondary, 90%, -60%); } } - .wmd-input:disabled { + .d-editor-input:disabled { background-color: dark-light-diff($primary, $secondary, 90%, -60%); } - .wmd-input, .wmd-preview { + .d-editor-input, .d-editor-preview { color: $primary; } - .wmd-preview { + .d-editor-preview { border: 1px dashed dark-light-diff($primary, $secondary, 90%, -60%); overflow: auto; visibility: visible; @@ -303,7 +303,7 @@ visibility: hidden; } } - .wmd-input { + .d-editor-input { bottom: 35px; } @@ -351,19 +351,18 @@ } #reply-control { - &.hide-preview { - .wmd-controls { - .wmd-input { - width: 100%; - } - .preview-wrapper { - display: none; - } - .textarea-wrapper { - width: 100%; - } + .wmd-controls.hide-preview { + .d-editor-input { + width: 100%; + } + .d-editor-preview-wrapper { + display: none; + } + .d-editor-textarea-wrapper { + width: 100%; } } + .wmd-controls { left: 30px; right: 30px; @@ -372,7 +371,7 @@ top: 50px; - .wmd-input, .wmd-preview-scroller, .wmd-preview { + .d-editor-input, .d-editor-preview { -moz-box-sizing: border-box; box-sizing: border-box; width: 100%; @@ -383,7 +382,7 @@ background-color: $secondary; word-wrap: break-word; } - .wmd-input, .wmd-preview-scroller { + .d-editor-input, .d-editor-preview-header { position: absolute; left: 0; top: 0; @@ -391,18 +390,17 @@ border-top: 30px solid transparent; @include border-radius-all(0); } - .wmd-preview-scroller { + .d-editor-preview-header { font-size: 0.929em; line-height: 18px; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; overflow: scroll; - visibility: hidden; .marker, .caret { display: inline-block; vertical-align: top; } } - .textarea-wrapper, .preview-wrapper { + .d-editor, .d-editor-container, .d-editor-textarea-wrapper, .d-editor-preview-wrapper { position: relative; -moz-box-sizing: border-box; box-sizing: border-box; @@ -410,9 +408,9 @@ min-height: 100%; margin: 0; padding: 0; - width: 50%; } - .textarea-wrapper { + .d-editor-textarea-wrapper { + width: 50%; padding-right: 5px; float: left; .popup-tip { @@ -420,12 +418,13 @@ right: 4px; } } - .preview-wrapper { + .d-editor-preview-wrapper { + width: 50%; padding-left: 5px; float: right; } } - .wmd-button-bar { + .d-editor-button-bar { top: 0; position: absolute; border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); diff --git a/app/assets/stylesheets/desktop/queued-posts.scss b/app/assets/stylesheets/desktop/queued-posts.scss index 06e24ab1e15..6d306bae6ee 100644 --- a/app/assets/stylesheets/desktop/queued-posts.scss +++ b/app/assets/stylesheets/desktop/queued-posts.scss @@ -18,7 +18,7 @@ width: $topic-body-width; float: left; - .wmd-input { + .d-editor-input { width: 98%; height: 15em; } diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index cd1f837e67a..10a6b9d4558 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -39,10 +39,6 @@ } } - .bio-composer #wmd-quote-post { - display: none; - } - .static { color: $primary; display: inline-block; diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index d63afa772de..b477ca6f2c8 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -163,13 +163,13 @@ input { background-color: dark-light-choose(scale-color($primary, $lightness: 75%), scale-color($secondary, $lightness: 25%)); } } - .wmd-input:disabled { + .d-editor-input:disabled { background-color: dark-light-choose(scale-color($primary, $lightness: 75%), scale-color($secondary, $lightness: 25%)); } - .wmd-input { + .d-editor-input { color: dark-light-choose(darken($primary, 40%), blend-primary-secondary(90%)); } - .wmd-input { + .d-editor-input { bottom: 35px; } .submit-panel { @@ -196,7 +196,7 @@ input { width: 240px; right: 5px; } - .textarea-wrapper .popup-tip { + .d-editor-textarea-wrapper .popup-tip { top: 28px; } button.btn.no-text { @@ -221,23 +221,22 @@ input { top: 40px; bottom: 50px; display: block; - - .wmd-input { + .d-editor-container { + padding: 0; + } + .d-editor-preview-wrapper { + display: none; + } + .d-editor-input { width: 100%; - height: 100%; - min-height: 100%; + height: 180px; padding: 7px; margin: 0; background-color: $secondary; word-wrap: break-word; box-sizing: border-box; } - .wmd-input { - position: absolute; - left: 0; - top: 0; - } - .textarea-wrapper { + .d-editor-textarea-wrapper { position: relative; box-sizing: border-box; height: 100%; @@ -250,7 +249,7 @@ input { } } } - .wmd-button-bar { + .d-editor-button-bar { display: none; } } diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index 58e9ed8f0b9..7398fc6b5f5 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -63,10 +63,6 @@ padding: 5px 8px; } - .bio-composer #wmd-quote-post { - display: none; - } - textarea {width: 100%;} } @@ -99,10 +95,6 @@ } } - .bio-composer #wmd-quote-post { - display: none; - } - .static { color: $primary; display: inline-block; diff --git a/spec/phantom_js/smoke_test.js b/spec/phantom_js/smoke_test.js index 94e69310204..2399ea848dc 100644 --- a/spec/phantom_js/smoke_test.js +++ b/spec/phantom_js/smoke_test.js @@ -179,12 +179,12 @@ var runTests = function() { $("#create-topic").click(); $("#reply-title").val(title).trigger("change"); - $("#reply-control .wmd-input").val(post).trigger("change"); - $("#reply-control .wmd-input").focus()[0].setSelectionRange(post.length, post.length); + $("#reply-control .d-editor-input").val(post).trigger("change"); + $("#reply-control .d-editor-input").focus()[0].setSelectionRange(post.length, post.length); }); exec("open upload modal", function() { - $(".wmd-image-button").click(); + $(".d-editor-button-bar .upload").click(); }); test("upload modal is open", function() { @@ -214,16 +214,16 @@ var runTests = function() { }); test("composer is open", function() { - return document.querySelector("#reply-control .wmd-input"); + return document.querySelector("#reply-control .d-editor-input"); }); exec("compose reply", function() { var post = "I can even write a reply inside the smoke test ;) (" + (+new Date()) + ")"; - $("#reply-control .wmd-input").val(post).trigger("change"); + $("#reply-control .d-editor-input").val(post).trigger("change"); }); test("waiting for the preview", function() { - return $(".wmd-preview").text().trim().indexOf("I can even write") === 0; + return $(".d-editor-preview").text().trim().indexOf("I can even write") === 0; }); execAsync("submit the reply", 6000, function() { diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 index 835a3429642..76966aa689e 100644 --- a/test/javascripts/acceptance/composer-test.js.es6 +++ b/test/javascripts/acceptance/composer-test.js.es6 @@ -10,25 +10,25 @@ test("Tests the Composer controls", () => { click('#create-topic'); andThen(() => { - ok(exists('.wmd-input'), 'the composer input is visible'); + ok(exists('.d-editor-input'), 'the composer input is visible'); ok(exists('.title-input .popup-tip.bad.hide'), 'title errors are hidden by default'); - ok(exists('.textarea-wrapper .popup-tip.bad.hide'), 'body errors are hidden by default'); + ok(exists('.d-editor-textarea-wrapper .popup-tip.bad.hide'), 'body errors are hidden by default'); }); click('a.toggle-preview'); andThen(() => { - ok(!exists('.wmd-preview:visible'), "clicking the toggle hides the preview"); + ok(!exists('.d-editor-preview:visible'), "clicking the toggle hides the preview"); }); click('a.toggle-preview'); andThen(() => { - ok(exists('.wmd-preview:visible'), "clicking the toggle shows the preview again"); + ok(exists('.d-editor-preview:visible'), "clicking the toggle shows the preview again"); }); click('#reply-control button.create'); andThen(() => { ok(!exists('.title-input .popup-tip.bad.hide'), 'it shows the empty title error'); - ok(!exists('.textarea-wrapper .popup-tip.bad.hide'), 'it shows the empty body error'); + ok(!exists('.d-editor-wrapper .popup-tip.bad.hide'), 'it shows the empty body error'); }); fillIn('#reply-title', "this is my new topic title"); @@ -36,10 +36,10 @@ test("Tests the Composer controls", () => { ok(exists('.title-input .popup-tip.good'), 'the title is now good'); }); - fillIn('.wmd-input', "this is the *content* of a post"); + fillIn('.d-editor-input', "this is the *content* of a post"); andThen(() => { - equal(find('.wmd-preview').html(), "

          this is the content of a post

          ", "it previews content"); - ok(exists('.textarea-wrapper .popup-tip.good'), 'the body is now good'); + equal(find('.d-editor-preview').html().trim(), "

          this is the content of a post

          ", "it previews content"); + ok(exists('.d-editor-textarea-wrapper .popup-tip.good'), 'the body is now good'); }); click('#reply-control a.cancel'); @@ -58,7 +58,7 @@ test("Create a topic with server side errors", () => { visit("/"); click('#create-topic'); fillIn('#reply-title', "this title triggers an error"); - fillIn('.wmd-input', "this is the *content* of a post"); + fillIn('.d-editor-input', "this is the *content* of a post"); click('#reply-control button.create'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up an error message'); @@ -66,7 +66,7 @@ test("Create a topic with server side errors", () => { click('.bootbox.modal a.btn-primary'); andThen(() => { ok(!exists('.bootbox.modal'), 'it dismisses the error'); - ok(exists('.wmd-input'), 'the composer input is visible'); + ok(exists('.d-editor-input'), 'the composer input is visible'); }); }); @@ -74,7 +74,7 @@ test("Create a Topic", () => { visit("/"); click('#create-topic'); fillIn('#reply-title', "Internationalization Localization"); - fillIn('.wmd-input', "this is the *content* of a new topic post"); + fillIn('.d-editor-input', "this is the *content* of a new topic post"); click('#reply-control button.create'); andThen(() => { equal(currentURL(), "/t/internationalization-localization/280", "it transitions to the newly created topic URL"); @@ -85,7 +85,7 @@ test("Create an enqueued Topic", () => { visit("/"); click('#create-topic'); fillIn('#reply-title', "Internationalization Localization"); - fillIn('.wmd-input', "enqueue this content please"); + fillIn('.d-editor-input', "enqueue this content please"); click('#reply-control button.create'); andThen(() => { ok(visible('#discourse-modal'), 'it pops up a modal'); @@ -108,11 +108,11 @@ test("Create a Reply", () => { click('#topic-footer-buttons .btn.create'); andThen(() => { - ok(exists('.wmd-input'), 'the composer input is visible'); + ok(exists('.d-editor-input'), 'the composer input is visible'); ok(!exists('#reply-title'), 'there is no title since this is a reply'); }); - fillIn('.wmd-input', 'this is the content of my reply'); + fillIn('.d-editor-input', 'this is the content of my reply'); click('#reply-control button.create'); andThen(() => { equal(find('.cooked:last p').text(), 'this is the content of my reply'); @@ -122,7 +122,7 @@ test("Create a Reply", () => { test("Posting on a different topic", (assert) => { visit("/t/internationalization-localization/280"); click('#topic-footer-buttons .btn.create'); - fillIn('.wmd-input', 'this is the content for a different topic'); + fillIn('.d-editor-input', 'this is the content for a different topic'); visit("/t/1-3-0beta9-no-rate-limit-popups/28830"); andThen(function() { @@ -145,11 +145,11 @@ test("Create an enqueued Reply", () => { click('#topic-footer-buttons .btn.create'); andThen(() => { - ok(exists('.wmd-input'), 'the composer input is visible'); + ok(exists('.d-editor-input'), 'the composer input is visible'); ok(!exists('#reply-title'), 'there is no title since this is a reply'); }); - fillIn('.wmd-input', 'enqueue this content please'); + fillIn('.d-editor-input', 'enqueue this content please'); click('#reply-control button.create'); andThen(() => { ok(find('.cooked:last p').text() !== 'enqueue this content please', "it doesn't insert the post"); @@ -173,14 +173,14 @@ test("Edit the first post", () => { click('.topic-post:eq(0) button[data-action=showMoreActions]'); click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { - equal(find('.wmd-input').val().indexOf('Any plans to support'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('Any plans to support'), 0, 'it populates the input with the post text'); }); - fillIn('.wmd-input', "This is the new text for the post"); + fillIn('.d-editor-input', "This is the new text for the post"); fillIn('#reply-title', "This is the new text for the title"); click('#reply-control button.create'); andThen(() => { - ok(!exists('.wmd-input'), 'it closes the composer'); + ok(!exists('.d-editor-input'), 'it closes the composer'); ok(exists('.topic-post:eq(0) .post-info.edits'), 'it has the edits icon'); ok(find('#topic-title h1').text().indexOf('This is the new text for the title') !== -1, 'it shows the new title'); ok(find('.topic-post:eq(0) .cooked').text().indexOf('This is the new text for the post') !== -1, 'it updates the post'); @@ -192,11 +192,11 @@ test("Composer can switch between edits", () => { click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); click('.topic-post:eq(1) button[data-action=edit]'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the second post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the second post.'), 0, 'it populates the input with the post text'); }); }); @@ -204,14 +204,14 @@ test("Composer with dirty edit can toggle to another edit", () => { visit("/t/this-is-a-test-topic/9"); click('.topic-post:eq(0) button[data-action=edit]'); - fillIn('.wmd-input', 'This is a dirty reply'); + fillIn('.d-editor-input', 'This is a dirty reply'); click('.topic-post:eq(1) button[data-action=edit]'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); }); click('.modal-footer a:eq(0)'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the second post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the second post.'), 0, 'it populates the input with the post text'); }); }); @@ -220,15 +220,15 @@ test("Composer can toggle between edit and reply", () => { click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); click('.topic-post:eq(0) button[data-action=reply]'); andThen(() => { - equal(find('.wmd-input').val(), "", 'it clears the input'); + equal(find('.d-editor-input').val(), "", 'it clears the input'); }); click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); }); @@ -236,14 +236,14 @@ test("Composer with dirty reply can toggle to edit", () => { visit("/t/this-is-a-test-topic/9"); click('.topic-post:eq(0) button[data-action=reply]'); - fillIn('.wmd-input', 'This is a dirty reply'); + fillIn('.d-editor-input', 'This is a dirty reply'); click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); }); click('.modal-footer a:eq(0)'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); }); @@ -251,7 +251,7 @@ test("Composer draft with dirty reply can toggle to edit", () => { visit("/t/this-is-a-test-topic/9"); click('.topic-post:eq(0) button[data-action=reply]'); - fillIn('.wmd-input', 'This is a dirty reply'); + fillIn('.d-editor-input', 'This is a dirty reply'); click('.toggler'); click('.topic-post:eq(0) button[data-action=edit]'); andThen(() => { @@ -259,6 +259,6 @@ test("Composer draft with dirty reply can toggle to edit", () => { }); click('.modal-footer a:eq(0)'); andThen(() => { - equal(find('.wmd-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); + equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); }); diff --git a/test/javascripts/components/d-editor-test.js.es6 b/test/javascripts/components/d-editor-test.js.es6 index 572d7342693..08f3568fdbe 100644 --- a/test/javascripts/components/d-editor-test.js.es6 +++ b/test/javascripts/components/d-editor-test.js.es6 @@ -8,13 +8,10 @@ componentTest('preview updates with markdown', { test(assert) { assert.ok(this.$('.d-editor-button-bar').length); - assert.equal(this.$('.d-editor-preview.hidden').length, 1); - fillIn('.d-editor-input', 'hello **world**'); andThen(() => { assert.equal(this.get('value'), 'hello **world**'); - assert.equal(this.$('.d-editor-preview.hidden').length, 0); assert.equal(this.$('.d-editor-preview').html().trim(), '

          hello world

          '); }); } diff --git a/test/javascripts/models/composer-test.js.es6 b/test/javascripts/models/composer-test.js.es6 index a31c569caeb..a6c419cf0cb 100644 --- a/test/javascripts/models/composer-test.js.es6 +++ b/test/javascripts/models/composer-test.js.es6 @@ -1,13 +1,10 @@ import { blank } from 'helpers/qunit-helpers'; import { currentUser } from 'helpers/qunit-helpers'; -import KeyValueStore from 'discourse/lib/key-value-store'; import Composer from 'discourse/models/composer'; import createStore from 'helpers/create-store'; module("model:composer"); -const keyValueStore = new KeyValueStore("_test_composer"); - function createComposer(opts) { opts = opts || {}; opts.user = opts.user || currentUser(); @@ -185,22 +182,6 @@ test('initial category when uncategorized is not allowed', function() { ok(!composer.get('categoryId'), "Uncategorized by default. Must choose a category."); }); -test('showPreview', function() { - const newComposer = function() { - return openComposer({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); - }; - - Discourse.Mobile.mobileView = true; - equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view"); - - keyValueStore.set({ key: 'composer.showPreview', value: 'true' }); - equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view even if KeyValueStore wants to"); - keyValueStore.remove('composer.showPreview'); - - Discourse.Mobile.mobileView = false; - equal(newComposer().get('showPreview'), true, "Show preview by default in desktop view"); -}); - test('open with a quote', function() { const quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]'; const newComposer = function() { diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index cc05265ff78..f75d5d89705 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -21,9 +21,6 @@ //= require ../../app/assets/javascripts/locales/i18n //= require ../../app/assets/javascripts/locales/en -// Pagedown customizations -//= require ../../app/assets/javascripts/pagedown_custom.js - //= require vendor //= require htmlparser.js