Skip to content

Commit 9318510

Browse files
authored
fix(terminal): preserve touch selection while scrolling (#1904)
1 parent 50c76a6 commit 9318510

File tree

1 file changed

+104
-70
lines changed

1 file changed

+104
-70
lines changed

src/components/terminal/terminalTouchSelection.js

Lines changed: 104 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export default class TerminalTouchSelection {
3030
this.initialTouchPos = { x: 0, y: 0 };
3131
this.tapHoldTimeout = null;
3232
this.dragHandle = null;
33+
this.isSelectionTouchActive = false;
34+
this.pendingSelectionClearTouch = null;
3335

3436
// Zoom tracking
3537
this.pinchStartDistance = 0;
@@ -59,6 +61,12 @@ export default class TerminalTouchSelection {
5961
this.selectionProtected = false;
6062
this.protectionTimeout = null;
6163

64+
// Scroll tracking
65+
this.scrollElement = null;
66+
this.isTerminalScrolling = false;
67+
this.scrollEndTimeout = null;
68+
this.scrollEndDelay = 100;
69+
6270
this.init();
6371
}
6472

@@ -189,15 +197,6 @@ export default class TerminalTouchSelection {
189197
this.boundHandlers.selectionChange = this.onSelectionChange.bind(this);
190198
this.terminal.onSelectionChange(this.boundHandlers.selectionChange);
191199

192-
// Click outside to clear selection - only within terminal area
193-
this.boundHandlers.terminalAreaTouchStart =
194-
this.onTerminalAreaTouchStart.bind(this);
195-
this.terminal.element.addEventListener(
196-
"touchstart",
197-
this.boundHandlers.terminalAreaTouchStart,
198-
{ passive: false },
199-
);
200-
201200
// Orientation change
202201
this.boundHandlers.orientationChange = this.onOrientationChange.bind(this);
203202
window.addEventListener(
@@ -208,7 +207,10 @@ export default class TerminalTouchSelection {
208207

209208
// Terminal scroll listener
210209
this.boundHandlers.terminalScroll = this.onTerminalScroll.bind(this);
211-
this.terminal.element.addEventListener(
210+
this.scrollElement =
211+
this.terminal.element.querySelector(".xterm-viewport") ||
212+
this.terminal.element;
213+
this.scrollElement.addEventListener(
212214
"scroll",
213215
this.boundHandlers.terminalScroll,
214216
{ passive: true },
@@ -237,6 +239,14 @@ export default class TerminalTouchSelection {
237239

238240
// If already selecting, don't start new selection
239241
if (this.isSelecting) {
242+
this.isSelectionTouchActive = false;
243+
this.pendingSelectionClearTouch = {
244+
x: touch.clientX,
245+
y: touch.clientY,
246+
moved: false,
247+
};
248+
// Hide menu while user scrolls or repositions, then restore on touch end.
249+
this.hideContextMenu(true);
240250
return;
241251
}
242252

@@ -251,6 +261,8 @@ export default class TerminalTouchSelection {
251261
}
252262

253263
// Start tap-hold timer
264+
this.pendingSelectionClearTouch = null;
265+
this.isSelectionTouchActive = false;
254266
this.tapHoldTimeout = setTimeout(() => {
255267
if (!this.isSelecting && !this.isPinching) {
256268
this.startSelection(touch);
@@ -275,6 +287,18 @@ export default class TerminalTouchSelection {
275287
const deltaX = Math.abs(touch.clientX - this.touchStartPos.x);
276288
const deltaY = Math.abs(touch.clientY - this.touchStartPos.y);
277289
const horizontalDelta = touch.clientX - this.touchStartPos.x;
290+
const clearTouch = this.pendingSelectionClearTouch;
291+
292+
if (clearTouch) {
293+
const clearDeltaX = Math.abs(touch.clientX - clearTouch.x);
294+
const clearDeltaY = Math.abs(touch.clientY - clearTouch.y);
295+
if (
296+
clearDeltaX > this.options.moveThreshold ||
297+
clearDeltaY > this.options.moveThreshold
298+
) {
299+
clearTouch.moved = true;
300+
}
301+
}
278302

279303
// Check if this looks like a back gesture (started near edge and moving horizontally inward)
280304
if (
@@ -301,7 +325,11 @@ export default class TerminalTouchSelection {
301325
}
302326

303327
// If we're selecting, extend selection
304-
if (this.isSelecting && !this.isHandleDragging) {
328+
if (
329+
this.isSelecting &&
330+
!this.isHandleDragging &&
331+
this.isSelectionTouchActive
332+
) {
305333
event.preventDefault();
306334
this.extendSelection(touch);
307335
}
@@ -320,8 +348,25 @@ export default class TerminalTouchSelection {
320348
this.tapHoldTimeout = null;
321349
}
322350

351+
const shouldClearSelectionByTap =
352+
this.isSelecting &&
353+
!this.isHandleDragging &&
354+
this.pendingSelectionClearTouch &&
355+
!this.pendingSelectionClearTouch.moved &&
356+
!this.isTerminalScrolling &&
357+
!this.selectionProtected;
358+
359+
this.pendingSelectionClearTouch = null;
360+
this.isSelectionTouchActive = false;
361+
362+
if (shouldClearSelectionByTap) {
363+
this.clearSelection();
364+
return;
365+
}
366+
323367
// If we were selecting and not dragging handles, finalize selection
324368
if (this.isSelecting && !this.isHandleDragging) {
369+
if (this.isTerminalScrolling) return;
325370
this.finalizeSelection();
326371
} else if (!this.isSelecting) {
327372
// Only focus terminal on touch end if not selecting and terminal was already focused
@@ -365,6 +410,8 @@ export default class TerminalTouchSelection {
365410

366411
this.isHandleDragging = true;
367412
this.dragHandle = handleType;
413+
this.isSelectionTouchActive = false;
414+
this.pendingSelectionClearTouch = null;
368415

369416
// Store the initial touch position for delta calculations
370417
const touch = event.touches[0];
@@ -452,9 +499,6 @@ export default class TerminalTouchSelection {
452499
event.preventDefault();
453500
event.stopPropagation();
454501

455-
// Store the current drag handle before clearing
456-
const currentDragHandle = this.dragHandle;
457-
458502
this.isHandleDragging = false;
459503
this.dragHandle = null;
460504

@@ -481,43 +525,6 @@ export default class TerminalTouchSelection {
481525
}
482526
}
483527

484-
onTerminalAreaTouchStart(event) {
485-
// Clear selection if touching terminal area while selecting, except on handles or context menu
486-
if (this.isSelecting) {
487-
// Don't clear selection if it's protected (during keyboard events)
488-
if (this.selectionProtected) {
489-
return;
490-
}
491-
492-
// Don't interfere with context menu at all
493-
if (this.contextMenu && this.contextMenu.style.display === "flex") {
494-
// Context menu is visible, check if touching it
495-
const rect = this.contextMenu.getBoundingClientRect();
496-
const touchX = event.touches[0].clientX;
497-
const touchY = event.touches[0].clientY;
498-
499-
if (
500-
touchX >= rect.left &&
501-
touchX <= rect.right &&
502-
touchY >= rect.top &&
503-
touchY <= rect.bottom
504-
) {
505-
// Touching context menu area, don't clear selection
506-
return;
507-
}
508-
}
509-
510-
const isHandleTouch =
511-
this.startHandle.contains(event.target) ||
512-
this.endHandle.contains(event.target);
513-
514-
// Only clear if touching within terminal but not on handles
515-
if (!isHandleTouch && this.terminal.element.contains(event.target)) {
516-
this.clearSelection();
517-
}
518-
}
519-
}
520-
521528
onOrientationChange() {
522529
// Update cell dimensions and handle positions after orientation change
523530
setTimeout(() => {
@@ -529,13 +536,28 @@ export default class TerminalTouchSelection {
529536
}
530537

531538
onTerminalScroll() {
532-
// Update handle positions when terminal is scrolled
533-
if (this.isSelecting) {
534-
this.updateHandlePositions();
535-
// Hide context menu if it's open during scroll
536-
if (this.contextMenu && this.contextMenu.style.display === "flex") {
537-
this.hideContextMenu();
538-
}
539+
if (!this.isSelecting || this.isHandleDragging) return;
540+
541+
this.isTerminalScrolling = true;
542+
this.hideHandles();
543+
this.hideContextMenu(true);
544+
545+
if (this.scrollEndTimeout) {
546+
clearTimeout(this.scrollEndTimeout);
547+
}
548+
this.scrollEndTimeout = setTimeout(() => {
549+
this.onTerminalScrollEnd();
550+
}, this.scrollEndDelay);
551+
}
552+
553+
onTerminalScrollEnd() {
554+
this.scrollEndTimeout = null;
555+
this.isTerminalScrolling = false;
556+
if (!this.isSelecting || this.isHandleDragging) return;
557+
558+
this.updateHandlePositions();
559+
if (this.contextMenuShouldStayVisible && this.options.showContextMenu) {
560+
this.showContextMenu();
539561
}
540562
}
541563

@@ -565,7 +587,7 @@ export default class TerminalTouchSelection {
565587
this.updateHandlePositions();
566588
// Temporarily hide context menu during resize but keep selection
567589
if (this.contextMenu && this.contextMenu.style.display === "flex") {
568-
this.hideContextMenu();
590+
this.hideContextMenu(true);
569591
}
570592
// Re-show context menu after resize if selection is still active
571593
setTimeout(() => {
@@ -596,6 +618,8 @@ export default class TerminalTouchSelection {
596618
}, 1000);
597619

598620
this.isSelecting = true;
621+
this.isSelectionTouchActive = true;
622+
this.pendingSelectionClearTouch = null;
599623

600624
// Try to auto-select word at touch position
601625
const wordBounds = this.getWordBoundsAt(coords);
@@ -873,9 +897,9 @@ export default class TerminalTouchSelection {
873897
this.selectionOverlay.appendChild(this.contextMenu);
874898
}
875899

876-
hideContextMenu() {
900+
hideContextMenu(force = false) {
877901
// Only hide if explicitly requested or if context menu should not stay visible
878-
if (this.contextMenu && !this.contextMenuShouldStayVisible) {
902+
if (this.contextMenu && (force || !this.contextMenuShouldStayVisible)) {
879903
this.contextMenu.style.display = "none";
880904
}
881905
}
@@ -930,6 +954,9 @@ export default class TerminalTouchSelection {
930954
this.selectionEnd = null;
931955
this.currentSelection = null;
932956
this.dragHandle = null;
957+
this.pendingSelectionClearTouch = null;
958+
this.isSelectionTouchActive = false;
959+
this.isTerminalScrolling = false;
933960

934961
this.terminal.clearSelection();
935962
this.hideHandles();
@@ -939,6 +966,10 @@ export default class TerminalTouchSelection {
939966
clearTimeout(this.tapHoldTimeout);
940967
this.tapHoldTimeout = null;
941968
}
969+
if (this.scrollEndTimeout) {
970+
clearTimeout(this.scrollEndTimeout);
971+
this.scrollEndTimeout = null;
972+
}
942973

943974
// Clear protection timeout
944975
if (this.protectionTimeout) {
@@ -963,7 +994,6 @@ export default class TerminalTouchSelection {
963994

964995
forceClearSelection() {
965996
// Temporarily disable protection to force clear
966-
const wasProtected = this.selectionProtected;
967997
this.selectionProtected = false;
968998
this.clearSelection();
969999
// Don't restore protection state since we're clearing
@@ -1225,20 +1255,24 @@ export default class TerminalTouchSelection {
12251255
this.boundHandlers.handleTouchEnd,
12261256
);
12271257

1228-
this.terminal.element.removeEventListener(
1229-
"touchstart",
1230-
this.boundHandlers.terminalAreaTouchStart,
1231-
);
1232-
this.terminal.element.removeEventListener(
1233-
"scroll",
1234-
this.boundHandlers.terminalScroll,
1235-
);
1258+
if (this.scrollElement) {
1259+
this.scrollElement.removeEventListener(
1260+
"scroll",
1261+
this.boundHandlers.terminalScroll,
1262+
);
1263+
this.scrollElement = null;
1264+
}
12361265
window.removeEventListener(
12371266
"orientationchange",
12381267
this.boundHandlers.orientationChange,
12391268
);
12401269
window.removeEventListener("resize", this.boundHandlers.orientationChange);
12411270

1271+
if (this.scrollEndTimeout) {
1272+
clearTimeout(this.scrollEndTimeout);
1273+
this.scrollEndTimeout = null;
1274+
}
1275+
12421276
// Remove selection change listener
12431277
if (this.terminal.onSelectionChange) {
12441278
this.terminal.onSelectionChange(null);

0 commit comments

Comments
 (0)