diff --git a/ember/app/components/discussions/composer-reply.js b/ember/app/components/discussions/composer-reply.js
index 1978271c5..6bfa490eb 100644
--- a/ember/app/components/discussions/composer-reply.js
+++ b/ember/app/components/discussions/composer-reply.js
@@ -7,37 +7,43 @@ var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend(Ember.Evented, {
layoutName: 'components/discussions/composer-body',
- placeholder: 'Write your reply...',
submitLabel: 'Post Reply',
+ placeholder: '',
value: '',
+ submit: null,
+ loading: false,
didInsertElement: function() {
- var headerItems = TaggedArray.create();
- this.trigger('populateHeader', headerItems);
- this.set('headerItems', headerItems);
+ var controls = TaggedArray.create();
+ this.trigger('populateControls', controls);
+ this.set('controls', controls);
},
- populateHeader: function(header) {
+ populateControls: function(controls) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('Replying to {{component.discussion.title}}'),
component: this
});
- header.pushObjectWithTag(title, 'title');
+ controls.pushObjectWithTag(title, 'title');
},
actions: {
submit: function(value) {
- this.get('submit').call(this, value);
+ this.get('submit')(value);
},
+
willExit: function(abort) {
+ // If the user has typed something, prompt them before exiting
+ // this composer state.
if (this.get('value') && ! confirm('You have not posted your reply. Do you wish to discard it?')) {
abort();
}
},
+
reset: function() {
this.set('loading', false);
this.set('value', '');
- },
+ }
}
});
diff --git a/ember/app/components/ui/controls/text-editor.js b/ember/app/components/ui/controls/text-editor.js
index 8457a2e84..b787cc6e1 100644
--- a/ember/app/components/ui/controls/text-editor.js
+++ b/ember/app/components/ui/controls/text-editor.js
@@ -4,6 +4,8 @@ import TaggedArray from '../../../utils/tagged-array';
import ActionButton from './action-button';
export default Ember.Component.extend({
+ disabled: false,
+
classNames: ['text-editor'],
didInsertElement: function() {
diff --git a/ember/app/controllers/composer.js b/ember/app/controllers/composer.js
index f24ace0c1..b205f2289 100644
--- a/ember/app/controllers/composer.js
+++ b/ember/app/controllers/composer.js
@@ -1,15 +1,27 @@
import Ember from 'ember';
+export var PositionEnum = {
+ HIDDEN: 'hidden',
+ NORMAL: 'normal',
+ MINIMIZED: 'minimized',
+ FULLSCREEN: 'fullscreen'
+};
+
export default Ember.Controller.extend(Ember.Evented, {
-
- needs: ['index', 'application'],
-
content: null,
+ position: PositionEnum.HIDDEN,
- showing: false,
- minimized: false,
- fullScreen: false,
+ visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
+ normal: Ember.computed.equal('position', PositionEnum.NORMAL),
+ minimized: Ember.computed.equal('position', PositionEnum.MINIMIZED),
+ fullscreen: Ember.computed.equal('position', PositionEnum.FULLSCREEN),
+ // Switch out the composer's content for a new component. The old
+ // component will be given the opportunity to abort the switch. Note:
+ // there appears to be a bug in Ember where the content binding won't
+ // update in the view if we switch the value out immediately. As a
+ // workaround, we set it to null, and then set it to its new value in the
+ // next run loop iteration.
switchContent: function(newContent) {
var composer = this;
this.confirmExit().then(function() {
@@ -20,6 +32,9 @@ export default Ember.Controller.extend(Ember.Evented, {
});
},
+ // Ask the content component if it's OK to close it, and give it the
+ // opportunity to abort. The content component must respond to the
+ // `willExit(abort)` action, and call `abort()` if we should not proceed.
confirmExit: function() {
var composer = this;
var promise = new Ember.RSVP.Promise(function(resolve, reject) {
@@ -33,39 +48,43 @@ export default Ember.Controller.extend(Ember.Evented, {
},
actions: {
+ show: function() {
+ var composer = this;
+
+ // We do this in the next run loop because we need to wait for new
+ // content to be switched in. See `switchContent` above.
+ Ember.run.next(function() {
+ composer.set('position', PositionEnum.NORMAL);
+ composer.trigger('focus');
+ });
+ },
+
+ hide: function() {
+ this.set('position', PositionEnum.HIDDEN);
+ var content = this.get('content');
+ if (content) {
+ content.send('reset');
+ }
+ },
+
close: function() {
var composer = this;
this.confirmExit().then(function() {
composer.send('hide');
});
},
- hide: function() {
- this.set('showing', false);
- this.set('fullScreen', false);
- var content = this.get('content');
- if (content) {
- content.send('reset');
- }
- },
+
minimize: function() {
- this.set('minimized', true);
- this.set('fullScreen', false);
+ this.set('position', PositionEnum.MINIMIZED);
},
- show: function() {
- var composer = this;
- Ember.run.next(function() {
- composer.set('showing', true);
- composer.set('minimized', false);
- composer.trigger('focus');
- });
- },
- fullScreen: function() {
- this.set('fullScreen', true);
- this.set('minimized', false);
+
+ fullscreen: function() {
+ this.set('position', PositionEnum.FULLSCREEN);
this.trigger('focus');
},
- exitFullScreen: function() {
- this.set('fullScreen', false);
+
+ exitFullscreen: function() {
+ this.set('position', PositionEnum.NORMAL);
this.trigger('focus');
}
}
diff --git a/ember/app/styles/flarum/composer.less b/ember/app/styles/flarum/composer.less
index fc88b6fd8..a85cf18b6 100644
--- a/ember/app/styles/flarum/composer.less
+++ b/ember/app/styles/flarum/composer.less
@@ -80,7 +80,7 @@
display: inline-block;
}
}
-.btn-minimize .fa {
+.fa-minus.minimize {
vertical-align: -5px;
}
.composer-avatar {
diff --git a/ember/app/templates/components/discussions/composer-body.hbs b/ember/app/templates/components/discussions/composer-body.hbs
index 0c4a8f1dc..977ecab4d 100644
--- a/ember/app/templates/components/discussions/composer-body.hbs
+++ b/ember/app/templates/components/discussions/composer-body.hbs
@@ -1,10 +1,10 @@
{{user-avatar user class="composer-avatar"}}
- {{ui/controls/item-list items=headerItems class="composer-header list-inline"}}
+ {{ui/controls/item-list items=controls class="composer-header list-inline"}}
- {{ui/controls/text-editor submit="submit" value=value placeholder=placeholder submitLabel=submitLabel}}
+ {{ui/controls/text-editor submit="submit" value=value placeholder=placeholder submitLabel=submitLabel disabled=loading}}
diff --git a/ember/app/templates/components/ui/controls/text-editor.hbs b/ember/app/templates/components/ui/controls/text-editor.hbs
index a61da22ac..fbd51c865 100644
--- a/ember/app/templates/components/ui/controls/text-editor.hbs
+++ b/ember/app/templates/components/ui/controls/text-editor.hbs
@@ -1,3 +1,3 @@
-{{textarea value=value placeholder=placeholder class="form-control"}}
+{{textarea value=value placeholder=placeholder class="form-control flexible-height" disabled=disabled}}
-{{ui/controls/item-list items=controlItems class="text-editor-controls"}}
+{{ui/controls/item-list items=controlItems class="text-editor-controls fade" classNameBindings="value:in"}}
diff --git a/ember/app/views/composer.js b/ember/app/views/composer.js
index 1f42fa0a9..238fd65c4 100644
--- a/ember/app/views/composer.js
+++ b/ember/app/views/composer.js
@@ -1,112 +1,66 @@
import Ember from 'ember';
+import { PositionEnum } from '../controllers/composer';
import ActionButton from '../components/ui/controls/action-button';
import TaggedArray from '../utils/tagged-array';
+var $ = Ember.$;
+
export default Ember.View.extend(Ember.Evented, {
-
classNames: ['composer'],
-
classNameBindings: [
- 'controller.showing:showing',
- 'controller.minimized:minimized',
- 'controller.fullScreen:fullscreen',
+ 'minimized',
+ 'fullscreen',
'active'
],
+ position: Ember.computed.alias('controller.position'),
+ visible: Ember.computed.alias('controller.visible'),
+ normal: Ember.computed.alias('controller.normal'),
+ minimized: Ember.computed.alias('controller.minimized'),
+ fullscreen: Ember.computed.alias('controller.fullscreen'),
+
+ // Calculate the composer's current height, based on the intended height
+ // (which is set when the resizing handle is dragged), and the composer's
+ // current state.
computedHeight: function() {
- if (this.get('controller.minimized') || this.get('controller.fullScreen')) {
+ if (this.get('minimized')) {
return '';
+ } else if (this.get('fullscreen')) {
+ return $(window).height();
} else {
return Math.max(200, Math.min(this.get('height'), $(window).height() - $('#header').outerHeight()));
}
- }.property('height', 'controller.minimized', 'controller.fullScreen'),
-
- updateHeight: function() {
- if (! this.$()) {
- return;
- }
-
- var view = this;
- Ember.run.scheduleOnce('afterRender', function() {
- view.$().height(view.get('computedHeight'));
- view.updateTextareaHeight();
- // view.updateBottomPadding();
- });
- }.observes('computedHeight'),
-
- showingChanged: function() {
- if (! this.$()) {
- return;
- }
-
- var view = this;
- if (view.get('controller.showing')) {
- view.$().show();
- }
- Ember.run.scheduleOnce('afterRender', function() {
- view.$().css('bottom', view.get('controller.showing') ? -view.$().outerHeight() : 0);
- view.$().animate({bottom: view.get('controller.showing') ? 0 : -view.$().outerHeight()}, 'fast', function() {
- if (view.get('controller.showing')) {
- view.focus();
- } else {
- view.$().hide();
- }
- });
- view.updateBottomPadding(true);
- });
- }.observes('controller.showing'),
-
- minimizedChanged: function() {
- if (! this.$() || ! this.get('controller.showing')) {
- return;
- }
-
- var view = this;
- var oldHeight = this.$().height();
- Ember.run.scheduleOnce('afterRender', function() {
- var newHeight = view.$().height();
- view.updateBottomPadding(true);
- view.$().css('height', oldHeight).animate({height: newHeight}, 'fast', function() {
- if (! view.get('controller.minimized')) {
- view.focus();
- }
- });
- });
- }.observes('controller.minimized'),
-
- fullScreenChanged: function() {
- if (! this.$()) {
- return;
- }
-
- var view = this;
- Ember.run.scheduleOnce('afterRender', function() {
- view.updateTextareaHeight();
- });
- }.observes('controller.fullScreen'),
+ }.property('height', 'minimized', 'fullscreen'),
didInsertElement: function() {
+ var view = this;
+ var controller = this.get('controller');
+
+ // Hide the composer to begin with.
+ this.set('height', this.$().height());
this.$().hide();
- var controller = this.get('controller');
+ // If the composer is minimized, allow the user to click anywhere on
+ // it to show it.
this.$('.composer-content').click(function() {
- if (controller.get('minimized')) {
+ if (view.get('minimized')) {
controller.send('show');
}
});
- var view = this;
+ // Modulate the view's active property/class according to the focus
+ // state of any inputs.
this.$().on('focus', ':input', function() {
view.set('active', true);
}).on('blur', ':input', function() {
view.set('active', false);
});
- this.set('height', this.$().height());
-
+ // Focus on the first input when the controller wants to focus.
controller.on('focus', this, this.focus);
+ // Set up the handle so that the composer can be resized.
$(window).on('resize', {view: this}, this.windowWasResized).resize();
var dragData = {view: this};
@@ -123,107 +77,36 @@ export default Ember.View.extend(Ember.Evented, {
$(document)
.on('mousemove', dragData, this.mouseWasMoved)
.on('mouseup', dragData, this.mouseWasReleased);
+
+ // When the escape key is pressed on any inputs, close the composer.
+ this.$().on('keydown', ':input', 'esc', function() {
+ controller.send('close');
+ });
},
willDestroyElement: function() {
- $(window)
- .off('resize', this.windowWasResized);
+ $(window).off('resize', this.windowWasResized);
$(document)
.off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased);
},
- windowWasResized: function(event) {
- var view = event.data.view;
- view.notifyPropertyChange('computedHeight');
- },
+ // Update the amount of padding-bottom on the body so that the page's
+ // content will still be visible above the composer when the page is
+ // scrolled right to the bottom.
+ updateBodyPadding: function(animate) {
+ // Before we change anything, work out if we're currently scrolled
+ // right to the bottom of the page. If we are, we'll want to anchor
+ // the body's scroll position to the bottom after we update the
+ // padding.
+ var anchorScroll = $(window).scrollTop() + $(window).height() >= $(document).height();
- mouseWasMoved: function(event) {
- if (! event.data.handle) {
- return;
- }
- var view = event.data.view;
+ var func = animate ? 'animate' : 'css';
+ var paddingBottom = this.get('visible') ? this.get('computedHeight') - Ember.$('#footer').outerHeight(true) : 0;
+ $('#main')[func]({paddingBottom: paddingBottom}, 'fast');
- var deltaPixels = event.data.mouseStart - event.clientY;
- view.set('height', event.data.heightStart + deltaPixels);
- view.updateTextareaHeight();
- view.updateBottomPadding();
- },
-
- mouseWasReleased: function(event) {
- if (! event.data.handle) {
- return;
- }
- event.data.handle = null;
- $('body').css('cursor', '');
- },
-
- refreshControls: function() {
- var controlItems = TaggedArray.create();
- this.trigger('populateControls', controlItems);
- this.set('controlItems', controlItems);
- }.observes('controller.minimized', 'controller.fullScreen'),
-
- populateControls: function(controls) {
- var controller = this.get('controller');
-
- if (controller.get('fullScreen')) {
- var exit = ActionButton.create({
- icon: 'compress',
- title: 'Exit Full Screen',
- className: 'btn btn-icon btn-link',
- action: function() {
- controller.send('exitFullScreen');
- }
- });
- controls.pushObjectWithTag(exit, 'exit');
- } else {
- if (! controller.get('minimized')) {
- var minimize = ActionButton.create({
- icon: 'minus',
- title: 'Minimize',
- className: 'btn btn-icon btn-link btn-minimize',
- action: function() {
- controller.send('minimize');
- }
- });
- controls.pushObjectWithTag(minimize, 'minimize');
-
- var fullScreen = ActionButton.create({
- icon: 'expand',
- title: 'Full Screen',
- className: 'btn btn-icon btn-link',
- action: function() {
- controller.send('fullScreen');
- }
- });
- controls.pushObjectWithTag(fullScreen, 'fullScreen');
- }
-
- var close = ActionButton.create({
- icon: 'times',
- title: 'Close',
- className: 'btn btn-icon btn-link',
- action: function() {
- controller.send('close');
- }
- });
- controls.pushObjectWithTag(close, 'close');
- }
- },
-
- focus: function() {
- this.$().find(':input:enabled:visible:first').focus();
- },
-
- updateBottomPadding: function(animate) {
- var top = $(document).height() - $(window).height();
- var isBottom = $(window).scrollTop() >= top;
-
- $('#main')[animate ? 'animate' : 'css']({paddingBottom: this.get('controller.showing') ? this.$().outerHeight() - Ember.$('#footer').outerHeight(true) : 0}, 'fast');
-
- if (isBottom) {
+ if (anchorScroll) {
if (animate) {
$('html, body').animate({scrollTop: $(document).height()}, 'fast');
} else {
@@ -232,13 +115,151 @@ export default Ember.View.extend(Ember.Evented, {
}
},
- updateTextareaHeight: function() {
+ // Update the height of the stuff inside of the composer. There should be
+ // an element with the class .flexible-height — this element is intended
+ // to fill up the height of the composer, minus the space taken up by the
+ // composer's header/footer/etc.
+ updateContentHeight: function() {
var content = this.$('.composer-content');
- this.$('textarea').height((this.get('computedHeight') || this.$().height())
+ this.$('.flexible-height').height(this.get('computedHeight')
- parseInt(content.css('padding-top'))
- parseInt(content.css('padding-bottom'))
- this.$('.composer-header').outerHeight(true)
- this.$('.text-editor-controls').outerHeight(true));
- }
+ },
+ // ------------------------------------------------------------------------
+ // OBSERVERS
+ // ------------------------------------------------------------------------
+
+ // Whenever the composer is minimized or goes to/from fullscreen, we need
+ // to re-populate the control buttons, because their configuration depends
+ // on the composer's current state.
+ refreshControls: function() {
+ var controlItems = TaggedArray.create();
+ this.trigger('populateControls', controlItems);
+ this.set('controlItems', controlItems);
+ }.observes('minimized', 'fullscreen'),
+
+ // Whenever the composer's computed height changes, update the DOM to
+ // reflect it.
+ updateHeight: function() {
+ if (!this.$()) { return; }
+
+ var view = this;
+ Ember.run.scheduleOnce('afterRender', function() {
+ view.$().height(view.get('computedHeight'));
+ view.updateContentHeight();
+ });
+ }.observes('computedHeight'),
+
+ positionWillChange: function() {
+ this.set('oldPosition', this.get('position'));
+ }.observesBefore('position'),
+
+ // Whenever the composer's display state changes, update the DOM to slide
+ // it in or out.
+ positionDidChange: function() {
+ var $composer = this.$();
+ if (!$composer) { return; }
+ var view = this;
+
+ // At this stage, the position property has just changed, and the
+ // class name hasn't been altered in the DOM. So, we can grab the
+ // composer's current height which we might want to animate from.
+ // After the DOM has updated, we animate to its new height.
+ var oldHeight = $composer.height();
+
+ Ember.run.scheduleOnce('afterRender', function() {
+ var newHeight = $composer.height();
+
+ switch (view.get('position')) {
+ case PositionEnum.HIDDEN:
+ $composer.animate({bottom: -oldHeight}, 'fast', function() {
+ $composer.hide();
+ });
+ break;
+
+ case PositionEnum.NORMAL:
+ if (view.get('oldPosition') !== PositionEnum.FULLSCREEN) {
+ $composer.show();
+ $composer.css({height: oldHeight}).animate({bottom: 0, height: newHeight}, 'fast', function() {
+ view.focus();
+ });
+ }
+ break;
+
+ case PositionEnum.MINIMIZED:
+ $composer.css({height: oldHeight}).animate({height: newHeight}, 'fast', function() {
+ view.focus();
+ });
+ break;
+ }
+
+ if (view.get('position') !== PositionEnum.FULLSCREEN) {
+ view.updateBodyPadding(true);
+ }
+ view.updateContentHeight();
+ });
+ }.observes('position'),
+
+ // ------------------------------------------------------------------------
+ // LISTENERS
+ // ------------------------------------------------------------------------
+
+ windowWasResized: function(event) {
+ // Force a recalculation of the computed height, because its value
+ // depends on the window's height.
+ var view = event.data.view;
+ view.notifyPropertyChange('computedHeight');
+ },
+
+ mouseWasMoved: function(event) {
+ if (! event.data.handle) { return; }
+ var view = event.data.view;
+
+ // Work out how much the mouse has been moved, and set the height
+ // relative to the old one based on that. Then update the content's
+ // height so that it fills the height of the composer, and update the
+ // body's padding.
+ var deltaPixels = event.data.mouseStart - event.clientY;
+ view.set('height', event.data.heightStart + deltaPixels);
+ view.updateContentHeight();
+ view.updateBodyPadding();
+ },
+
+ mouseWasReleased: function(event) {
+ if (! event.data.handle) { return; }
+ event.data.handle = null;
+ $('body').css('cursor', '');
+ },
+
+ focus: function() {
+ this.$().find(':input:enabled:visible:first').focus();
+ },
+
+ populateControls: function(controls) {
+ var controller = this.get('controller');
+ var addControl = function(action, icon, title) {
+ var control = ActionButton.create({
+ icon: icon,
+ title: title,
+ className: 'btn btn-icon btn-link',
+ action: function() {
+ controller.send(action);
+ }
+ });
+ controls.pushObjectWithTag(control, action);
+ };
+
+ if (this.get('fullscreen')) {
+ addControl('exitFullscreen', 'compress', 'Exit Full Screen');
+ } else {
+ if (! this.get('minimized')) {
+ addControl('minimize', 'minus minimize', 'Minimize');
+ addControl('fullscreen', 'expand', 'Full Screen');
+ }
+ addControl('close', 'times', 'Close');
+ }
+ }
});