mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 22:41:10 +08:00
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:
@ -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("touchstart.quote-button")
|
.off("selectionchange.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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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');
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}}
|
||||||
|
@ -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]',
|
||||||
|
Reference in New Issue
Block a user