Clean up/refactor composer, add escape hotkey

This commit is contained in:
Toby Zerner
2015-02-03 16:59:53 +10:30
parent 74d8b0e377
commit 6544052da6
7 changed files with 259 additions and 211 deletions

View File

@ -7,37 +7,43 @@ var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend(Ember.Evented, { export default Ember.Component.extend(Ember.Evented, {
layoutName: 'components/discussions/composer-body', layoutName: 'components/discussions/composer-body',
placeholder: 'Write your reply...',
submitLabel: 'Post Reply', submitLabel: 'Post Reply',
placeholder: '',
value: '', value: '',
submit: null,
loading: false,
didInsertElement: function() { didInsertElement: function() {
var headerItems = TaggedArray.create(); var controls = TaggedArray.create();
this.trigger('populateHeader', headerItems); this.trigger('populateControls', controls);
this.set('headerItems', headerItems); this.set('controls', controls);
}, },
populateHeader: function(header) { populateControls: function(controls) {
var title = Ember.Component.create({ var title = Ember.Component.create({
tagName: 'h3', tagName: 'h3',
layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'), layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'),
component: this component: this
}); });
header.pushObjectWithTag(title, 'title'); controls.pushObjectWithTag(title, 'title');
}, },
actions: { actions: {
submit: function(value) { submit: function(value) {
this.get('submit').call(this, value); this.get('submit')(value);
}, },
willExit: function(abort) { 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?')) { if (this.get('value') && ! confirm('You have not posted your reply. Do you wish to discard it?')) {
abort(); abort();
} }
}, },
reset: function() { reset: function() {
this.set('loading', false); this.set('loading', false);
this.set('value', ''); this.set('value', '');
}, }
} }
}); });

View File

