Skip to content

Commit a929410

Browse files
committed
FIX: improves text selection of posts
This commit is applying different techniques to make selecting text of a post less error prone: - disables text selection of all the page BUT the current post - ensures the selection menu is not interfering with text selection - ensures the d-header is not interfering with text selection The situation was very bad on android but it should also improve other situations.
1 parent 6b5dea2 commit a929410

File tree

3 files changed

+114
-61
lines changed

3 files changed

+114
-61
lines changed

app/assets/javascripts/discourse/app/components/post-text-selection.gjs

Lines changed: 114 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { cancel, debounce } from "@ember/runloop";
55
import { service } from "@ember/service";
66
import { modifier } from "ember-modifier";
77
import PostTextSelectionToolbar from "discourse/components/post-text-selection-toolbar";
8+
import bodyClass from "discourse/helpers/body-class";
89
import discourseDebounce from "discourse/lib/debounce";
910
import { bind } from "discourse/lib/decorators";
1011
import { INPUT_DELAY } from "discourse/lib/environment";
1112
import escapeRegExp from "discourse/lib/escape-regexp";
1213
import isElementInViewport from "discourse/lib/is-element-in-viewport";
14+
import discourseLater from "discourse/lib/later";
1315
import toMarkdown from "discourse/lib/to-markdown";
1416
import { applyValueTransformer } from "discourse/lib/transformer";
1517
import {
@@ -49,7 +51,8 @@ export default class PostTextSelection extends Component {
4951
@service siteSettings;
5052
@service menu;
5153

52-
@tracked isSelecting = false;
54+
@tracked hasPostTextSelection = false;
55+
@tracked selectTextMode = false;
5356
@tracked preventClose = applyValueTransformer(
5457
"post-text-selection-prevent-close",
5558
false
@@ -64,14 +67,24 @@ export default class PostTextSelection extends Component {
6467
});
6568

6669
documentListeners = modifier(() => {
67-
document.addEventListener("mousedown", this.mousedown, { passive: true });
68-
document.addEventListener("mouseup", this.mouseup, { passive: true });
6970
document.addEventListener("selectionchange", this.onSelectionChanged);
71+
document.addEventListener("pointerup", this.pointerup, { passive: true });
72+
// fires when user finishes adjusting text selection on android
73+
document.addEventListener("contextmenu", this.contextmenu, {
74+
passive: true,
75+
});
76+
document.addEventListener("touchend", this.touchend, {
77+
passive: true,
78+
});
7079

7180
return () => {
72-
document.removeEventListener("mousedown", this.mousedown);
73-
document.removeEventListener("mouseup", this.mouseup);
7481
document.removeEventListener("selectionchange", this.onSelectionChanged);
82+
document.removeEventListener("pointerup", this.pointerup);
83+
document.removeEventListener("contextmenu", this.contextmenu);
84+
document.removeEventListener("touchend", this.touchend);
85+
86+
this._cleanUserSelectState();
87+
this.hasPostTextSelection = false;
7588
};
7689
});
7790

@@ -93,6 +106,7 @@ export default class PostTextSelection extends Component {
93106
super.willDestroy(...arguments);
94107

95108
cancel(this.debouncedSelectionChanged);
109+
cancel(this.touchEndLaterHandler);
96110
this.menuInstance?.close();
97111
}
98112

@@ -105,11 +119,7 @@ export default class PostTextSelection extends Component {
105119
await this.menuInstance?.close();
106120
}
107121

108-
async selectionChanged(options = {}) {
109-
if (this.isSelecting) {
110-
return;
111-
}
112-
122+
async selectionChanged(options = {}, cooked, postId) {
113123
const _selectedText = selectedText();
114124

115125
const selection = window.getSelection();
@@ -134,29 +144,6 @@ export default class PostTextSelection extends Component {
134144

135145
this.prevSelectedText = _selectedText;
136146

137-
// ensure we selected content inside 1 post *only*
138-
let postId;
139-
for (let r = 0; r < selection.rangeCount; r++) {
140-
const range = selection.getRangeAt(r);
141-
const selectionStart = getElement(range.startContainer);
142-
const ancestor = getElement(range.commonAncestorContainer);
143-
144-
if (!selectionStart.closest(".cooked")) {
145-
return await this.hideToolbar();
146-
}
147-
148-
postId ||= ancestor.closest(".boxed, .reply")?.dataset?.postId;
149-
150-
if (!ancestor.closest(".contents") || !postId) {
151-
return await this.hideToolbar();
152-
}
153-
}
154-
155-
const _selectedElement = getElement(selectedNode());
156-
const cooked =
157-
_selectedElement.querySelector(".cooked") ||
158-
_selectedElement.closest(".cooked");
159-
160147
// computing markdown takes a lot of time on long posts
161148
// this code attempts to compute it only when we can't fast track
162149
let opts = {
@@ -166,6 +153,7 @@ export default class PostTextSelection extends Component {
166153
: _selectedText === toMarkdown(cooked.innerHTML),
167154
};
168155

156+
const _selectedElement = getElement(selectedNode());
169157
for (
170158
let element = _selectedElement;
171159
element && element.tagName !== "ARTICLE";
@@ -185,7 +173,6 @@ export default class PostTextSelection extends Component {
185173
let supportsFastEdit = this.canEditPost;
186174

187175
const start = getElement(selection.getRangeAt(0).startContainer);
188-
189176
if (!start || start.closest(CSS_TO_DISABLE_FAST_EDIT)) {
190177
supportsFastEdit = false;
191178
}
@@ -220,7 +207,8 @@ export default class PostTextSelection extends Component {
220207
// so we need more space
221208
// - the end of the selection is not in viewport, in this case our menu will be shown at the top
222209
// of the screen, so we need more space to avoid overlapping with the native menu
223-
offset = 70;
210+
const { isAndroid } = this.capabilities;
211+
offset = isAndroid ? 90 : 70;
224212
}
225213
}
226214

@@ -257,29 +245,59 @@ export default class PostTextSelection extends Component {
257245
}
258246

259247
@bind
260-
onSelectionChanged() {
261-
if (this.isSelecting) {
262-
return;
248+
async onSelectionChanged(options) {
249+
const selection = window.getSelection();
250+
if (selection.rangeCount) {
251+
const range = selection.getRangeAt(0);
252+
const parent =
253+
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
254+
? range.commonAncestorContainer.parentNode
255+
: range.commonAncestorContainer;
256+
257+
const cooked = parent.closest(".cooked");
258+
if (cooked) {
259+
const article = cooked.closest(".boxed, .reply");
260+
const postId = article.dataset.postId;
261+
262+
if (!options.force) {
263+
this.selectTextMode = true;
264+
this.hasPostTextSelection = true;
265+
}
266+
267+
const { isIOS, isWinphone, isAndroid } = this.capabilities;
268+
const wait = isIOS || isWinphone || isAndroid ? INPUT_DELAY : 25;
269+
this.selectionChangeHandler = discourseDebounce(
270+
this,
271+
this.selectionChanged,
272+
options,
273+
cooked,
274+
postId,
275+
wait
276+
);
277+
} else {
278+
cancel(this.selectionChangeHandler);
279+
await this.hideToolbar();
280+
}
263281
}
282+
}
264283

265-
const { isIOS, isWinphone, isAndroid } = this.capabilities;
266-
const wait = isIOS || isWinphone || isAndroid ? INPUT_DELAY : 25;
267-
this.selectionChangeHandler = discourseDebounce(
268-
this,
269-
this.selectionChanged,
270-
wait
271-
);
284+
@bind
285+
touchend() {
286+
// ensures touchend is processed after selectionchange
287+
// this is especially needed on iOS
288+
this.touchEndLaterHandler = discourseLater(() => {
289+
this._cleanUserSelectState();
290+
}, 50);
272291
}
273292

274293
@bind
275-
mousedown() {
276-
this.isSelecting = true;
294+
contextmenu() {
295+
this._cleanUserSelectState();
277296
}
278297

279298
@bind
280-
mouseup() {
281-
this.isSelecting = false;
282-
this.onSelectionChanged();
299+
pointerup() {
300+
this._cleanUserSelectState();
283301
}
284302

285303
get post() {
@@ -307,9 +325,10 @@ export default class PostTextSelection extends Component {
307325
@action
308326
handleTopicScroll() {
309327
if (this.site.mobileView) {
328+
this._cleanUserSelectState();
310329
this.debouncedSelectionChanged = debounce(
311330
this,
312-
this.selectionChanged,
331+
this.onSelectionChanged,
313332
{ force: true },
314333
250,
315334
false
@@ -328,7 +347,51 @@ export default class PostTextSelection extends Component {
328347
return await this.args.buildQuoteMarkdown();
329348
}
330349

350+
_cleanUserSelectState() {
351+
document
352+
.querySelector(".allow-post-text-selection")
353+
?.classList.remove("allow-post-text-selection");
354+
355+
this.selectTextMode = false;
356+
357+
const selection = window.getSelection();
358+
if (selection.rangeCount) {
359+
const range = selection.getRangeAt(0);
360+
const selectionStart = getElement(range.startContainer);
361+
const cooked = selectionStart.closest(".cooked");
362+
this.hasPostTextSelection = !!cooked;
363+
} else {
364+
this.hasPostTextSelection = false;
365+
}
366+
}
367+
331368
<template>
369+
{{! styles are inline to ensure browser has to parse them only when necessary}}
370+
{{#if this.selectTextMode}}
371+
{{bodyClass "-select-post-text-mode"}}
372+
373+
<style>
374+
body.-select-post-text-mode {
375+
[data-identifier="post-text-selection-toolbar"] {
376+
display: none;
377+
}
378+
}
379+
</style>
380+
{{/if}}
381+
382+
{{#if this.hasPostTextSelection}}
383+
{{bodyClass "-has-post-text-selection"}}
384+
385+
<style>
386+
body.-has-post-text-selection {
387+
.d-header-wrap * {
388+
user-select: none;
389+
-webkit-user-select: none;
390+
}
391+
}
392+
</style>
393+
{{/if}}
394+
332395
<div
333396
{{this.documentListeners}}
334397
{{this.appEventsListeners}}

app/assets/javascripts/discourse/app/lib/utilities.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,6 @@ export function selectedText() {
122122
? range.commonAncestorContainer
123123
: range.commonAncestorContainer.parentElement;
124124

125-
// ensure we never quote text in the post menu area
126-
const postMenuArea = ancestor.querySelector(".post-menu-area");
127-
if (postMenuArea) {
128-
range.setEndBefore(postMenuArea);
129-
}
130-
131125
const oneboxTest = ancestor.closest("aside.onebox[data-onebox-src]");
132126
const codeBlockTest = ancestor.closest("pre");
133127
if (codeBlockTest) {

app/assets/stylesheets/common/base/topic-post.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -737,10 +737,6 @@ aside.quote {
737737
opacity: 0.4;
738738
}
739739

740-
.fk-d-menu[data-identifier="post-text-selection-toolbar"] {
741-
@include user-select(none);
742-
}
743-
744740
.quote-button {
745741
flex-direction: column;
746742

0 commit comments

Comments
 (0)