diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 new file mode 100644 index 00000000000..82db141444d --- /dev/null +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -0,0 +1,173 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { selectedText } from 'discourse/lib/utilities'; + +// we don't want to deselect when we click on buttons that use it +function ignoreElements(e) { + const $target = $(e.target); + return $target.hasClass('quote-button') || + $target.closest('.create').length || + $target.closest('.reply-new').length || + $target.closest('.share').length; +} + +export default Ember.Component.extend({ + classNames: ['quote-button'], + classNameBindings: ['visible'], + isMouseDown: false, + _isTouchInProgress: false, + + @computed('quoteState.buffer') + visible: buffer => buffer && buffer.length > 0, + + /** + Binds to the following global events: + - `mousedown` to clear the quote button if they click elsewhere. + - `mouseup` to trigger the display of the quote button. + - `selectionchange` to make the selection work under iOS + + @method didInsertElement + **/ + didInsertElement() { + let onSelectionChanged = () => this._selectText(window.getSelection().anchorNode); + + // Windows Phone hack, it is not firing the touch events + // best we can do is debounce this so we dont keep locking up + // the selection when we add the caret to measure where we place + // the quote reply widget + // + // Same hack applied to Android cause it has unreliable touchend + const isAndroid = this.capabilities.isAndroid; + if (this.capabilities.isWinphone || isAndroid) { + onSelectionChanged = _.debounce(onSelectionChanged, 500); + } + + $(document).on("mousedown.quote-button", e => { + this.set('isMouseDown', true); + + if (ignoreElements(e)) { return; } + + // deselects only when the user left click + // (allows anyone to `extend` their selection using shift+click) + if (!window.getSelection().isCollapsed && + e.which === 1 && + !e.shiftKey) { + this.sendAction('deselectText'); + } + }).on('mouseup.quote-button', e => { + if (ignoreElements(e)) { return; } + + this._selectText(e.target); + this.set('isMouseDown', false); + }).on('selectionchange', () => { + // there is no need to handle this event when the mouse is down + // or if there a touch in progress + if (this.get('isMouseDown') || this._isTouchInProgress) { return; } + // `selection.anchorNode` is used as a target + onSelectionChanged(); + }); + + // Android is dodgy, touchend often will not fire + // https://code.google.com/p/android/issues/detail?id=19827 + if (!this.isAndroid) { + $(document).on('touchstart.quote-button', () => { + this._isTouchInProgress = true; + return true; + }); + + $(document).on('touchend.quote-button', () => { + this._isTouchInProgress = false; + return true; + }); + } + }, + + _selectText(target) { + // anonymous users cannot "quote-reply" + if (!this.currentUser) return; + + const quoteState = this.get('quoteState'); + + const $target = $(target); + const postId = $target.closest('.boxed, .reply').data('post-id'); + + const details = this.get('topic.details'); + if (!(details.get('can_reply_as_new_topic') || details.get('can_create_post'))) { + return; + } + + const selection = window.getSelection(); + if (selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0), + cloned = range.cloneRange(), + $ancestor = $(range.commonAncestorContainer); + + if ($ancestor.closest('.cooked').length === 0) { + return this.sendAction('deselectText'); + } + + const selVal = selectedText(); + if (quoteState.get('buffer') === selVal) { return; } + quoteState.setProperties({ postId, buffer: selVal }); + + // create a marker element containing a single invisible character + const markerElement = document.createElement("span"); + markerElement.appendChild(document.createTextNode("\ufeff")); + + const isMobileDevice = this.site.isMobileDevice; + const capabilities = this.capabilities; + const isIOS = capabilities.isIOS; + const isAndroid = capabilities.isAndroid; + + // collapse the range at the beginning/end of the selection + // and insert it at the start of our selection range + range.collapse(!isMobileDevice); + range.insertNode(markerElement); + + // retrieve the position of the marker + const $markerElement = $(markerElement); + const markerOffset = $markerElement.offset(); + const parentScrollLeft = $markerElement.parent().scrollLeft(); + const $quoteButton = this.$(); + + // remove the marker + markerElement.parentNode.removeChild(markerElement); + + // work around Chrome that would sometimes lose the selection + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(cloned); + + Ember.run.scheduleOnce('afterRender', function() { + let topOff = markerOffset.top; + let leftOff = markerOffset.left; + + if (parentScrollLeft > 0) leftOff += parentScrollLeft; + + if (isMobileDevice || isIOS || isAndroid) { + topOff = topOff + 20; + leftOff = Math.min(leftOff + 10, $(window).width() - $quoteButton.outerWidth()); + } else { + topOff = topOff - $quoteButton.outerHeight() - 5; + } + + $quoteButton.offset({ top: topOff, left: leftOff }); + }); + }, + + willDestroyElement() { + $(document) + .off("mousedown.quote-button") + .off("mouseup.quote-button") + .off("touchstart.quote-button") + .off("touchend.quote-button") + .off("selectionchange"); + }, + + click(e) { + e.stopPropagation(); + this.sendAction('selectText'); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 deleted file mode 100644 index d91712542e6..00000000000 --- a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 +++ /dev/null @@ -1,160 +0,0 @@ -import Quote from 'discourse/lib/quote'; -import computed from 'ember-addons/ember-computed-decorators'; -import { selectedText } from 'discourse/lib/utilities'; - -export default Ember.Controller.extend({ - topic: Ember.inject.controller(), - composer: Ember.inject.controller(), - - @computed('buffer', 'postId') - post(buffer, postId) { - if (!postId || Ember.isEmpty(buffer)) { return null; } - - const postStream = this.get('topic.model.postStream'); - const post = postStream.findLoadedPost(postId); - - return post; - }, - - // Save the currently selected text and displays the - // "quote reply" button - selectText(postId) { - // anonymous users cannot "quote-reply" - if (!this.currentUser) return; - - // don't display the "quote-reply" button if we can't reply - const topicDetails = this.get('topic.model.details'); - if (!(topicDetails.get('can_reply_as_new_topic') || topicDetails.get('can_create_post'))) { - return; - } - - const selection = window.getSelection(); - - // no selections - if (selection.isCollapsed) { - this.set('buffer', ''); - return; - } - - // retrieve the selected range - const range = selection.getRangeAt(0), - cloned = range.cloneRange(), - $ancestor = $(range.commonAncestorContainer); - - if ($ancestor.closest('.cooked').length === 0) { - this.set('buffer', ''); - return; - } - - const selVal = selectedText(); - if (this.get('buffer') === selVal) return; - - // we need to retrieve the post data from the posts collection in the topic controller - this.set('postId', postId); - this.set('buffer', selVal); - - // create a marker element - const markerElement = document.createElement("span"); - // containing a single invisible character - markerElement.appendChild(document.createTextNode("\ufeff")); - - const isMobileDevice = this.site.isMobileDevice; - const capabilities = this.capabilities, - isIOS = capabilities.isIOS, - isAndroid = capabilities.isAndroid; - - // collapse the range at the beginning/end of the selection - range.collapse(!isMobileDevice); - // and insert it at the start of our selection range - range.insertNode(markerElement); - - // retrieve the position of the marker - const $markerElement = $(markerElement), - markerOffset = $markerElement.offset(), - parentScrollLeft = $markerElement.parent().scrollLeft(), - $quoteButton = $('.quote-button'); - - // remove the marker - markerElement.parentNode.removeChild(markerElement); - - // work around Chrome that would sometimes lose the selection - const sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(cloned); - - // move the quote button above the marker - Em.run.schedule('afterRender', function() { - let topOff = markerOffset.top; - let leftOff = markerOffset.left; - - if (parentScrollLeft > 0) leftOff += parentScrollLeft; - - if (isMobileDevice || isIOS || isAndroid) { - topOff = topOff + 20; - leftOff = Math.min(leftOff + 10, $(window).width() - $quoteButton.outerWidth()); - } else { - topOff = topOff - $quoteButton.outerHeight() - 5; - } - - $quoteButton.offset({ top: topOff, left: leftOff }); - }); - }, - - quoteText() { - const Composer = require('discourse/models/composer').default; - const postId = this.get('postId'); - const post = this.get('post'); - - // defer load if needed, if in an expanded replies section - if (!post) { - const postStream = this.get('topic.model.postStream'); - return postStream.loadPost(postId).then(p => { - this.set('post', p); - return this.quoteText(); - }); - } - - // If we can't create a post, delegate to reply as new topic - if (!this.get('topic.model.details.can_create_post')) { - this.get('topic').send('replyAsNewTopic', post); - return; - } - - const composerController = this.get('composer'); - const composerOpts = { - action: Composer.REPLY, - draftKey: post.get('topic.draft_key') - }; - - if (post.get('post_number') === 1) { - composerOpts.topic = post.get("topic"); - } else { - composerOpts.post = post; - } - - // If the composer is associated with a different post, we don't change it. - const composerPost = composerController.get('content.post'); - if (composerPost && (composerPost.get('id') !== this.get('post.id'))) { - composerOpts.post = composerPost; - } - - const buffer = this.get('buffer'); - const quotedText = Quote.build(post, buffer); - composerOpts.quote = quotedText; - if (composerController.get('content.viewOpen') || composerController.get('content.viewDraft')) { - this.appEvents.trigger('composer:insert-text', quotedText); - } else { - composerController.open(composerOpts); - } - this.set('buffer', ''); - return false; - }, - - deselectText() { - // clear selected text - window.getSelection().removeAllRanges(); - // clean up the buffer - this.set('buffer', ''); - } - -}); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 247d1a6755c..40dcacd855d 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -14,9 +14,7 @@ import isElementInViewport from "discourse/lib/is-element-in-viewport"; export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { composer: Ember.inject.controller(), - quoteButton: Ember.inject.controller('quote-button'), application: Ember.inject.controller(), - multiSelect: false, allPostsSelected: false, editingTopic: false, @@ -30,9 +28,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { userTriggeredProgress: null, _progressIndex: null, hasScrolled: null, - username_filters: null, filter: null, + quoteState: null, topicDelegated: [ 'toggleMultiSelect', @@ -141,10 +139,12 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { I18n.t("suggested_topics.title"); }, - _clearSelected: function() { + init() { + this._super(); this.set('selectedPosts', []); this.set('selectedReplies', []); - }.on('init'), + this.set('quoteState', Ember.Object.create({ buffer: null, postId: null })); + }, showCategoryChooser: Ember.computed.not("model.isPrivateMessage"), @@ -167,6 +167,52 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { actions: { + deselectText() { + const quoteState = this.get('quoteState'); + quoteState.setProperties({ buffer: null, postId: null }); + }, + + selectText() { + const quoteState = this.get('quoteState'); + const postStream = this.get('model.postStream'); + + const postId = quoteState.get('postId'); + postStream.loadPost(postId).then(post => { + // If we can't create a post, delegate to reply as new topic + if (!this.get('model.details.can_create_post')) { + this.send('replyAsNewTopic', post); + return; + } + + const composer = this.get('composer'); + const composerOpts = { + action: Composer.REPLY, + draftKey: post.get('topic.draft_key') + }; + + if (post.get('post_number') === 1) { + composerOpts.topic = post.get("topic"); + } else { + composerOpts.post = post; + } + + // If the composer is associated with a different post, we don't change it. + const composerPost = composer.get('content.post'); + if (composerPost && (composerPost.get('id') !== this.get('post.id'))) { + composerOpts.post = composerPost; + } + + const quotedText = Quote.build(post, quoteState.get('buffer')); + composerOpts.quote = quotedText; + if (composer.get('content.viewOpen') || composer.get('content.viewDraft')) { + this.appEvents.trigger('composer:insert-text', quotedText); + } else { + composer.open(composerOpts); + } + this.send('deselectText'); + }); + }, + fillGapBefore(args) { return this.get('model.postStream').fillGapBefore(args.post, args.gap); }, @@ -265,12 +311,14 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // Post related methods replyToPost(post) { - const composerController = this.get('composer'), - quoteController = this.get('quoteButton'), - quotedText = Quote.build(quoteController.get('post'), quoteController.get('buffer')), - topic = post ? post.get('topic') : this.get('model'); + const composerController = this.get('composer'); + const topic = post ? post.get('topic') : this.get('model'); - quoteController.set('buffer', ''); + const quoteState = this.get('quoteState'); + const postStream = this.get('model.postStream'); + const quotedPost = postStream.findLoadedPost(quoteState.get('postId')); + const quotedText = Quote.build(quotedPost, quoteState.get('buffer')); + this.send('deselectText'); if (composerController.get('content.topic.id') === topic.get('id') && composerController.get('content.action') === Composer.REPLY) { @@ -597,11 +645,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { replyAsNewTopic(post) { const composerController = this.get('composer'); - const quoteController = this.get('quoteButton'); - post = post || quoteController.get('post'); - const quotedText = Quote.build(post, quoteController.get('buffer')); - quoteController.deselectText(); + const quoteState = this.get('quoteState'); + post = post || quoteState.get('post'); + const quotedText = Quote.build(post, quoteState.get('buffer')); + this.send('deselectText'); composerController.open({ action: Composer.CREATE_TOPIC, @@ -633,7 +681,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { togglePostType(post) { const regular = this.site.get('post_types.regular'); const moderator = this.site.get('post_types.moderator_action'); - return post.updatePostField('post_type', post.get('post_type') === moderator ? regular : moderator); }, @@ -922,7 +969,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, - _showFooter: function() { const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts"); this.set("application.showFooter", showFooter); diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 989d1004325..794c432a7b6 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -117,7 +117,7 @@ const TopicRoute = Discourse.Route.extend({ willTransition() { this._super(); - this.controllerFor("quote-button").deselectText(); + this.controllerFor("topic").send('deselectText'); Em.run.cancel(scheduledReplace); isTransitioning = true; return true; diff --git a/app/assets/javascripts/discourse/templates/quote-button.hbs b/app/assets/javascripts/discourse/templates/components/quote-button.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/quote-button.hbs rename to app/assets/javascripts/discourse/templates/components/quote-button.hbs diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 68fd6469bcf..625155eae71 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -224,6 +224,9 @@ {{share-popup topic=model replyAsNewTopic="replyAsNewTopic"}} {{#if currentUser.enable_quoting}} - {{render "quote-button"}} + {{quote-button topic=model + quoteState=quoteState + selectText="selectText" + deselectText="deselectText"}} {{/if}} diff --git a/app/assets/javascripts/discourse/views/quote-button.js.es6 b/app/assets/javascripts/discourse/views/quote-button.js.es6 deleted file mode 100644 index 9e86495e153..00000000000 --- a/app/assets/javascripts/discourse/views/quote-button.js.es6 +++ /dev/null @@ -1,106 +0,0 @@ -// we don't want to deselect when we click on buttons that use it -function ignoreElements(e) { - const $target = $(e.target); - return $target.hasClass('quote-button') || - $target.closest('.create').length || - $target.closest('.reply-new').length || - $target.closest('.share').length; -} - -export default Ember.View.extend({ - classNames: ['quote-button'], - classNameBindings: ['visible'], - isMouseDown: false, - _isTouchInProgress: false, - - // The button is visible whenever there is something in the buffer - // (ie. something has been selected) - visible: Em.computed.notEmpty('controller.buffer'), - - /** - Binds to the following global events: - - `mousedown` to clear the quote button if they click elsewhere. - - `mouseup` to trigger the display of the quote button. - - `selectionchange` to make the selection work under iOS - - @method didInsertElement - **/ - didInsertElement() { - const controller = this.get('controller'); - - let onSelectionChanged = () => this.selectText(window.getSelection().anchorNode, controller); - - // Windows Phone hack, it is not firing the touch events - // best we can do is debounce this so we dont keep locking up - // the selection when we add the caret to measure where we place - // the quote reply widget - // - // Same hack applied to Android cause it has unreliable touchend - const isAndroid = this.capabilities.isAndroid; - if (this.capabilities.isWinphone || isAndroid) { - onSelectionChanged = _.debounce(onSelectionChanged, 500); - } - - $(document).on("mousedown.quote-button", e => { - this.set('isMouseDown', true); - - if (ignoreElements(e)) { return; } - - // deselects only when the user left click - // (allows anyone to `extend` their selection using shift+click) - if (!window.getSelection().isCollapsed && - e.which === 1 && - !e.shiftKey) controller.deselectText(); - }).on('mouseup.quote-button', e => { - if (ignoreElements(e)) { return; } - - this.selectText(e.target, controller); - this.set('isMouseDown', false); - }).on('selectionchange', () => { - // there is no need to handle this event when the mouse is down - // or if there a touch in progress - if (this.get('isMouseDown') || this._isTouchInProgress) { return; } - // `selection.anchorNode` is used as a target - onSelectionChanged(); - }); - - // Android is dodgy, touchend often will not fire - // https://code.google.com/p/android/issues/detail?id=19827 - if (!this.isAndroid) { - $(document).on('touchstart.quote-button', () => { - this._isTouchInProgress = true; - return true; - }); - - $(document).on('touchend.quote-button', () => { - this._isTouchInProgress = false; - return true; - }); - } - }, - - selectText(target, controller) { - const $target = $(target); - // breaks if quoting has been disabled by the user - if (!Discourse.User.currentProp('enable_quoting')) return; - // retrieve the post id from the DOM - const postId = $target.closest('.boxed, .reply').data('post-id'); - // select the text - if (postId) controller.selectText(postId); - }, - - willDestroyElement() { - $(document) - .off("mousedown.quote-button") - .off("mouseup.quote-button") - .off("touchstart.quote-button") - .off("touchend.quote-button") - .off("selectionchange"); - }, - - click(e) { - e.stopPropagation(); - return this.get('controller').quoteText(e); - } - -}); diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index 74bb33dabc4..c3f2ad102b2 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -2,8 +2,7 @@ import { blank, present } from 'helpers/qunit-helpers'; import { mapRoutes } from 'discourse/mapping-router'; moduleFor('controller:topic', 'controller:topic', { - needs: ['controller:modal', 'controller:composer', 'controller:quote-button', - 'controller:application'], + needs: ['controller:modal', 'controller:composer', 'controller:application'], setup() { this.registry.register('router:main', mapRoutes()); },