Skip to content
Permalink
Browse files

FEATURE: Replace composer editor with ember version

  • Loading branch information...
eviltrout committed Nov 3, 2015
1 parent fc27b74 commit 47495a5713fe5f3c9dfdbefdb3921378e8782093
Showing with 771 additions and 3,420 deletions.
  1. +0 −1 .codeclimate.yml
  2. +0 −2 .eslintignore
  3. +354 −0 app/assets/javascripts/discourse/components/composer-editor.js.es6
  4. +0 −15 app/assets/javascripts/discourse/components/composer-text-area.js.es6
  5. +29 −0 app/assets/javascripts/discourse/components/composer-title.js.es6
  6. +53 −7 app/assets/javascripts/discourse/components/d-editor.js.es6
  7. +1 −1 app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6
  8. +2 −2 app/assets/javascripts/discourse/components/image-uploader.js.es6
  9. +9 −4 app/assets/javascripts/discourse/components/popup-input-tip.js.es6
  10. +2 −2 app/assets/javascripts/discourse/components/post-gutter.js.es6
  11. +44 −65 app/assets/javascripts/discourse/controllers/composer.js.es6
  12. +3 −3 app/assets/javascripts/discourse/controllers/quote-button.js.es6
  13. +0 −3 app/assets/javascripts/discourse/controllers/topic.js.es6
  14. +47 −3 app/assets/javascripts/discourse/controllers/upload-selector.js.es6
  15. +0 −16 app/assets/javascripts/discourse/initializers/enable-emoji.js.es6
  16. +1 −1 app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6
  17. +0 −2,190 app/assets/javascripts/discourse/lib/Markdown.Editor.js
  18. +0 −54 app/assets/javascripts/discourse/lib/markdown.js
  19. +1 −1 app/assets/javascripts/discourse/lib/show-modal.js.es6
  20. +0 −4 app/assets/javascripts/discourse/lib/utilities.js
  21. +1 −13 app/assets/javascripts/discourse/models/composer.js.es6
  22. +10 −5 app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6
  23. +3 −5 app/assets/javascripts/discourse/routes/application.js.es6
  24. +30 −0 app/assets/javascripts/discourse/templates/components/composer-editor.hbs
  25. +8 −0 app/assets/javascripts/discourse/templates/components/composer-title.hbs
  26. +10 −3 app/assets/javascripts/discourse/templates/components/d-editor.hbs
  27. +11 −34 app/assets/javascripts/discourse/templates/composer.hbs
  28. +13 −10 app/assets/javascripts/discourse/templates/modal/{upload_selector.hbs → upload-selector.hbs}
  29. +2 −2 app/assets/javascripts/discourse/templates/topic.hbs
  30. +35 −603 app/assets/javascripts/discourse/views/composer.js.es6
  31. +3 −4 app/assets/javascripts/discourse/views/quote-button.js.es6
  32. +2 −2 app/assets/javascripts/discourse/views/topic-map-container.js.es6
  33. +1 −1 app/assets/javascripts/discourse/views/topic-progress.js.es6
  34. +14 −55 app/assets/javascripts/discourse/views/upload-selector.js.es6
  35. +1 −3 app/assets/javascripts/main_include.js
  36. +0 −37 app/assets/javascripts/pagedown_custom.js
  37. +1 −1 app/assets/stylesheets/common/admin/admin_base.scss
  38. +2 −6 app/assets/stylesheets/common/base/compose.scss
  39. +1 −1 app/assets/stylesheets/common/base/discourse.scss
  40. +0 −145 app/assets/stylesheets/common/base/pagedown.scss
  41. +2 −2 app/assets/stylesheets/common/base/topic-post.scss
  42. +23 −24 app/assets/stylesheets/desktop/compose.scss
  43. +1 −1 app/assets/stylesheets/desktop/queued-posts.scss
  44. +0 −4 app/assets/stylesheets/desktop/user.scss
  45. +14 −15 app/assets/stylesheets/mobile/compose.scss
  46. +0 −8 app/assets/stylesheets/mobile/user.scss
  47. +6 −6 spec/phantom_js/smoke_test.js
  48. +31 −31 test/javascripts/acceptance/composer-test.js.es6
  49. +0 −3 test/javascripts/components/d-editor-test.js.es6
  50. +0 −19 test/javascripts/models/composer-test.js.es6
  51. +0 −3 test/javascripts/test_helper.js
@@ -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/*"
@@ -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
@@ -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 += "<br/>" + 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.$("<div id='contenteditable' contenteditable='true' style='height: 0; width: 0; overflow: hidden'></div>") );
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 <img> 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("<img src='" + imageSrc + "'>");
}
}

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));
},
}
});

This file was deleted.

0 comments on commit 47495a5

Please sign in to comment.
You can’t perform that action at this time.