@@ -5,11 +5,13 @@ import { cancel, debounce } from "@ember/runloop";
5
5
import { service } from " @ember/service" ;
6
6
import { modifier } from " ember-modifier" ;
7
7
import PostTextSelectionToolbar from " discourse/components/post-text-selection-toolbar" ;
8
+ import bodyClass from " discourse/helpers/body-class" ;
8
9
import discourseDebounce from " discourse/lib/debounce" ;
9
10
import { bind } from " discourse/lib/decorators" ;
10
11
import { INPUT_DELAY } from " discourse/lib/environment" ;
11
12
import escapeRegExp from " discourse/lib/escape-regexp" ;
12
13
import isElementInViewport from " discourse/lib/is-element-in-viewport" ;
14
+ import discourseLater from " discourse/lib/later" ;
13
15
import toMarkdown from " discourse/lib/to-markdown" ;
14
16
import { applyValueTransformer } from " discourse/lib/transformer" ;
15
17
import {
@@ -49,7 +51,8 @@ export default class PostTextSelection extends Component {
49
51
@service siteSettings;
50
52
@service menu;
51
53
52
- @tracked isSelecting = false ;
54
+ @tracked hasPostTextSelection = false ;
55
+ @tracked selectTextMode = false ;
53
56
@tracked preventClose = applyValueTransformer (
54
57
" post-text-selection-prevent-close" ,
55
58
false
@@ -64,14 +67,24 @@ export default class PostTextSelection extends Component {
64
67
});
65
68
66
69
documentListeners = modifier (() => {
67
- document .addEventListener (" mousedown" , this .mousedown , { passive: true });
68
- document .addEventListener (" mouseup" , this .mouseup , { passive: true });
69
70
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
+ });
70
79
71
80
return () => {
72
- document .removeEventListener (" mousedown" , this .mousedown );
73
- document .removeEventListener (" mouseup" , this .mouseup );
74
81
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 ;
75
88
};
76
89
});
77
90
@@ -93,6 +106,7 @@ export default class PostTextSelection extends Component {
93
106
super .willDestroy (... arguments );
94
107
95
108
cancel (this .debouncedSelectionChanged );
109
+ cancel (this .touchEndLaterHandler );
96
110
this .menuInstance ? .close ();
97
111
}
98
112
@@ -105,11 +119,7 @@ export default class PostTextSelection extends Component {
105
119
await this .menuInstance ? .close ();
106
120
}
107
121
108
- async selectionChanged (options = {}) {
109
- if (this .isSelecting ) {
110
- return ;
111
- }
112
-
122
+ async selectionChanged (options = {}, cooked , postId ) {
113
123
const _selectedText = selectedText ();
114
124
115
125
const selection = window .getSelection ();
@@ -134,29 +144,6 @@ export default class PostTextSelection extends Component {
134
144
135
145
this .prevSelectedText = _selectedText;
136
146
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
-
160
147
// computing markdown takes a lot of time on long posts
161
148
// this code attempts to compute it only when we can't fast track
162
149
let opts = {
@@ -166,6 +153,7 @@ export default class PostTextSelection extends Component {
166
153
: _selectedText === toMarkdown (cooked .innerHTML ),
167
154
};
168
155
156
+ const _selectedElement = getElement (selectedNode ());
169
157
for (
170
158
let element = _selectedElement;
171
159
element && element .tagName !== " ARTICLE" ;
@@ -185,7 +173,6 @@ export default class PostTextSelection extends Component {
185
173
let supportsFastEdit = this .canEditPost ;
186
174
187
175
const start = getElement (selection .getRangeAt (0 ).startContainer );
188
-
189
176
if (! start || start .closest (CSS_TO_DISABLE_FAST_EDIT )) {
190
177
supportsFastEdit = false ;
191
178
}
@@ -220,7 +207,8 @@ export default class PostTextSelection extends Component {
220
207
// so we need more space
221
208
// - the end of the selection is not in viewport, in this case our menu will be shown at the top
222
209
// 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 ;
224
212
}
225
213
}
226
214
@@ -257,29 +245,59 @@ export default class PostTextSelection extends Component {
257
245
}
258
246
259
247
@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
+ }
263
281
}
282
+ }
264
283
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 );
272
291
}
273
292
274
293
@bind
275
- mousedown () {
276
- this .isSelecting = true ;
294
+ contextmenu () {
295
+ this ._cleanUserSelectState () ;
277
296
}
278
297
279
298
@bind
280
- mouseup () {
281
- this .isSelecting = false ;
282
- this .onSelectionChanged ();
299
+ pointerup () {
300
+ this ._cleanUserSelectState ();
283
301
}
284
302
285
303
get post () {
@@ -307,9 +325,10 @@ export default class PostTextSelection extends Component {
307
325
@action
308
326
handleTopicScroll () {
309
327
if (this .site .mobileView ) {
328
+ this ._cleanUserSelectState ();
310
329
this .debouncedSelectionChanged = debounce (
311
330
this ,
312
- this .selectionChanged ,
331
+ this .onSelectionChanged ,
313
332
{ force: true },
314
333
250 ,
315
334
false
@@ -328,7 +347,51 @@ export default class PostTextSelection extends Component {
328
347
return await this .args .buildQuoteMarkdown ();
329
348
}
330
349
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
+
331
368
<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
+
332
395
<div
333
396
{{this .documentListeners }}
334
397
{{this .appEventsListeners }}
0 commit comments