Skip to content

Commit e1a2703

Browse files
committed
feat: implement xterm touch selection with menu, fix resize issue
- fixed different resize cases issue - implemented touch selection
1 parent fcbb923 commit e1a2703

File tree

5 files changed

+1387
-11
lines changed

5 files changed

+1387
-11
lines changed

src/components/terminal/terminal.js

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import appSettings from "lib/settings";
1717
import LigaturesAddon from "./ligatures";
1818
import { getTerminalSettings } from "./terminalDefaults";
1919
import TerminalThemeManager from "./terminalThemeManager";
20+
import TerminalTouchSelection from "./terminalTouchSelection";
2021

2122
export default class TerminalComponent {
2223
constructor(options = {}) {
@@ -55,6 +56,7 @@ export default class TerminalComponent {
5556
this.pid = null;
5657
this.isConnected = false;
5758
this.serverMode = options.serverMode !== false; // Default true
59+
this.touchSelection = null;
5860

5961
this.init();
6062
}
@@ -99,12 +101,8 @@ export default class TerminalComponent {
99101
}
100102

101103
setupEventHandlers() {
102-
// Handle terminal resize
103-
this.terminal.onResize((size) => {
104-
if (this.serverMode) {
105-
this.resizeTerminal(size.cols, size.rows);
106-
}
107-
});
104+
// terminal resize handling
105+
this.setupResizeHandling();
108106

109107
// Handle terminal title changes
110108
this.terminal.onTitleChange((title) => {
@@ -120,6 +118,157 @@ export default class TerminalComponent {
120118
this.setupCopyPasteHandlers();
121119
}
122120

121+
/**
122+
* Setup resize handling for keyboard events and content preservation
123+
*/
124+
setupResizeHandling() {
125+
let resizeTimeout = null;
126+
let lastKnownScrollPosition = 0;
127+
let isResizing = false;
128+
let resizeCount = 0;
129+
const RESIZE_DEBOUNCE = 150;
130+
const MAX_RAPID_RESIZES = 3;
131+
132+
// Store original dimensions for comparison
133+
let originalRows = this.terminal.rows;
134+
let originalCols = this.terminal.cols;
135+
136+
this.terminal.onResize((size) => {
137+
// Track resize events
138+
resizeCount++;
139+
isResizing = true;
140+
141+
// Store current scroll position before resize
142+
if (this.terminal.buffer && this.terminal.buffer.active) {
143+
lastKnownScrollPosition = this.terminal.buffer.active.viewportY;
144+
}
145+
146+
// Clear any existing timeout
147+
if (resizeTimeout) {
148+
clearTimeout(resizeTimeout);
149+
}
150+
151+
// Debounced resize handling
152+
resizeTimeout = setTimeout(async () => {
153+
try {
154+
// Only proceed with server resize if dimensions actually changed significantly
155+
const rowDiff = Math.abs(size.rows - originalRows);
156+
const colDiff = Math.abs(size.cols - originalCols);
157+
158+
// If this is a minor resize (likely intermediate state), skip server update
159+
if (rowDiff < 2 && colDiff < 2 && resizeCount > 1) {
160+
console.log("Skipping minor resize to prevent instability");
161+
isResizing = false;
162+
resizeCount = 0;
163+
return;
164+
}
165+
166+
// Handle server resize
167+
if (this.serverMode) {
168+
await this.resizeTerminal(size.cols, size.rows);
169+
}
170+
171+
// Preserve scroll position for content-heavy terminals
172+
this.preserveViewportPosition(lastKnownScrollPosition);
173+
174+
// Update stored dimensions
175+
originalRows = size.rows;
176+
originalCols = size.cols;
177+
178+
// Mark resize as complete
179+
isResizing = false;
180+
resizeCount = 0;
181+
182+
// Notify touch selection if it exists
183+
if (this.touchSelection) {
184+
this.touchSelection.onTerminalResize(size);
185+
}
186+
} catch (error) {
187+
console.error("Resize handling failed:", error);
188+
isResizing = false;
189+
resizeCount = 0;
190+
}
191+
}, RESIZE_DEBOUNCE);
192+
});
193+
194+
// Also handle viewport changes for scroll position preservation
195+
this.terminal.onData(() => {
196+
// If we're not resizing and user types, everything is stable
197+
if (!isResizing && this.terminal.buffer && this.terminal.buffer.active) {
198+
lastKnownScrollPosition = this.terminal.buffer.active.viewportY;
199+
}
200+
});
201+
}
202+
203+
/**
204+
* Preserve viewport position during resize to prevent jumping
205+
*/
206+
preserveViewportPosition(targetScrollPosition) {
207+
if (!this.terminal.buffer || !this.terminal.buffer.active) return;
208+
209+
const buffer = this.terminal.buffer.active;
210+
const maxScroll = Math.max(0, buffer.length - this.terminal.rows);
211+
212+
// Ensure scroll position is within valid bounds
213+
const safeScrollPosition = Math.min(targetScrollPosition, maxScroll);
214+
215+
// Only adjust if we have significant content and the position is different
216+
if (
217+
buffer.length > this.terminal.rows &&
218+
Math.abs(buffer.viewportY - safeScrollPosition) > 2
219+
) {
220+
// Gradually adjust to prevent jarring movements
221+
const steps = 3;
222+
const diff = safeScrollPosition - buffer.viewportY;
223+
const stepSize = Math.ceil(Math.abs(diff) / steps);
224+
225+
let currentStep = 0;
226+
const adjustStep = () => {
227+
if (currentStep >= steps) return;
228+
229+
const currentPos = buffer.viewportY;
230+
const remaining = safeScrollPosition - currentPos;
231+
const adjustment =
232+
Math.sign(remaining) * Math.min(stepSize, Math.abs(remaining));
233+
234+
if (Math.abs(adjustment) >= 1) {
235+
this.terminal.scrollLines(adjustment);
236+
}
237+
238+
currentStep++;
239+
if (currentStep < steps && Math.abs(remaining) > 1) {
240+
setTimeout(adjustStep, 50);
241+
}
242+
};
243+
244+
setTimeout(adjustStep, 100);
245+
}
246+
}
247+
248+
/**
249+
* Setup touch selection for mobile devices
250+
*/
251+
setupTouchSelection() {
252+
// Only initialize touch selection on mobile devices
253+
if (window.cordova && this.container) {
254+
const terminalSettings = getTerminalSettings();
255+
this.touchSelection = new TerminalTouchSelection(
256+
this.terminal,
257+
this.container,
258+
{
259+
tapHoldDuration:
260+
terminalSettings.touchSelectionTapHoldDuration || 600,
261+
moveThreshold: terminalSettings.touchSelectionMoveThreshold || 8,
262+
handleSize: terminalSettings.touchSelectionHandleSize || 24,
263+
hapticFeedback:
264+
terminalSettings.touchSelectionHapticFeedback !== false,
265+
showContextMenu:
266+
terminalSettings.touchSelectionShowContextMenu !== false,
267+
},
268+
);
269+
}
270+
}
271+
123272
/**
124273
* Setup copy/paste keyboard handlers
125274
*/
@@ -250,6 +399,9 @@ export default class TerminalComponent {
250399
setTimeout(() => {
251400
this.fitAddon.fit();
252401
this.terminal.focus();
402+
403+
// Initialize touch selection after terminal is mounted
404+
this.setupTouchSelection();
253405
}, 10);
254406
} catch (error) {
255407
console.error("Failed to mount terminal:", error);
@@ -613,6 +765,12 @@ export default class TerminalComponent {
613765
dispose() {
614766
this.terminate();
615767

768+
// Dispose touch selection
769+
if (this.touchSelection) {
770+
this.touchSelection.destroy();
771+
this.touchSelection = null;
772+
}
773+
616774
// Dispose addons
617775
this.disposeImageAddon();
618776
this.disposeLigaturesAddon();

src/components/terminal/terminalDefaults.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export const DEFAULT_TERMINAL_SETTINGS = {
1414
letterSpacing: 0,
1515
imageSupport: false,
1616
fontLigatures: false,
17+
// Touch selection settings
18+
touchSelectionTapHoldDuration: 600,
19+
touchSelectionMoveThreshold: 8,
20+
touchSelectionHandleSize: 24,
21+
touchSelectionHapticFeedback: true,
22+
touchSelectionShowContextMenu: true,
1723
};
1824

1925
export function getTerminalSettings() {

src/components/terminal/terminalManager.js

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,53 @@ class TerminalManager {
262262
this.closeTerminal(terminalId);
263263
};
264264

265-
// Handle window resize
266-
const resizeObserver = new ResizeObserver(() => {
267-
setTimeout(() => {
268-
terminalComponent.fit();
269-
}, 100);
265+
// Enhanced resize handling with debouncing
266+
let resizeTimeout = null;
267+
const RESIZE_DEBOUNCE = 200;
268+
let lastResizeTime = 0;
269+
270+
const resizeObserver = new ResizeObserver((entries) => {
271+
const now = Date.now();
272+
273+
// Clear any pending resize
274+
if (resizeTimeout) {
275+
clearTimeout(resizeTimeout);
276+
}
277+
278+
// Debounce rapid resize events (common during keyboard open/close)
279+
resizeTimeout = setTimeout(() => {
280+
try {
281+
// Check if terminal is still available and mounted
282+
if (!terminalComponent.terminal || !terminalComponent.container) {
283+
return;
284+
}
285+
286+
// Get current terminal state
287+
const currentRows = terminalComponent.terminal.rows;
288+
const currentCols = terminalComponent.terminal.cols;
289+
290+
// Fit the terminal to new container size
291+
terminalComponent.fit();
292+
293+
// Check if dimensions actually changed after fit
294+
const newRows = terminalComponent.terminal.rows;
295+
const newCols = terminalComponent.terminal.cols;
296+
297+
if (
298+
Math.abs(newRows - currentRows) > 1 ||
299+
Math.abs(newCols - currentCols) > 1
300+
) {
301+
// console.log(
302+
// `Terminal ${terminalId} resized: ${currentRows}x${currentCols} -> ${newRows}x${newCols}`,
303+
// );
304+
}
305+
306+
// Update last resize time
307+
lastResizeTime = now;
308+
} catch (error) {
309+
console.error(`Resize error for terminal ${terminalId}:`, error);
310+
}
311+
}, RESIZE_DEBOUNCE);
270312
});
271313

272314
// Wait for the terminal container to be available, then observe it
@@ -415,6 +457,78 @@ class TerminalManager {
415457
serverMode: true,
416458
});
417459
}
460+
461+
/**
462+
* Handle keyboard resize events for all terminals
463+
* This is called when the virtual keyboard opens/closes on mobile
464+
*/
465+
handleKeyboardResize() {
466+
// Add a small delay to let the UI settle
467+
setTimeout(() => {
468+
this.terminals.forEach((terminal) => {
469+
try {
470+
if (terminal.component && terminal.component.terminal) {
471+
// Force a re-fit for all terminals
472+
terminal.component.fit();
473+
474+
// If terminal has lots of content, try to preserve scroll position
475+
const buffer = terminal.component.terminal.buffer?.active;
476+
if (
477+
buffer &&
478+
buffer.length > terminal.component.terminal.rows * 2
479+
) {
480+
// For content-heavy terminals, ensure we stay near the bottom if we were there
481+
const wasNearBottom =
482+
buffer.viewportY >=
483+
buffer.length - terminal.component.terminal.rows - 5;
484+
if (wasNearBottom) {
485+
// Scroll to bottom after resize
486+
setTimeout(() => {
487+
terminal.component.terminal.scrollToBottom();
488+
}, 100);
489+
}
490+
}
491+
}
492+
} catch (error) {
493+
console.error(
494+
`Error handling keyboard resize for terminal ${terminal.id}:`,
495+
error,
496+
);
497+
}
498+
});
499+
}, 150);
500+
}
501+
502+
/**
503+
* Stabilize terminal viewport after resize operations
504+
*/
505+
stabilizeTerminals() {
506+
this.terminals.forEach((terminal) => {
507+
try {
508+
if (terminal.component && terminal.component.terminal) {
509+
// Clear any touch selections during stabilization
510+
if (
511+
terminal.component.touchSelection &&
512+
terminal.component.touchSelection.isSelecting
513+
) {
514+
terminal.component.touchSelection.clearSelection();
515+
}
516+
517+
// Re-fit and refresh
518+
terminal.component.fit();
519+
520+
// Focus the active terminal to ensure proper state
521+
if (terminal.file && terminal.file.isOpen) {
522+
setTimeout(() => {
523+
terminal.component.focus();
524+
}, 50);
525+
}
526+
}
527+
} catch (error) {
528+
console.error(`Error stabilizing terminal ${terminal.id}:`, error);
529+
}
530+
});
531+
}
418532
}
419533

420534
// Create singleton instance

0 commit comments

Comments
 (0)