Skip to content

Commit 47495a5

Browse files
committed
FEATURE: Replace composer editor with ember version
1 parent fc27b74 commit 47495a5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+771
-3420
lines changed

Diff for: .codeclimate.yml

-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ languages:
66

77
exclude_paths:
88
- "app/assets/javascripts/defer/*"
9-
- "app/assets/javascripts/discourse/lib/Markdown.Editor.js"
109
- "app/assets/javascripts/ember-addons/*"
1110
- "lib/autospec/*"
1211
- "lib/es6_module_transpiler/*"

Diff for: .eslintignore

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

Diff for: app/assets/javascripts/discourse/components/composer-text-area.js.es6

-15
This file was deleted.

0 commit comments

Comments
 (0)