FIX: quote button issues

- disappear when moving to another topic
- disappears when clicking outside of the selection
- works even when selecting the last paragraph of a post
- works on all latest mobile OS
This commit is contained in:
Régis Hanol
2016-11-24 18:23:33 +01:00
parent 054c428ba3
commit af387edeb0
5 changed files with 87 additions and 167 deletions

View File

@ -2,129 +2,53 @@ import computed from 'ember-addons/ember-computed-decorators';
import { selectedText } from 'discourse/lib/utilities'; import { selectedText } from 'discourse/lib/utilities';
// we don't want to deselect when we click on buttons that use it // we don't want to deselect when we click on buttons that use it
function ignoreElements(e) { function willQuote(e) {
const $target = $(e.target); const $target = $(e.target);
return $target.hasClass('quote-button') || return $target.hasClass('quote-button') || $target.closest('.create, .share, .reply-new').length;
$target.closest('.create').length ||
$target.closest('.reply-new').length ||
$target.closest('.share').length;
} }
export default Ember.Component.extend({ export default Ember.Component.extend({
classNames: ['quote-button'], classNames: ['quote-button'],
classNameBindings: ['visible'], classNameBindings: ['visible'],
isMouseDown: false,
_isTouchInProgress: false,
@computed('quoteState.buffer') @computed('quoteState.buffer')
visible: buffer => buffer && buffer.length > 0, visible: buffer => buffer && buffer.length > 0,
/** _isMouseDown: false,
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 => {
this.set('isMouseDown', false);
if (ignoreElements(e)) { return; }
this._selectText(e.target);
}).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 (!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;
}
_selectionChanged() {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.isCollapsed) { if (selection.isCollapsed) { return; }
return;
// ensure we selected content inside 1 post
let firstRange, postId;
for (let r = 0; r < selection.rangeCount; r++) {
const range = selection.getRangeAt(r);
firstRange = firstRange || range;
const $ancestor = $(range.commonAncestorContainer);
if ($ancestor.closest(".contents").length === 0) { return; }
postId = postId || $ancestor.closest('.boxed, .reply').data('post-id');
if (!postId) { return; }
} }
const range = selection.getRangeAt(0), this.get("quoteState").setProperties({ postId, buffer: selectedText() });
cloned = range.cloneRange(),
$ancestor = $(range.commonAncestorContainer);
if ($ancestor.closest('.cooked').length === 0) { // on Desktop, shows the button at the beginning of the selection
return this.sendAction('deselectText'); // on Mobile, shows the button at the end of the selection
} const isMobileDevice = this.site.isMobileDevice;
const { isIOS, isAndroid } = this.capabilities;
const selVal = selectedText(); const showAtEnd = isMobileDevice || isIOS || isAndroid;
if (quoteState.get('buffer') === selVal) { return; }
quoteState.setProperties({ postId, buffer: selVal });
// create a marker element containing a single invisible character // create a marker element containing a single invisible character
const markerElement = document.createElement("span"); const markerElement = document.createElement("span");
markerElement.appendChild(document.createTextNode("\ufeff")); markerElement.appendChild(document.createTextNode("\ufeff"));
const isMobileDevice = this.site.isMobileDevice; // on mobile, collapse the range at the end of the selection
const capabilities = this.capabilities; if (showAtEnd) { firstRange.collapse(); }
const isIOS = capabilities.isIOS; // insert the marker
const isAndroid = capabilities.isAndroid; firstRange.insertNode(markerElement);
// 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 // retrieve the position of the marker
const $markerElement = $(markerElement); const $markerElement = $(markerElement);
@ -135,41 +59,51 @@ export default Ember.Component.extend({
// remove the marker // remove the marker
markerElement.parentNode.removeChild(markerElement); markerElement.parentNode.removeChild(markerElement);
// work around Chrome that would sometimes lose the selection // change the position of the button
const sel = window.getSelection(); Ember.run.scheduleOnce("afterRender", () => {
sel.removeAllRanges(); let top = markerOffset.top;
sel.addRange(cloned); let left = markerOffset.left + Math.max(0, parentScrollLeft);
Ember.run.scheduleOnce('afterRender', function() { if (showAtEnd) {
let topOff = markerOffset.top; top = top + 20;
let leftOff = markerOffset.left; left = Math.min(left + 10, $(window).width() - $quoteButton.outerWidth());
if (parentScrollLeft > 0) leftOff += parentScrollLeft;
if (isMobileDevice || isIOS || isAndroid) {
topOff = topOff + 20;
leftOff = Math.min(leftOff + 10, $(window).width() - $quoteButton.outerWidth());
} else { } else {
topOff = topOff - $quoteButton.outerHeight() - 5; top = top - $quoteButton.outerHeight() - 5;
} }
$quoteButton.offset({ top: topOff, left: leftOff }); $quoteButton.offset({ top, left });
});
},
didInsertElement() {
const { isWinphone, isAndroid } = this.capabilities;
const wait = (isWinphone || isAndroid) ? 250 : 25;
const onSelectionChanged = _.debounce(() => this._selectionChanged(), wait);
$(document).on("mousedown.quote-button", (e) => {
this._isMouseDown = true;
if (!willQuote(e)) {
this.sendAction("deselectText");
}
}).on("mouseup.quote-button", () => {
this._isMouseDown = false;
onSelectionChanged();
}).on("selectionchange.quote-button", () => {
if (!this._isMouseDown) {
onSelectionChanged();
}
}); });
}, },
willDestroyElement() { willDestroyElement() {
$(document) $(document).off("mousedown.quote-button")
.off("mousedown.quote-button") .off("mouseup.quote-button")
.off("mouseup.quote-button") .off("selectionchange.quote-button");
.off("touchstart.quote-button")
.off("touchend.quote-button")
.off("selectionchange");
this.sendAction('deselectText');
}, },
click(e) { click(e) {
e.stopPropagation(); this.sendAction("selectText");
this.sendAction('selectText'); return false;
} }
}); });

View File

@ -168,23 +168,21 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
actions: { actions: {
deselectText() { deselectText() {
const quoteState = this.get('quoteState'); this.get('quoteState').setProperties({ buffer: null, postId: null });
quoteState.setProperties({ buffer: null, postId: null });
}, },
selectText() { selectText() {
const quoteState = this.get('quoteState'); const { postId, buffer } = this.get('quoteState');
const postStream = this.get('model.postStream');
const postId = quoteState.get('postId'); this.send('deselectText');
postStream.loadPost(postId).then(post => {
this.get('model.postStream').loadPost(postId).then(post => {
// If we can't create a post, delegate to reply as new topic // If we can't create a post, delegate to reply as new topic
if (!this.get('model.details.can_create_post')) { if (!this.get('model.details.can_create_post')) {
this.send('replyAsNewTopic', post); this.send('replyAsNewTopic', post);
return; return;
} }
const composer = this.get('composer');
const composerOpts = { const composerOpts = {
action: Composer.REPLY, action: Composer.REPLY,
draftKey: post.get('topic.draft_key') draftKey: post.get('topic.draft_key')
@ -197,19 +195,19 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
} }
// If the composer is associated with a different post, we don't change it. // If the composer is associated with a different post, we don't change it.
const composer = this.get('composer');
const composerPost = composer.get('content.post'); const composerPost = composer.get('content.post');
if (composerPost && (composerPost.get('id') !== this.get('post.id'))) { if (composerPost && (composerPost.get('id') !== this.get('post.id'))) {
composerOpts.post = composerPost; composerOpts.post = composerPost;
} }
const quotedText = Quote.build(post, quoteState.get('buffer')); const quotedText = Quote.build(post, buffer);
composerOpts.quote = quotedText; composerOpts.quote = quotedText;
if (composer.get('content.viewOpen') || composer.get('content.viewDraft')) { if (composer.get('content.viewOpen') || composer.get('content.viewDraft')) {
this.appEvents.trigger('composer:insert-text', quotedText); this.appEvents.trigger('composer:insert-text', quotedText);
} else { } else {
composer.open(composerOpts); composer.open(composerOpts);
} }
this.send('deselectText');
}); });
}, },

View File

@ -85,37 +85,27 @@ export function extractDomainFromUrl(url) {
} }
export function selectedText() { export function selectedText() {
var html = ''; const selection = window.getSelection();
if (selection.isCollapsed) { return ""; }
if (typeof window.getSelection !== "undefined") { const $div = $("<div>");
var sel = window.getSelection(); for (let r = 0; r < selection.rangeCount; r++) {
if (sel.rangeCount) { const range = selection.getRangeAt(r);
var container = document.createElement("div"); const $ancestor = $(range.commonAncestorContainer);
for (var i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents()); // ensure we never quote text in the post menu area
} const $postMenuArea = $ancestor.find(".post-menu-area")[0];
html = container.innerHTML; if ($postMenuArea) { range.setEndBefore($postMenuArea); }
}
} else if (typeof document.selection !== "undefined") { $div.append(range.cloneContents());
if (document.selection.type === "Text") {
html = document.selection.createRange().htmlText;
}
} }
html = html.replace(/<br>/g, "\n"); // strip click counters
$div.find(".clicks").remove();
// replace emojis
$div.find("img.emoji").replaceWith(function() { return this.title; });
// Strip out any .click elements from the HTML before converting it to text return String($div.text()).trim();
const div = document.createElement('div');
div.innerHTML = html;
const $div = $(div);
// Find all emojis and replace with its title attribute.
$div.find('img.emoji').replaceWith(function() { return this.title; });
$('.clicks', $div).remove();
const text = div.textContent || div.innerText || "";
return String(text).trim();
} }
// Determine the row and col of the caret in an element // Determine the row and col of the caret in an element

View File

@ -225,8 +225,7 @@
{{share-popup topic=model replyAsNewTopic="replyAsNewTopic"}} {{share-popup topic=model replyAsNewTopic="replyAsNewTopic"}}
{{#if currentUser.enable_quoting}} {{#if currentUser.enable_quoting}}
{{quote-button topic=model {{quote-button quoteState=quoteState
quoteState=quoteState
selectText="selectText" selectText="selectText"
deselectText="deselectText"}} deselectText="deselectText"}}
{{/if}} {{/if}}

View File

@ -111,7 +111,6 @@ whiteListFeature('default', [
'a.onebox', 'a.onebox',
'a[data-bbcode]', 'a[data-bbcode]',
'a[name]', 'a[name]',
'a[name]',
'a[rel=nofollow]', 'a[rel=nofollow]',
'a[target=_blank]', 'a[target=_blank]',
'a[title]', 'a[title]',