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