FEATURE: Replace composer editor with ember version

This commit is contained in:
Robin Ward 2015-11-03 16:25:37 -05:00
parent fc27b7442f
commit 47495a5713
51 changed files with 771 additions and 3420 deletions

View File

@ -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/*"

View File

@ -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

View File

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

View File

@ -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')
});

View File

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

View File

@ -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;
}
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() {

View File

@ -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')

View File

@ -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();

View File

@ -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';

View File

@ -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 = [];

View File

@ -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() {

View File

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

View File

@ -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)) {

View File

@ -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");
}
}
});

View File

@ -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();
}

View File

@ -18,6 +18,6 @@ export default {
const style = 'max-width:' + width + 'px;' +
'max-height:' + height + 'px;';
$('<style id="image-sizing-hack">#reply-control .wmd-preview img:not(.thumbnail), .cooked img:not(.thumbnail) {' + style + '}</style>').appendTo('head');
$('<style id="image-sizing-hack">#reply-control .d-editor-preview img:not(.thumbnail), .cooked img:not(.thumbnail) {' + style + '}</style>').appendTo('head');
}
};

File diff suppressed because it is too large Load Diff

View File

@ -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") + " <strong> Ctrl+B",
boldexample: I18n.t("composer.bold_text"),
italic: I18n.t("composer.italic_title") + " <em> Ctrl+I",
italicexample: I18n.t("composer.italic_text"),
link: I18n.t("composer.link_title") + " <a> Ctrl+L",
linkdescription: I18n.t("composer.link_description"),
linkdialog: "<p><b>" + I18n.t("composer.link_dialog_title") + "</b></p><p>http://example.com/ \"" +
I18n.t("composer.link_optional_text") + "\"</p>",
quote: I18n.t("composer.quote_title") + " <blockquote> Ctrl+Q",
quoteexample: I18n.t("composer.quote_text"),
code: I18n.t("composer.code_title") + " <pre><code> 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") + " <ol> Ctrl+O",
ulist: I18n.t("composer.ulist_title") + " <ul> Ctrl+U",
litem: I18n.t("composer.list_item"),
heading: I18n.t("composer.heading_title") + " <h1>/<h2> Ctrl+H",
headingexample: I18n.t("composer.heading_text"),
hr: I18n.t("composer.hr_title") + " <hr> 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

View File

@ -1,4 +1,4 @@
export default (name, opts) => {
export default function(name, opts) {
opts = opts || {};
if (opts.__type) {

View File

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

View File

@ -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 class="marker"><\/span>/g, '');
return $('#reply-control .d-editor-preview').html().replace(/<span class="marker"><\/span>/g, '');
},
saveDraft() {

View File

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

View File

@ -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() {

View File

@ -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}}
<div class="composer-bottom-right">
{{#if site.mobileView}}
<input type="file" id="mobile-uploader" />
<a class="mobile-file-upload {{if isUploading 'hidden'}}">{{i18n 'upload'}}</a>
{{else}}
<a href {{action "togglePreview"}} class='toggle-preview'>{{{toggleText}}}</a>
{{/if}}
{{#if isUploading}}
<div id="file-uploading">
{{loading-spinner size="small"}} {{i18n 'upload_selector.uploading'}}
{{uploadProgress}}%
<a href id="cancel-file-upload" {{action "cancelUpload"}}>{{fa-icon "times"}}</a>
</div>
{{/if}}
<div id='draft-status' class="{{if isUploading 'hidden'}}">
{{draftStatus}}
</div>
</div>

View File

@ -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}}

View File

@ -17,10 +17,17 @@
{{/unless}}
{{/each}}
</div>
<div class='d-editor-preview-header'></div>
{{textarea value=value class="d-editor-input"}}
<div class="d-editor-textarea-wrapper">
{{conditional-loading-spinner condition=loading}}
{{textarea tabindex=tabindex value=value class="d-editor-input" placeholder=placeholderTranslated}}
{{popup-input-tip validation=validation}}
</div>
<div class="d-editor-preview {{unless preview 'hidden'}}">
<div class="d-editor-preview-wrapper">
<div class="d-editor-preview">
{{{preview}}}
</div>
</div>
</div>

View File

@ -56,15 +56,12 @@
{{/if}}
{{/if}}
<div class="title-input">
{{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}}
</div>
{{composer-title composer=model lastValidatedAt=lastValidatedAt}}
{{#if model.showCategoryChooser}}
<div class="category-input">
{{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}}
</div>
{{#if model.archetype.hasOptions}}
<button class='btn' {{action "showOptions"}}>{{i18n 'topic.options'}}</button>
@ -77,35 +74,15 @@
{{plugin-outlet "composer-fields"}}
</div>
<div class='wmd-controls'>
<div class='textarea-wrapper'>
<div class='wmd-button-bar'></div>
<div class='wmd-preview-scroller'></div>
{{conditional-loading-spinner condition=model.loading}}
{{composer-text-area tabindex="4" value=model.reply}}
{{popup-input-tip validation=view.replyValidation shownAt=view.showReplyTip}}
</div>
<!-- keep the classes here in sync with post.hbs -->
<div class='preview-wrapper regular'>
<div class="wmd-preview cooked {{if model.hidePreview 'hidden'}}"></div>
</div>
<div class="composer-bottom-right">
{{#if site.mobileView}}
<input type="file" id="mobile-uploader" />
<a class="mobile-file-upload {{if view.isUploading 'hidden'}}">{{i18n 'upload'}}</a>
{{else}}
<a href {{action "togglePreview"}} class='toggle-preview'>{{{model.toggleText}}}</a>
{{/if}}
{{#if view.isUploading}}
<div id="file-uploading">
{{loading-spinner size="small"}} {{i18n 'upload_selector.uploading'}} {{view.uploadProgress}}% <a id="cancel-file-upload">{{fa-icon "times"}}</a>
</div>
{{/if}}
<div id='draft-status' class="{{if view.isUploading 'hidden'}}">
{{model.draftStatus}}
</div>
</div>
</div>
{{composer-editor topic=topic
composer=model
lastValidatedAt=lastValidatedAt
canWhisper=canWhisper
draftStatus=model.draftStatus
isUploading=isUploading
importQuote="importQuote"
showOptions="showOptions"
showUploadSelector="showUploadSelector"}}
{{#if currentUser}}
<div class='submit-panel'>

View File

@ -5,7 +5,7 @@
{{#if local}}
<div class="inputs">
<input type="file" id="filename-input" multiple><br>
<span class="description">{{unbound view.tip}}</span>
<span class="description">{{tip}}</span>
</div>
{{/if}}
</div>
@ -14,31 +14,34 @@
<label class="radio" for="remote">{{i18n 'upload_selector.from_the_web'}}</label>
{{#if remote}}
<div class="inputs">
<input type="text" id="fileurl-input" placeholder="http://example.com/image.png"><br>
<span class="description">{{unbound view.tip}}</span>
{{input value=imageUrl placeholder="http://example.com/image.png"}}
<span class="description">{{tip}}</span>
</div>
{{/if}}
</div>
{{#if showMore}}
<div class="radios">
<div class="inputs">
<input type="text" id="link-input" placeholder="http://example.com"><br>
{{input value=imageLink laceholder="http://example.com"}}
<span class="description">{{i18n 'upload_selector.image_link'}}</span>
</div>
</div>
{{/if}}
<div class="radios">
<div class="inputs">
<p class="hint">{{unbound view.hint}}</p>
<p class="hint">
{{#if capabilities.canPasteImages}}
{{i18n 'upload_selector.hint'}}
{{else}}
{{i18n 'upload_selector.hint_for_supported_browsers'}}
{{/if}}
</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" {{action "upload" target="view"}}>
<span class='add-upload'><i {{bind-attr class=":fa view.uploadIcon"}}></i></span>
{{i18n 'upload'}}
</button>
<a {{action "closeModal"}}>{{i18n 'cancel'}}</a>
{{d-button action="upload" class='btn-primary' icon=uploadIcon label='upload'}}
<a href {{action "closeModal"}}>{{i18n 'cancel'}}</a>
{{#if remote}}<a {{action "toggleShowMore"}} class="pull-right">{{i18n 'show_more'}}</a>{{/if}}
</div>

View File

@ -15,9 +15,9 @@
{{#if editingTopic}}
{{#if model.isPrivateMessage}}
<span class="private-message-glyph">{{fa-icon "envelope"}}</span>
{{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}}
<br>
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}}
{{/if}}

View File

@ -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.$("<div id='contenteditable' contenteditable='true' style='height: 0; width: 0; overflow: hidden'></div>") );
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 <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(function(blob) {
$uploadTarget.fileupload('add', {files: blob});
});
};
image.src = imageSrc;
} else {
restoreSelection("<img src='" + imageSrc + "'>");
}
}
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 += "<br/>" + 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;

View File

@ -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);

View File

@ -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;

View File

@ -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;
}

View File

@ -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');
}
}
}
});

View File

@ -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

View File

@ -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");
}
}
};

View File

@ -1188,7 +1188,7 @@ table.api-keys {
height: 200px;
}
.wmd-input {
.d-editor-input {
width: 98%;
height: 200px;
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -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 {
.wmd-controls.hide-preview {
.d-editor-input {
width: 100%;
}
.preview-wrapper {
.d-editor-preview-wrapper {
display: none;
}
.textarea-wrapper {
.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%);

View File

@ -18,7 +18,7 @@
width: $topic-body-width;
float: left;
.wmd-input {
.d-editor-input {
width: 98%;
height: 15em;
}

View File

@ -39,10 +39,6 @@
}
}
.bio-composer #wmd-quote-post {
display: none;
}
.static {
color: $primary;
display: inline-block;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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() {

View File

@ -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(), "<p>this is the <em>content</em> of a post</p>", "it previews content");
ok(exists('.textarea-wrapper .popup-tip.good'), 'the body is now good');
equal(find('.d-editor-preview').html().trim(), "<p>this is the <em>content</em> of a post</p>", "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');
});
});

View File

@ -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(), '<p>hello <strong>world</strong></p>');
});
}

View File

@ -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() {

View File

@ -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