mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 10:02:57 +08:00
The custom mobile text-selection layer positioned the start handle at `x - HANDLE_SIZE_PX / 2`, so a selection beginning at column 0 placed it at ~-11px. The overlay clips with overflow:hidden, leaving only a sliver against the screen edge that was impossible to grab — making it hard to adjust the selection start point on the left edge. Clamp each handle's horizontal position to stay fully within the terminal area. This is visual only; drag tracking uses the finger's coordinates, so selection accuracy is unchanged. Also protects the end handle from being clipped at the right edge.
1050 lines
30 KiB
TypeScript
1050 lines
30 KiB
TypeScript
import type { IDisposable, Terminal } from '@xterm/xterm';
|
|
|
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
|
|
type TerminalCoords = {
|
|
col: number;
|
|
row: number;
|
|
};
|
|
|
|
type TouchCoords = {
|
|
clientX: number;
|
|
clientY: number;
|
|
};
|
|
|
|
type CellDimensions = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type DragHandle = 'start' | 'end';
|
|
|
|
type TerminalWithRenderService = Terminal & {
|
|
_core?: {
|
|
_renderService?: {
|
|
dimensions?: {
|
|
css?: {
|
|
cell?: {
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
export type MobileTerminalSelectionManager = {
|
|
dispose: () => void;
|
|
updateHandles: () => void;
|
|
};
|
|
|
|
const LONG_PRESS_MS = 600;
|
|
const MOVE_THRESHOLD_PX = 8;
|
|
const HANDLE_SIZE_PX = 22;
|
|
const FINGER_OFFSET_PX = 40;
|
|
const CONTEXT_MENU_GAP_PX = 12;
|
|
const CONTEXT_MENU_EDGE_PADDING_PX = 8;
|
|
const ZOOM_THROTTLE_MS = 50;
|
|
const DEFAULT_MIN_FONT_SIZE = 8;
|
|
const DEFAULT_MAX_FONT_SIZE = 48;
|
|
// xterm scrolls the viewport 1:1 with the finger and never coasts, so we add
|
|
// our own inertial (fling) scrolling: track finger velocity during the drag and
|
|
// keep scrolling with friction after release.
|
|
const SCROLL_INERTIA_FRICTION = 0.95; // velocity multiplier per ~16ms frame
|
|
const SCROLL_INERTIA_MIN_VELOCITY = 0.02; // px/ms below which coasting stops
|
|
const SCROLL_INERTIA_MAX_VELOCITY = 5; // px/ms cap to avoid runaway flings
|
|
const SCROLL_INERTIA_MAX_IDLE_MS = 90; // ignore a flick if the finger paused before lifting
|
|
|
|
type ContextMenuItem = {
|
|
label: string;
|
|
action: () => void;
|
|
};
|
|
|
|
export type MobileTerminalSelectionOptions = {
|
|
minFontSize?: number;
|
|
maxFontSize?: number;
|
|
onFontSizeChange?: (fontSize: number) => void;
|
|
};
|
|
|
|
function isTouchSelectionEnvironment(): boolean {
|
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
navigator.maxTouchPoints > 0 ||
|
|
'ontouchstart' in window ||
|
|
window.matchMedia?.('(pointer: coarse)').matches === true
|
|
);
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function getDistance(start: TouchCoords, end: TouchCoords): number {
|
|
return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY);
|
|
}
|
|
|
|
class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
|
|
private readonly terminal: Terminal;
|
|
private readonly terminalContent: HTMLElement;
|
|
private readonly overlay: HTMLDivElement;
|
|
private readonly startHandle: HTMLDivElement;
|
|
private readonly endHandle: HTMLDivElement;
|
|
private readonly contextMenu: HTMLDivElement;
|
|
private readonly disposables: IDisposable[] = [];
|
|
private readonly originalPosition: string;
|
|
|
|
private didSetPosition = false;
|
|
private isDestroyed = false;
|
|
private isSelecting = false;
|
|
private isHandleDragging = false;
|
|
private dragHandle: DragHandle | null = null;
|
|
private selectionStart: TerminalCoords | null = null;
|
|
private selectionEnd: TerminalCoords | null = null;
|
|
private touchStart: TouchCoords | null = null;
|
|
private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null;
|
|
private tapHoldTimeout: number | null = null;
|
|
private cellDimensions: CellDimensions = { width: 0, height: 0 };
|
|
private isContextMenuVisible = false;
|
|
|
|
private readonly minFontSize: number;
|
|
private readonly maxFontSize: number;
|
|
private readonly onFontSizeChange: (fontSize: number) => void;
|
|
private isPinching = false;
|
|
private pinchStartDistance = 0;
|
|
private initialFontSize = 0;
|
|
private lastZoomTime = 0;
|
|
|
|
private viewportElement: HTMLElement | null = null;
|
|
private lastScrollTouchY: number | null = null;
|
|
private lastScrollTouchTime = 0;
|
|
private scrollVelocity = 0;
|
|
private inertiaFrame: number | null = null;
|
|
|
|
constructor(
|
|
terminal: Terminal,
|
|
terminalContent: HTMLElement,
|
|
options: MobileTerminalSelectionOptions = {},
|
|
) {
|
|
this.terminal = terminal;
|
|
this.terminalContent = terminalContent;
|
|
this.originalPosition = terminalContent.style.position;
|
|
|
|
const minFontSize = Number(options.minFontSize) || DEFAULT_MIN_FONT_SIZE;
|
|
const maxFontSize = Number(options.maxFontSize) || DEFAULT_MAX_FONT_SIZE;
|
|
this.minFontSize = Math.min(minFontSize, maxFontSize);
|
|
this.maxFontSize = Math.max(minFontSize, maxFontSize);
|
|
this.onFontSizeChange =
|
|
options.onFontSizeChange ??
|
|
((fontSize) => {
|
|
this.terminal.options.fontSize = fontSize;
|
|
this.terminal.refresh(0, this.terminal.rows - 1);
|
|
});
|
|
|
|
if (window.getComputedStyle(terminalContent).position === 'static') {
|
|
terminalContent.style.position = 'relative';
|
|
this.didSetPosition = true;
|
|
}
|
|
|
|
this.overlay = this.createSelectionOverlay();
|
|
this.startHandle = this.createHandle('start');
|
|
this.endHandle = this.createHandle('end');
|
|
this.contextMenu = this.createContextMenu();
|
|
this.overlay.append(this.startHandle, this.endHandle, this.contextMenu);
|
|
this.terminalContent.appendChild(this.overlay);
|
|
|
|
this.attachEventListeners();
|
|
this.updateCellDimensions();
|
|
}
|
|
|
|
private createSelectionOverlay(): HTMLDivElement {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'shell-mobile-selection-overlay';
|
|
overlay.style.position = 'absolute';
|
|
overlay.style.inset = '0';
|
|
overlay.style.overflow = 'hidden';
|
|
overlay.style.pointerEvents = 'none';
|
|
overlay.style.zIndex = '30';
|
|
return overlay;
|
|
}
|
|
|
|
private createHandle(type: DragHandle): HTMLDivElement {
|
|
const handle = document.createElement('div');
|
|
handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`;
|
|
handle.dataset.handleType = type;
|
|
handle.style.position = 'absolute';
|
|
handle.style.width = `${HANDLE_SIZE_PX}px`;
|
|
handle.style.height = `${HANDLE_SIZE_PX}px`;
|
|
handle.style.borderRadius = '50%';
|
|
handle.style.background = '#3b82f6';
|
|
handle.style.border = '2px solid #fff';
|
|
handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
|
handle.style.display = 'none';
|
|
handle.style.pointerEvents = 'auto';
|
|
handle.style.touchAction = 'none';
|
|
handle.style.zIndex = '31';
|
|
return handle;
|
|
}
|
|
|
|
private createContextMenu(): HTMLDivElement {
|
|
const menu = document.createElement('div');
|
|
menu.className = 'shell-mobile-selection-menu';
|
|
menu.style.position = 'absolute';
|
|
menu.style.display = 'none';
|
|
menu.style.alignItems = 'stretch';
|
|
menu.style.padding = '4px';
|
|
menu.style.gap = '2px';
|
|
menu.style.background = '#1f2937';
|
|
menu.style.border = '1px solid rgba(255,255,255,0.12)';
|
|
menu.style.borderRadius = '10px';
|
|
menu.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)';
|
|
menu.style.pointerEvents = 'auto';
|
|
menu.style.touchAction = 'none';
|
|
menu.style.zIndex = '32';
|
|
menu.style.whiteSpace = 'nowrap';
|
|
menu.style.userSelect = 'none';
|
|
|
|
const items: ContextMenuItem[] = [
|
|
{ label: 'Copy', action: () => this.copySelection() },
|
|
{ label: 'Select All', action: () => this.selectAllText() },
|
|
];
|
|
|
|
for (const item of items) {
|
|
menu.appendChild(this.createContextMenuButton(item));
|
|
}
|
|
|
|
return menu;
|
|
}
|
|
|
|
private createContextMenuButton(item: ContextMenuItem): HTMLButtonElement {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = item.label;
|
|
button.style.appearance = 'none';
|
|
button.style.border = 'none';
|
|
button.style.margin = '0';
|
|
button.style.padding = '8px 14px';
|
|
button.style.background = 'transparent';
|
|
button.style.color = '#f9fafb';
|
|
button.style.fontSize = '14px';
|
|
button.style.fontFamily = 'inherit';
|
|
button.style.lineHeight = '1';
|
|
button.style.borderRadius = '6px';
|
|
button.style.cursor = 'pointer';
|
|
button.style.pointerEvents = 'auto';
|
|
button.style.touchAction = 'none';
|
|
|
|
let actionExecuted = false;
|
|
const arm = (event: Event): void => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
actionExecuted = false;
|
|
};
|
|
const run = (event: Event): void => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (actionExecuted) {
|
|
return;
|
|
}
|
|
actionExecuted = true;
|
|
item.action();
|
|
};
|
|
|
|
button.addEventListener('touchstart', arm, { passive: false });
|
|
button.addEventListener('touchend', run, { passive: false });
|
|
button.addEventListener('mousedown', arm);
|
|
button.addEventListener('mouseup', run);
|
|
button.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
|
|
return button;
|
|
}
|
|
|
|
private attachEventListeners(): void {
|
|
if (!this.terminal.element) {
|
|
return;
|
|
}
|
|
|
|
this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, {
|
|
passive: false,
|
|
});
|
|
this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, {
|
|
passive: false,
|
|
});
|
|
this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, {
|
|
passive: false,
|
|
});
|
|
this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, {
|
|
passive: false,
|
|
});
|
|
|
|
this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
|
|
this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
|
|
this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
|
|
this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
|
|
|
|
this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
|
|
this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
|
|
this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
|
|
this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
|
|
|
|
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true });
|
|
|
|
this.disposables.push(
|
|
this.terminal.onSelectionChange(this.onSelectionChange),
|
|
this.terminal.onResize(this.onTerminalResize),
|
|
this.terminal.onScroll(this.onTerminalScroll),
|
|
);
|
|
}
|
|
|
|
private onTerminalTouchStart = (event: TouchEvent): void => {
|
|
this.cancelInertia();
|
|
this.resetScrollTracking();
|
|
|
|
if (event.touches.length === 2) {
|
|
event.preventDefault();
|
|
this.startPinchZoom(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length !== 1) {
|
|
this.clearTapHoldTimeout();
|
|
return;
|
|
}
|
|
|
|
const touch = this.toTouchCoords(event.touches[0]);
|
|
this.touchStart = touch;
|
|
|
|
if (this.isSelecting) {
|
|
this.pendingClearTouch = { point: touch, moved: false };
|
|
return;
|
|
}
|
|
|
|
this.clearTapHoldTimeout();
|
|
this.tapHoldTimeout = window.setTimeout(() => {
|
|
this.tapHoldTimeout = null;
|
|
this.startSelection(touch);
|
|
}, LONG_PRESS_MS);
|
|
};
|
|
|
|
private onTerminalTouchMove = (event: TouchEvent): void => {
|
|
if (event.touches.length === 2 && this.isPinching) {
|
|
event.preventDefault();
|
|
this.handlePinchZoom(event);
|
|
return;
|
|
}
|
|
|
|
if (event.touches.length !== 1) {
|
|
this.clearTapHoldTimeout();
|
|
return;
|
|
}
|
|
|
|
if (this.isPinching) {
|
|
return;
|
|
}
|
|
|
|
const touch = this.toTouchCoords(event.touches[0]);
|
|
const touchStart = this.touchStart;
|
|
|
|
if (this.pendingClearTouch) {
|
|
this.pendingClearTouch.moved =
|
|
this.pendingClearTouch.moved ||
|
|
getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX;
|
|
return;
|
|
}
|
|
|
|
if (!touchStart) {
|
|
return;
|
|
}
|
|
|
|
const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX;
|
|
if (moved) {
|
|
this.clearTapHoldTimeout();
|
|
}
|
|
|
|
if (this.isSelecting && !this.isHandleDragging) {
|
|
event.preventDefault();
|
|
this.extendSelection(touch);
|
|
return;
|
|
}
|
|
|
|
// Plain one-finger scrolling: xterm moves the viewport itself; we only
|
|
// record the finger velocity so we can add inertia when the touch ends.
|
|
this.recordScrollSample(touch);
|
|
};
|
|
|
|
private onTerminalTouchEnd = (): void => {
|
|
if (this.isPinching) {
|
|
this.endPinchZoom();
|
|
return;
|
|
}
|
|
|
|
this.clearTapHoldTimeout();
|
|
this.touchStart = null;
|
|
|
|
if (!this.pendingClearTouch) {
|
|
this.maybeStartInertia();
|
|
return;
|
|
}
|
|
|
|
const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging;
|
|
this.pendingClearTouch = null;
|
|
|
|
if (shouldClear) {
|
|
this.clearSelection();
|
|
}
|
|
};
|
|
|
|
private onTerminalTouchCancel = (): void => {
|
|
if (this.isPinching) {
|
|
this.endPinchZoom();
|
|
}
|
|
|
|
this.clearTapHoldTimeout();
|
|
this.touchStart = null;
|
|
this.pendingClearTouch = null;
|
|
};
|
|
|
|
private onHandleTouchStart = (event: TouchEvent): void => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
|
|
const target = event.currentTarget as HTMLElement;
|
|
this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end';
|
|
this.isHandleDragging = true;
|
|
this.pendingClearTouch = null;
|
|
};
|
|
|
|
private onHandleTouchMove = (event: TouchEvent): void => {
|
|
if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const touch = this.toTouchCoords(event.touches[0]);
|
|
const adjustedTouch = {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY - FINGER_OFFSET_PX,
|
|
};
|
|
const coords = this.touchToTerminalCoords(adjustedTouch);
|
|
if (!coords) {
|
|
return;
|
|
}
|
|
|
|
if (this.dragHandle === 'start') {
|
|
this.selectionStart = coords;
|
|
} else {
|
|
this.selectionEnd = coords;
|
|
}
|
|
|
|
this.swapHandlesIfNeeded();
|
|
this.updateSelection();
|
|
};
|
|
|
|
private onHandleTouchEnd = (event: TouchEvent): void => {
|
|
if (!this.isHandleDragging) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.isHandleDragging = false;
|
|
this.dragHandle = null;
|
|
};
|
|
|
|
private onSelectionChange = (): void => {
|
|
if (!this.isSelecting) {
|
|
return;
|
|
}
|
|
|
|
if (!this.terminal.hasSelection()) {
|
|
this.resetSelectionState();
|
|
return;
|
|
}
|
|
|
|
this.updateHandles();
|
|
};
|
|
|
|
private onTerminalResize = (): void => {
|
|
this.updateCellDimensions();
|
|
this.updateHandles();
|
|
};
|
|
|
|
private onTerminalScroll = (): void => {
|
|
this.updateHandles();
|
|
};
|
|
|
|
private onDocumentTouchStart = (event: TouchEvent): void => {
|
|
if (!this.isSelecting || !event.target) {
|
|
return;
|
|
}
|
|
|
|
if (this.terminalContent.contains(event.target as Node)) {
|
|
return;
|
|
}
|
|
|
|
this.clearSelection();
|
|
};
|
|
|
|
private startSelection(touch: TouchCoords): void {
|
|
const coords = this.touchToTerminalCoords(touch);
|
|
if (!coords) {
|
|
return;
|
|
}
|
|
|
|
const wordBounds = this.getWordBoundsAt(coords);
|
|
this.selectionStart = wordBounds?.start ?? coords;
|
|
this.selectionEnd = wordBounds?.end ?? coords;
|
|
this.isSelecting = true;
|
|
|
|
this.updateSelection();
|
|
this.showHandles();
|
|
this.showContextMenu();
|
|
}
|
|
|
|
private extendSelection(touch: TouchCoords): void {
|
|
const coords = this.touchToTerminalCoords(touch);
|
|
if (!coords) {
|
|
return;
|
|
}
|
|
|
|
this.selectionEnd = coords;
|
|
this.updateSelection();
|
|
}
|
|
|
|
private updateSelection(): void {
|
|
if (!this.selectionStart || !this.selectionEnd) {
|
|
return;
|
|
}
|
|
|
|
const { start, end } = this.getOrderedSelection();
|
|
const length = this.calculateSelectionLength(start, end);
|
|
if (length <= 0) {
|
|
return;
|
|
}
|
|
|
|
this.terminal.select(start.col, start.row, length);
|
|
this.updateHandles();
|
|
}
|
|
|
|
private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number {
|
|
if (start.row === end.row) {
|
|
return end.col - start.col + 1;
|
|
}
|
|
|
|
return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1;
|
|
}
|
|
|
|
private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } {
|
|
const start = this.selectionStart;
|
|
const end = this.selectionEnd;
|
|
if (!start || !end) {
|
|
throw new Error('Cannot order empty terminal selection');
|
|
}
|
|
|
|
if (start.row < end.row || (start.row === end.row && start.col <= end.col)) {
|
|
return { start, end };
|
|
}
|
|
|
|
return { start: end, end: start };
|
|
}
|
|
|
|
private swapHandlesIfNeeded(): void {
|
|
if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) {
|
|
return;
|
|
}
|
|
|
|
const { start, end } = this.getOrderedSelection();
|
|
if (start === this.selectionStart && end === this.selectionEnd) {
|
|
return;
|
|
}
|
|
|
|
this.selectionStart = start;
|
|
this.selectionEnd = end;
|
|
this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start';
|
|
}
|
|
|
|
private showHandles(): void {
|
|
this.startHandle.style.display = 'block';
|
|
this.endHandle.style.display = 'block';
|
|
this.updateHandles();
|
|
}
|
|
|
|
private hideHandles(): void {
|
|
this.startHandle.style.display = 'none';
|
|
this.endHandle.style.display = 'none';
|
|
}
|
|
|
|
private showContextMenu(): void {
|
|
this.contextMenu.style.display = 'flex';
|
|
this.isContextMenuVisible = true;
|
|
this.positionContextMenu();
|
|
}
|
|
|
|
private hideContextMenu(): void {
|
|
this.contextMenu.style.display = 'none';
|
|
this.isContextMenuVisible = false;
|
|
}
|
|
|
|
private positionContextMenu(): void {
|
|
if (!this.isContextMenuVisible) {
|
|
return;
|
|
}
|
|
|
|
const containerRect = this.terminalContent.getBoundingClientRect();
|
|
const menuWidth = this.contextMenu.offsetWidth || 0;
|
|
const menuHeight = this.contextMenu.offsetHeight || 0;
|
|
|
|
const ordered =
|
|
this.selectionStart && this.selectionEnd ? this.getOrderedSelection() : null;
|
|
const startPosition = ordered ? this.terminalCoordsToPixels(ordered.start) : null;
|
|
const endPosition = ordered ? this.terminalCoordsToPixels(ordered.end) : null;
|
|
|
|
let menuX: number;
|
|
let menuY: number;
|
|
|
|
if (startPosition || endPosition) {
|
|
const topY = Math.min(
|
|
startPosition?.y ?? endPosition!.y,
|
|
endPosition?.y ?? startPosition!.y,
|
|
);
|
|
const centerX =
|
|
startPosition && endPosition
|
|
? (startPosition.x + endPosition.x) / 2
|
|
: (startPosition ?? endPosition)!.x;
|
|
|
|
menuX = centerX - menuWidth / 2;
|
|
menuY = topY - menuHeight - CONTEXT_MENU_GAP_PX;
|
|
|
|
// Not enough room above the selection: drop below the handles instead.
|
|
if (menuY < CONTEXT_MENU_EDGE_PADDING_PX) {
|
|
const bottomY = Math.max(
|
|
startPosition?.y ?? endPosition!.y,
|
|
endPosition?.y ?? startPosition!.y,
|
|
);
|
|
menuY = bottomY + this.cellDimensions.height + HANDLE_SIZE_PX + CONTEXT_MENU_GAP_PX;
|
|
}
|
|
} else {
|
|
// Whole-buffer selection (Select All): pin to the bottom center.
|
|
menuX = (containerRect.width - menuWidth) / 2;
|
|
menuY = containerRect.height - menuHeight - CONTEXT_MENU_GAP_PX;
|
|
}
|
|
|
|
const maxX = containerRect.width - menuWidth - CONTEXT_MENU_EDGE_PADDING_PX;
|
|
const maxY = containerRect.height - menuHeight - CONTEXT_MENU_EDGE_PADDING_PX;
|
|
menuX = clamp(menuX, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxX));
|
|
menuY = clamp(menuY, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxY));
|
|
|
|
this.contextMenu.style.left = `${menuX}px`;
|
|
this.contextMenu.style.top = `${menuY}px`;
|
|
}
|
|
|
|
private copySelection(): void {
|
|
const selectionText = this.terminal.getSelection();
|
|
if (selectionText) {
|
|
void copyTextToClipboard(selectionText);
|
|
}
|
|
this.clearSelection();
|
|
}
|
|
|
|
private selectAllText(): void {
|
|
this.terminal.selectAll();
|
|
this.selectionStart = null;
|
|
this.selectionEnd = null;
|
|
this.isSelecting = true;
|
|
this.hideHandles();
|
|
|
|
if (this.terminal.hasSelection()) {
|
|
this.showContextMenu();
|
|
} else {
|
|
this.clearSelection();
|
|
}
|
|
}
|
|
|
|
private startPinchZoom(event: TouchEvent): void {
|
|
if (event.touches.length !== 2) {
|
|
return;
|
|
}
|
|
|
|
this.clearTapHoldTimeout();
|
|
if (this.isSelecting) {
|
|
this.clearSelection();
|
|
}
|
|
|
|
this.isPinching = true;
|
|
this.initialFontSize = this.terminal.options.fontSize ?? DEFAULT_MIN_FONT_SIZE;
|
|
this.pinchStartDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
|
|
this.lastZoomTime = 0;
|
|
}
|
|
|
|
private handlePinchZoom(event: TouchEvent): void {
|
|
if (!this.isPinching || event.touches.length !== 2 || this.pinchStartDistance <= 0) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - this.lastZoomTime < ZOOM_THROTTLE_MS) {
|
|
return;
|
|
}
|
|
this.lastZoomTime = now;
|
|
|
|
const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
|
|
const scale = currentDistance / this.pinchStartDistance;
|
|
const nextFontSize = clamp(
|
|
Math.round(this.initialFontSize * scale),
|
|
this.minFontSize,
|
|
this.maxFontSize,
|
|
);
|
|
|
|
if (nextFontSize !== this.terminal.options.fontSize) {
|
|
this.onFontSizeChange(nextFontSize);
|
|
}
|
|
}
|
|
|
|
private endPinchZoom(): void {
|
|
this.isPinching = false;
|
|
this.pinchStartDistance = 0;
|
|
this.initialFontSize = 0;
|
|
}
|
|
|
|
private getTouchDistance(first: Touch, second: Touch): number {
|
|
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
|
}
|
|
|
|
private getViewportElement(): HTMLElement | null {
|
|
if (this.viewportElement?.isConnected) {
|
|
return this.viewportElement;
|
|
}
|
|
|
|
this.viewportElement =
|
|
this.terminal.element?.querySelector<HTMLElement>('.xterm-viewport') ?? null;
|
|
return this.viewportElement;
|
|
}
|
|
|
|
private resetScrollTracking(): void {
|
|
this.lastScrollTouchY = null;
|
|
this.lastScrollTouchTime = 0;
|
|
this.scrollVelocity = 0;
|
|
}
|
|
|
|
private recordScrollSample(touch: TouchCoords): void {
|
|
const now = performance.now();
|
|
|
|
if (this.lastScrollTouchY !== null) {
|
|
const dt = now - this.lastScrollTouchTime;
|
|
if (dt > 0) {
|
|
// Positive when the finger moves up, matching how xterm increases
|
|
// scrollTop, so the inertia continues in the same direction.
|
|
const velocity = (this.lastScrollTouchY - touch.clientY) / dt;
|
|
this.scrollVelocity = this.scrollVelocity * 0.4 + velocity * 0.6;
|
|
}
|
|
}
|
|
|
|
this.lastScrollTouchY = touch.clientY;
|
|
this.lastScrollTouchTime = now;
|
|
}
|
|
|
|
private maybeStartInertia(): void {
|
|
if (this.isSelecting || this.isHandleDragging || this.isPinching) {
|
|
return;
|
|
}
|
|
|
|
const idle = performance.now() - this.lastScrollTouchTime;
|
|
if (idle > SCROLL_INERTIA_MAX_IDLE_MS) {
|
|
return;
|
|
}
|
|
|
|
if (Math.abs(this.scrollVelocity) < SCROLL_INERTIA_MIN_VELOCITY) {
|
|
return;
|
|
}
|
|
|
|
this.startInertia(this.scrollVelocity);
|
|
}
|
|
|
|
private startInertia(initialVelocity: number): void {
|
|
const viewport = this.getViewportElement();
|
|
if (!viewport) {
|
|
return;
|
|
}
|
|
|
|
this.cancelInertia();
|
|
|
|
let velocity = clamp(
|
|
initialVelocity,
|
|
-SCROLL_INERTIA_MAX_VELOCITY,
|
|
SCROLL_INERTIA_MAX_VELOCITY,
|
|
);
|
|
let lastFrame = performance.now();
|
|
|
|
const step = (now: number): void => {
|
|
const dt = Math.max(1, now - lastFrame);
|
|
lastFrame = now;
|
|
velocity *= Math.pow(SCROLL_INERTIA_FRICTION, dt / 16);
|
|
|
|
if (Math.abs(velocity) < SCROLL_INERTIA_MIN_VELOCITY) {
|
|
this.inertiaFrame = null;
|
|
return;
|
|
}
|
|
|
|
const before = viewport.scrollTop;
|
|
viewport.scrollTop = before + velocity * dt;
|
|
if (viewport.scrollTop === before) {
|
|
// Reached the top or bottom of the buffer.
|
|
this.inertiaFrame = null;
|
|
return;
|
|
}
|
|
|
|
this.inertiaFrame = window.requestAnimationFrame(step);
|
|
};
|
|
|
|
this.inertiaFrame = window.requestAnimationFrame(step);
|
|
}
|
|
|
|
private cancelInertia(): void {
|
|
if (this.inertiaFrame !== null) {
|
|
window.cancelAnimationFrame(this.inertiaFrame);
|
|
this.inertiaFrame = null;
|
|
}
|
|
}
|
|
|
|
updateHandles(): void {
|
|
if (this.isContextMenuVisible) {
|
|
this.positionContextMenu();
|
|
}
|
|
|
|
if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) {
|
|
this.hideHandles();
|
|
return;
|
|
}
|
|
|
|
const { start, end } = this.getOrderedSelection();
|
|
const startPosition = this.terminalCoordsToPixels(start);
|
|
const endPosition = this.terminalCoordsToPixels(end);
|
|
|
|
// Keep the full handle inside the overlay (which clips via overflow:hidden)
|
|
// so a selection that begins at column 0 doesn't leave the handle clipped
|
|
// off the left edge where it can't be tapped.
|
|
const maxHandleLeft = Math.max(0, this.terminalContent.clientWidth - HANDLE_SIZE_PX);
|
|
|
|
if (startPosition) {
|
|
this.startHandle.style.display = 'block';
|
|
this.startHandle.style.left = `${clamp(startPosition.x - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`;
|
|
this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`;
|
|
} else {
|
|
this.startHandle.style.display = 'none';
|
|
}
|
|
|
|
if (endPosition) {
|
|
this.endHandle.style.display = 'block';
|
|
this.endHandle.style.left = `${clamp(endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`;
|
|
this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`;
|
|
} else {
|
|
this.endHandle.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
private clearSelection(): void {
|
|
this.terminal.clearSelection();
|
|
this.resetSelectionState();
|
|
}
|
|
|
|
private resetSelectionState(): void {
|
|
this.isSelecting = false;
|
|
this.isHandleDragging = false;
|
|
this.dragHandle = null;
|
|
this.selectionStart = null;
|
|
this.selectionEnd = null;
|
|
this.pendingClearTouch = null;
|
|
this.touchStart = null;
|
|
this.hideHandles();
|
|
this.hideContextMenu();
|
|
this.clearTapHoldTimeout();
|
|
}
|
|
|
|
private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null {
|
|
const screenElement = this.getTerminalScreenElement();
|
|
if (!screenElement) {
|
|
return null;
|
|
}
|
|
|
|
const rect = screenElement.getBoundingClientRect();
|
|
const x = touch.clientX - rect.left;
|
|
const y = touch.clientY - rect.top;
|
|
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
|
|
return null;
|
|
}
|
|
|
|
this.updateCellDimensions();
|
|
if (!this.cellDimensions.width || !this.cellDimensions.height) {
|
|
return null;
|
|
}
|
|
|
|
const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1);
|
|
const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY;
|
|
|
|
return {
|
|
col,
|
|
row: Math.max(0, row),
|
|
};
|
|
}
|
|
|
|
private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null {
|
|
const screenElement = this.getTerminalScreenElement();
|
|
if (!screenElement) {
|
|
return null;
|
|
}
|
|
|
|
this.updateCellDimensions();
|
|
|
|
const visibleRow = coords.row - this.terminal.buffer.active.viewportY;
|
|
if (visibleRow < 0 || visibleRow >= this.terminal.rows) {
|
|
return null;
|
|
}
|
|
|
|
const screenRect = screenElement.getBoundingClientRect();
|
|
const containerRect = this.terminalContent.getBoundingClientRect();
|
|
|
|
return {
|
|
x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width,
|
|
y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height,
|
|
};
|
|
}
|
|
|
|
private updateCellDimensions(): void {
|
|
const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService
|
|
?.dimensions?.css?.cell;
|
|
if (renderCell?.width && renderCell.height) {
|
|
this.cellDimensions = {
|
|
width: renderCell.width,
|
|
height: renderCell.height,
|
|
};
|
|
return;
|
|
}
|
|
|
|
const screenElement = this.getTerminalScreenElement();
|
|
const rect = screenElement?.getBoundingClientRect();
|
|
if (!rect || !this.terminal.cols || !this.terminal.rows) {
|
|
this.cellDimensions = { width: 0, height: 0 };
|
|
return;
|
|
}
|
|
|
|
this.cellDimensions = {
|
|
width: rect.width / this.terminal.cols,
|
|
height: rect.height / this.terminal.rows,
|
|
};
|
|
}
|
|
|
|
private getWordBoundsAt(coords: TerminalCoords): {
|
|
start: TerminalCoords;
|
|
end: TerminalCoords;
|
|
} | null {
|
|
const line = this.terminal.buffer.active.getLine(coords.row);
|
|
if (!line) {
|
|
return null;
|
|
}
|
|
|
|
const lineText = line.translateToString(false);
|
|
if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) {
|
|
return null;
|
|
}
|
|
|
|
let startCol = coords.col;
|
|
let endCol = coords.col;
|
|
|
|
while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) {
|
|
startCol--;
|
|
}
|
|
|
|
while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) {
|
|
endCol++;
|
|
}
|
|
|
|
return {
|
|
start: { row: coords.row, col: startCol },
|
|
end: { row: coords.row, col: endCol },
|
|
};
|
|
}
|
|
|
|
private getTerminalScreenElement(): HTMLElement | null {
|
|
return (
|
|
this.terminal.element?.querySelector<HTMLElement>('.xterm-screen') ??
|
|
this.terminal.element ??
|
|
null
|
|
);
|
|
}
|
|
|
|
private toTouchCoords(touch: Touch): TouchCoords {
|
|
return {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
};
|
|
}
|
|
|
|
private clearTapHoldTimeout(): void {
|
|
if (this.tapHoldTimeout === null) {
|
|
return;
|
|
}
|
|
|
|
window.clearTimeout(this.tapHoldTimeout);
|
|
this.tapHoldTimeout = null;
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
this.isDestroyed = true;
|
|
this.clearTapHoldTimeout();
|
|
this.cancelInertia();
|
|
|
|
this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart);
|
|
this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove);
|
|
this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd);
|
|
this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel);
|
|
|
|
this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart);
|
|
this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove);
|
|
this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd);
|
|
this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
|
|
|
|
this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart);
|
|
this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove);
|
|
this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd);
|
|
this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
|
|
|
|
document.removeEventListener('touchstart', this.onDocumentTouchStart);
|
|
this.disposables.forEach((disposable) => disposable.dispose());
|
|
this.disposables.length = 0;
|
|
|
|
this.overlay.remove();
|
|
|
|
if (this.didSetPosition) {
|
|
this.terminalContent.style.position = this.originalPosition;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function installMobileTerminalSelection(
|
|
terminal: Terminal,
|
|
terminalContent: HTMLElement,
|
|
options: MobileTerminalSelectionOptions = {},
|
|
): MobileTerminalSelectionManager | null {
|
|
if (!isTouchSelectionEnvironment() || !terminal.element) {
|
|
return null;
|
|
}
|
|
|
|
return new ShellMobileSelectionCore(terminal, terminalContent, options);
|
|
}
|