diff --git a/ember/Brocfile.js b/ember/Brocfile.js index 9f03d0dd8..79dc7ace5 100644 --- a/ember/Brocfile.js +++ b/ember/Brocfile.js @@ -8,6 +8,7 @@ app.import('bower_components/bootstrap/dist/js/bootstrap.js'); app.import('bower_components/spin.js/spin.js'); app.import('bower_components/spin.js/jquery.spin.js'); app.import('bower_components/moment/moment.js'); +app.import('bower_components/jquery.hotkeys/jquery.hotkeys.js'); app.import('bower_components/font-awesome/fonts/fontawesome-webfont.eot'); app.import('bower_components/font-awesome/fonts/fontawesome-webfont.svg'); diff --git a/ember/app/components/discussions/composer-reply.js b/ember/app/components/discussions/composer-reply.js new file mode 100644 index 000000000..1978271c5 --- /dev/null +++ b/ember/app/components/discussions/composer-reply.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; + +import TaggedArray from '../../utils/tagged-array'; + +var precompileTemplate = Ember.Handlebars.compile; + +export default Ember.Component.extend(Ember.Evented, { + layoutName: 'components/discussions/composer-body', + + placeholder: 'Write your reply...', + submitLabel: 'Post Reply', + value: '', + + didInsertElement: function() { + var headerItems = TaggedArray.create(); + this.trigger('populateHeader', headerItems); + this.set('headerItems', headerItems); + }, + + populateHeader: function(header) { + var title = Ember.Component.create({ + tagName: 'h3', + layout: precompileTemplate('Replying to {{component.discussion.title}}'), + component: this + }); + header.pushObjectWithTag(title, 'title'); + }, + + actions: { + submit: function(value) { + this.get('submit').call(this, value); + }, + willExit: function(abort) { + 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 aab281c8b..8457a2e84 100644 --- a/ember/app/components/ui/controls/text-editor.js +++ b/ember/app/components/ui/controls/text-editor.js @@ -1,9 +1,37 @@ import Ember from 'ember'; +import TaggedArray from '../../../utils/tagged-array'; +import ActionButton from './action-button'; + export default Ember.Component.extend({ - actions: { - save: function() { - this.sendAction('save', this.get('value')); - } - } + classNames: ['text-editor'], + + didInsertElement: function() { + var controlItems = TaggedArray.create(); + this.trigger('populateControls', controlItems); + this.set('controlItems', controlItems); + + var component = this; + this.$('textarea').bind('keydown', 'meta+return', function() { + component.send('submit'); + }); + }, + + populateControls: function(controls) { + var component = this; + var submit = ActionButton.create({ + label: this.get('submitLabel'), + className: 'btn btn-primary', + action: function() { + component.send('submit'); + } + }); + controls.pushObjectWithTag(submit, 'submit'); + }, + + actions: { + submit: function() { + this.sendAction('submit', this.get('value')); + } + } }); \ No newline at end of file diff --git a/ember/app/controllers/composer.js b/ember/app/controllers/composer.js index 08615abe3..f24ace0c1 100644 --- a/ember/app/controllers/composer.js +++ b/ember/app/controllers/composer.js @@ -1,41 +1,72 @@ import Ember from 'ember'; -export default Ember.Controller.extend({ +export default Ember.Controller.extend(Ember.Evented, { needs: ['index', 'application'], - user: Ember.Object.create({avatarNumber: 1}), + content: null, - discussion: null, - - showing: true, + showing: false, minimized: false, + fullScreen: false, - title: 'Replying to Some Discussion Title', + switchContent: function(newContent) { + var composer = this; + this.confirmExit().then(function() { + composer.set('content', null); + Ember.run.next(function() { + composer.set('content', newContent); + }); + }); + }, + + confirmExit: function() { + var composer = this; + var promise = new Ember.RSVP.Promise(function(resolve, reject) { + var content = composer.get('content'); + if (content) { + content.send('willExit', reject); + } + resolve(); + }); + return promise; + }, actions: { 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('minimized', true); + this.set('fullScreen', false); }, show: function() { - this.set('minimized', false); + var composer = this; + Ember.run.next(function() { + composer.set('showing', true); + composer.set('minimized', false); + composer.trigger('focus'); + }); }, - save: function(value) { - var store = this.store; - var discussion = this.get('discussion'); - var controller = this; - - var post = store.createRecord('post', { - content: value, - discussion: discussion - }); - post.save().then(function(post) { - discussion.set('posts', discussion.get('posts')+','+post.get('id')); - controller.get('delegate').send('replyAdded', post); - }); + fullScreen: function() { + this.set('fullScreen', true); + this.set('minimized', false); + this.trigger('focus'); + }, + exitFullScreen: function() { + this.set('fullScreen', false); + this.trigger('focus'); } } diff --git a/ember/app/controllers/discussion.js b/ember/app/controllers/discussion.js index cd6e3cabe..ee35f0939 100644 --- a/ember/app/controllers/discussion.js +++ b/ember/app/controllers/discussion.js @@ -1,6 +1,8 @@ import Ember from 'ember'; import PostStream from '../models/post-stream'; +import ComposerReply from '../components/discussions/composer-reply'; +import ActionButton from '../components/ui/controls/action-button'; export default Ember.ObjectController.extend(Ember.Evented, { @@ -38,28 +40,52 @@ export default Ember.ObjectController.extend(Ember.Evented, { }); }, + saveReply: function(discussion, content) { + var controller = this; + var composer = this.get('controllers.composer'); + var stream = this.get('stream'); + + composer.set('content.loading', true); + + var post = this.store.createRecord('post', { + content: content, + discussion: discussion + }); + + var promise = post.save().then(function(post) { + if (discussion == controller.get('model')) { + discussion.set('posts', discussion.get('posts')+','+post.get('id')); + stream.set('ids', controller.get('model.postIds')); + stream.addPostToEnd(post); + } + composer.send('hide'); + }, function(reason) { + var error = reason.errors[0].detail; + alert(error); + }); + + promise.finally(function() { + composer.set('content.loading', false); + }); + + return promise; + }, + actions: { reply: function() { + var controller = this; + var discussion = this.get('model'); var composer = this.get('controllers.composer'); - // composer.beginPropertyChanges(); - composer.set('minimized', false); - composer.set('showing', true); - composer.set('title', 'Replying to '+this.get('model.title')+''); - composer.set('delegate', this); - composer.set('discussion', this.get('model')); - // composer.endPropertyChanges(); - }, - - replyAdded: function(post) { - var stream = this.get('stream'); - stream.set('ids', this.get('model.postIds')); - var index = stream.get('count') - 1; - stream.get('content').pushObject(Ember.Object.create({ - indexStart: index, - indexEnd: index, - content: post - })); - this.get('controllers.composer').set('showing', false); + if (composer.get('content.discussion') != discussion) { + composer.switchContent(ComposerReply.create({ + user: controller.get('session.user'), + discussion: discussion, + submit: function(value) { + controller.saveReply(this.get('discussion'), value); + } + })); + } + composer.send('show'); }, // This action is called when the start position of the discussion diff --git a/ember/app/routes/discussion.js b/ember/app/routes/discussion.js index 6b4373834..a818d4934 100644 --- a/ember/app/routes/discussion.js +++ b/ember/app/routes/discussion.js @@ -50,12 +50,15 @@ export default Ember.Route.extend({ }, willTransition: function() { - // When we transition away from this discussion, we want to hide - // the discussions list pane. This means that when the user - // selects a different discussion within the pane, the pane will - // slide away. + // When we transition into a new discussion, we want to hide the + // discussions list pane. This means that when the user selects a + // different discussion within the pane, the pane will slide away. this.controllerFor('index').set('paneShowing', false); - } + }, + + didTransition: function() { + this.controllerFor('composer').send('minimize'); + } } }); diff --git a/ember/app/routes/index/index.js b/ember/app/routes/index/index.js index 15c977f4b..2e236f42c 100644 --- a/ember/app/routes/index/index.js +++ b/ember/app/routes/index/index.js @@ -10,6 +10,12 @@ export default Ember.Route.extend(AddCssClassToBodyMixin, { this.controllerFor('index').set('paned', false); this.controllerFor('index').set('paneShowing', false); this._super(controller, model); - } + }, + actions: { + didTransition: function() { + // @todo only if it's not a new discussion + this.controllerFor('composer').send('minimize'); + } + } }); diff --git a/ember/app/styles/flarum/composer.less b/ember/app/styles/flarum/composer.less index 8b00791b8..fc88b6fd8 100644 --- a/ember/app/styles/flarum/composer.less +++ b/ember/app/styles/flarum/composer.less @@ -19,23 +19,33 @@ margin-left: -20px; margin-right: 180px; .box-shadow(0 2px 6px rgba(0, 0, 0, 0.25)); - border-radius: 4px 4px 0 0; + border-radius: @border-radius-base @border-radius-base 0 0; background: rgba(255, 255, 255, 0.9); transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray position: relative; - .transition(~"margin-left 0.2s, margin-right 0.2s, background 0.2s"); + height: 300px; + .transition(~"background 0.2s"); .index-index & { margin-left: 205px; margin-right: -20px; } - &.active { + &.active, &.fullscreen { background: #fff; } &.minimized { height: 50px; cursor: pointer; } + &.fullscreen { + position: fixed; + left: 0; + top: 0; + bottom: 0; + right: 0; + margin: 0; + height: auto; + } } .composer-content { padding: 20px 20px 15px; @@ -43,14 +53,18 @@ .minimized & { padding: 10px 20px; } + .fullscreen & { + max-width: 900px; + margin: 0 auto; + padding: 30px; + } } .composer-handle { height: 20px; margin-bottom: -20px; - cursor: row-resize; position: relative; - .minimized & { + .minimized &, .fullscreen & { display: none; } } @@ -58,6 +72,16 @@ position: absolute; right: 10px; top: 10px; + list-style-type: none; + padding: 0; + margin: 0; + + & li { + display: inline-block; + } +} +.btn-minimize .fa { + vertical-align: -5px; } .composer-avatar { float: left; @@ -82,12 +106,15 @@ } } .composer-editor { + .minimized & { + visibility: hidden; + } +} +.text-editor { & textarea { background: none; border-radius: 0; padding: 0; - margin-bottom: 10px; - height: 200px; border: 0; resize: none; color: @fl-body-color; @@ -98,8 +125,30 @@ background: none; } } +} +.text-editor-controls { + margin: 10px 0 0; + padding: 0; + list-style-type: none; + & li { + display: inline-block; + } +} - .minimized & { - visibility: hidden; +.composer-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + opacity: 0; + pointer-events: none; + border-radius: @border-radius-base @border-radius-base 0 0; + .transition(opacity 0.2s); + + &.active { + opacity: 1; + pointer-events: auto; } } \ No newline at end of file diff --git a/ember/app/templates/components/discussions/composer-body.hbs b/ember/app/templates/components/discussions/composer-body.hbs new file mode 100644 index 000000000..0c4a8f1dc --- /dev/null +++ b/ember/app/templates/components/discussions/composer-body.hbs @@ -0,0 +1,11 @@ +{{user-avatar user class="composer-avatar"}} + +
+ {{ui/controls/item-list items=headerItems class="composer-header list-inline"}} + +
+ {{ui/controls/text-editor submit="submit" value=value placeholder=placeholder submitLabel=submitLabel}} +
+
+ +{{ui/controls/loading-indicator classNameBindings=":composer-loading loading:active"}} \ No newline at end of file diff --git a/ember/app/templates/components/ui/controls/text-editor.hbs b/ember/app/templates/components/ui/controls/text-editor.hbs index 96ad60dfd..a61da22ac 100644 --- a/ember/app/templates/components/ui/controls/text-editor.hbs +++ b/ember/app/templates/components/ui/controls/text-editor.hbs @@ -1,9 +1,3 @@ {{textarea value=value placeholder=placeholder class="form-control"}} -
- -
- - -
-
+{{ui/controls/item-list items=controlItems class="text-editor-controls"}} diff --git a/ember/app/templates/composer.hbs b/ember/app/templates/composer.hbs index 70a59ad88..fb2c72d8c 100644 --- a/ember/app/templates/composer.hbs +++ b/ember/app/templates/composer.hbs @@ -1,31 +1,9 @@
-
- {{#unless minimized}} - - {{fa-icon "chevron-down"}} - {{/unless}} - {{fa-icon "times"}} -
+{{ui/controls/item-list items=view.controlItems class="composer-controls"}}
- - {{user-avatar user class="composer-avatar"}} - -
- -

{{{title}}}

- -
- {{ui/controls/text-editor placeholder="" save="save"}} -
- -
- + {{#if content}} + {{view content}} + {{/if}}
\ No newline at end of file diff --git a/ember/app/views/composer.js b/ember/app/views/composer.js index 362ba3a70..1f42fa0a9 100644 --- a/ember/app/views/composer.js +++ b/ember/app/views/composer.js @@ -1,10 +1,39 @@ import Ember from 'ember'; -export default Ember.View.extend({ +import ActionButton from '../components/ui/controls/action-button'; +import TaggedArray from '../utils/tagged-array'; + +export default Ember.View.extend(Ember.Evented, { classNames: ['composer'], - classNameBindings: ['controller.showing:showing', 'controller.minimized:minimized', 'active'], + classNameBindings: [ + 'controller.showing:showing', + 'controller.minimized:minimized', + 'controller.fullScreen:fullscreen', + 'active' + ], + + computedHeight: function() { + if (this.get('controller.minimized') || this.get('controller.fullScreen')) { + return ''; + } 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.$()) { @@ -12,14 +41,19 @@ export default Ember.View.extend({ } 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')) { - Ember.$(this).find('textarea').focus(); + view.focus(); + } else { + view.$().hide(); } }); - view.updateBottomPadding(); + view.updateBottomPadding(true); }); }.observes('controller.showing'), @@ -32,19 +66,28 @@ export default Ember.View.extend({ var oldHeight = this.$().height(); Ember.run.scheduleOnce('afterRender', function() { var newHeight = view.$().height(); - view.updateBottomPadding(); + view.updateBottomPadding(true); view.$().css('height', oldHeight).animate({height: newHeight}, 'fast', function() { - view.$().css('height', ''); if (! view.get('controller.minimized')) { - view.$('textarea').focus(); + 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() { - this.showingChanged(); - this.minimizedChanged(); + this.$().hide(); var controller = this.get('controller'); this.$('.composer-content').click(function() { @@ -54,15 +97,148 @@ export default Ember.View.extend({ }); var view = this; - this.$('textarea').focus(function() { + this.$().on('focus', ':input', function() { view.set('active', true); - }).blur(function() { + }).on('blur', ':input', function() { view.set('active', false); }); + + this.set('height', this.$().height()); + + controller.on('focus', this, this.focus); + + $(window).on('resize', {view: this}, this.windowWasResized).resize(); + + var dragData = {view: this}; + this.$('.composer-handle').css('cursor', 'row-resize') + .mousedown(function(e) { + dragData.mouseStart = e.clientY; + dragData.heightStart = view.$().height(); + dragData.handle = $(this); + $('body').css('cursor', 'row-resize'); + }).bind('dragstart mousedown', function(e) { + e.preventDefault(); + }); + + $(document) + .on('mousemove', dragData, this.mouseWasMoved) + .on('mouseup', dragData, this.mouseWasReleased); }, - updateBottomPadding: function() { - Ember.$('#main').animate({paddingBottom: this.get('controller.showing') ? this.$().outerHeight() - Ember.$('#footer').outerHeight(true) : 0}, 'fast'); + willDestroyElement: function() { + $(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'); + }, + + mouseWasMoved: function(event) { + if (! event.data.handle) { + return; + } + var view = event.data.view; + + 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 (animate) { + $('html, body').animate({scrollTop: $(document).height()}, 'fast'); + } else { + $('html, body').scrollTop($(document).height()); + } + } + }, + + updateTextareaHeight: function() { + var content = this.$('.composer-content'); + this.$('textarea').height((this.get('computedHeight') || this.$().height()) + - parseInt(content.css('padding-top')) + - parseInt(content.css('padding-bottom')) + - this.$('.composer-header').outerHeight(true) + - this.$('.text-editor-controls').outerHeight(true)); } }); diff --git a/ember/bower.json b/ember/bower.json index 388de714f..6ebbfe68d 100644 --- a/ember/bower.json +++ b/ember/bower.json @@ -18,6 +18,7 @@ "spin.js": "~2.0.1", "pace": "~0.7.1", "moment": "~2.8.4", - "ember-simple-auth": "0.7.2" + "ember-simple-auth": "0.7.2", + "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0" } }