@ -4,6 +4,8 @@ import TaggedArray from '../../../utils/tagged-array';
import ActionButton from './action-button'; import ActionButton from './action-button';
export default Ember.Component.extend({ export default Ember.Component.extend({
disabled: false,
classNames: ['text-editor'], classNames: ['text-editor'],
didInsertElement: function() { didInsertElement: function() {

View File

@ -1,15 +1,27 @@
import Ember from 'ember'; import Ember from 'ember';
export var PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullscreen'
};
export default Ember.Controller.extend(Ember.Evented, { export default Ember.Controller.extend(Ember.Evented, {
needs: ['index', 'application'],
content: null, content: null,
position: PositionEnum.HIDDEN,
showing: false, visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
minimized: false, normal: Ember.computed.equal('position', PositionEnum.NORMAL),
fullScreen: false, 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) { switchContent: function(newContent) {
var composer = this; var composer = this;
this.confirmExit().then(function() { 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() { confirmExit: function() {
var composer = this; var composer = this;
var promise = new Ember.RSVP.Promise(function(resolve, reject) { var promise = new Ember.RSVP.Promise(function(resolve, reject) {
@ -33,39 +48,43 @@ export default Ember.Controller.extend(Ember.Evented, {
}, },
actions: { 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() { close: function() {
var composer = this; var composer = this;
this.confirmExit().then(function() { this.confirmExit().then(function() {
composer.send('hide'); 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() { minimize: function() {
this.set('minimized', true); this.set('position', PositionEnum.MINIMIZED);
this.set('fullScreen', false);
}, },
show: function() {
var composer = this; fullscreen: function() {
Ember.run.next(function() { this.set('position', PositionEnum.FULLSCREEN);
composer.set('showing', true);
composer.set('minimized', false);
composer.trigger('focus');
});
},
fullScreen: function() {
this.set('fullScreen', true);
this.set('minimized', false);
this.trigger('focus'); this.trigger('focus');
}, },
exitFullScreen: function() {
this.set('fullScreen', false); exitFullscreen: function() {
this.set('position', PositionEnum.NORMAL);
this.trigger('focus'); this.trigger('focus');
} }
} }

View File

@ -80,7 +80,7 @@
display: inline-block; display: inline-block;
} }
} }
.btn-minimize .fa { .fa-minus.minimize {
vertical-align: -5px; vertical-align: -5px;
} }
.composer-avatar { .composer-avatar {

View File

@ -1,10 +1,10 @@
{{user-avatar user class="composer-avatar"}} {{user-avatar user class="composer-avatar"}}
<div class="composer-body"> <div class="composer-body">
{{ui/controls/item-list items=headerItems class="composer-header list-inline"}} {{ui/controls/item-list items=controls class="composer-header list-inline"}}
<div class="composer-editor"> <div class="composer-editor">
{{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}}
</div> </div>
</div> </div>

View File

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

View File

@ -1,112 +1,66 @@
import Ember from 'ember'; import Ember from 'ember';
import { PositionEnum } from '../controllers/composer';
import ActionButton from '../components/ui/controls/action-button'; import ActionButton from '../components/ui/controls/action-button';
import TaggedArray from '../utils/tagged-array'; import TaggedArray from '../utils/tagged-array';
var $ = Ember.$;
export default Ember.View.extend(Ember.Evented, { export default Ember.View.extend(Ember.Evented, {
classNames: ['composer'], classNames: ['composer'],
classNameBindings: [ classNameBindings: [
'controller.showing:showing', 'minimized',
'controller.minimized:minimized', 'fullscreen',
'controller.fullScreen:fullscreen',
'active' '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() { computedHeight: function() {
if (this.get('controller.minimized') || this.get('controller.fullScreen')) { if (this.get('minimized')) {
return ''; return '';
} else if (this.get('fullscreen')) {
return $(window).height();
} else { } else {
return Math.max(200, Math.min(this.get('height'), $(window).height() - $('#header').outerHeight())); return Math.max(200, Math.min(this.get('height'), $(window).height() - $('#header').outerHeight()));
} }
}.property('height', 'controller.minimized', 'controller.fullScreen'), }.property('height', 'minimized', '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'),
didInsertElement: function() { didInsertElement: function() {
var view = this;
var controller = this.get('controller');
// Hide the composer to begin with.
this.set('height', this.$().height());
this.$().hide(); 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() { this.$('.composer-content').click(function() {
if (controller.get('minimized')) { if (view.get('minimized')) {
controller.send('show'); 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() { this.$().on('focus', ':input', function() {
view.set('active', true); view.set('active', true);
}).on('blur', ':input', function() { }).on('blur', ':input', function() {
view.set('active', false); 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); 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(); $(window).on('resize', {view: this}, this.windowWasResized).resize();
var dragData = {view: this}; var dragData = {view: this};
@ -123,107 +77,36 @@ export default Ember.View.extend(Ember.Evented, {
$(document) $(document)
.on('mousemove', dragData, this.mouseWasMoved) .on('mousemove', dragData, this.mouseWasMoved)
.on('mouseup', dragData, this.mouseWasReleased); .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() { willDestroyElement: function() {
$(window) $(window).off('resize', this.windowWasResized);
.off('resize', this.windowWasResized);
$(document) $(document)
.off('mousemove', this.mouseWasMoved) .off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased); .off('mouseup', this.mouseWasReleased);
}, },
windowWasResized: function(event) { // Update the amount of padding-bottom on the body so that the page's
var view = event.data.view; // content will still be visible above the composer when the page is
view.notifyPropertyChange('computedHeight'); // 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) { var func = animate ? 'animate' : 'css';
if (! event.data.handle) { var paddingBottom = this.get('visible') ? this.get('computedHeight') - Ember.$('#footer').outerHeight(true) : 0;
return; $('#main')[func]({paddingBottom: paddingBottom}, 'fast');
}
var view = event.data.view;
var deltaPixels = event.data.mouseStart - event.clientY; if (anchorScroll) {
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 (animate) { if (animate) {
$('html, body').animate({scrollTop: $(document).height()}, 'fast'); $('html, body').animate({scrollTop: $(document).height()}, 'fast');
} else { } 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'); 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-top'))
- parseInt(content.css('padding-bottom')) - parseInt(content.css('padding-bottom'))
- this.$('.composer-header').outerHeight(true) - this.$('.composer-header').outerHeight(true)
- this.$('.text-editor-controls').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');
}
}
}); });