mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 21:55:50 +08:00
fix(shell): support mobile terminal text selection
This commit is contained in:
@@ -4,13 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project } from '../../../types/app';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import {
|
||||
TERMINAL_INIT_DELAY_MS,
|
||||
TERMINAL_OPTIONS,
|
||||
TERMINAL_RESIZE_DELAY_MS,
|
||||
} from '../constants/constants';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import {
|
||||
installMobileTerminalSelection,
|
||||
type MobileTerminalSelectionManager,
|
||||
} from '../utils/mobileTerminalSelection';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||
|
||||
@@ -47,6 +52,7 @@ export function useShellTerminal({
|
||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const resizeTimeoutRef = useRef<number | null>(null);
|
||||
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
|
||||
@@ -64,6 +70,11 @@ export function useShellTerminal({
|
||||
}, [terminalRef]);
|
||||
|
||||
const disposeTerminal = useCallback(() => {
|
||||
if (mobileSelectionRef.current) {
|
||||
mobileSelectionRef.current.dispose();
|
||||
mobileSelectionRef.current = null;
|
||||
}
|
||||
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.dispose();
|
||||
terminalRef.current = null;
|
||||
@@ -74,7 +85,8 @@ export function useShellTerminal({
|
||||
}, [fitAddonRef, terminalRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||
const terminalContainer = terminalContainerRef.current;
|
||||
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +108,11 @@ export function useShellTerminal({
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
nextTerminal.open(terminalContainerRef.current);
|
||||
nextTerminal.open(terminalContainer);
|
||||
mobileSelectionRef.current = installMobileTerminalSelection(
|
||||
nextTerminal,
|
||||
terminalContainer,
|
||||
);
|
||||
|
||||
const copyTerminalSelection = async () => {
|
||||
const selection = nextTerminal.getSelection();
|
||||
@@ -127,7 +143,7 @@ export function useShellTerminal({
|
||||
void copyTextToClipboard(selection);
|
||||
};
|
||||
|
||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
||||
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
||||
|
||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||
if (
|
||||
@@ -214,10 +230,10 @@ export function useShellTerminal({
|
||||
}, TERMINAL_RESIZE_DELAY_MS);
|
||||
});
|
||||
|
||||
resizeObserver.observe(terminalContainerRef.current);
|
||||
resizeObserver.observe(terminalContainer);
|
||||
|
||||
return () => {
|
||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
||||
resizeObserver.disconnect();
|
||||
if (resizeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resizeTimeoutRef.current);
|
||||
@@ -233,6 +249,7 @@ export function useShellTerminal({
|
||||
fitAddonRef,
|
||||
isRestarting,
|
||||
hasSelectedProject,
|
||||
minimal,
|
||||
selectedProjectKey,
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
|
||||
637
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
637
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm';
|
||||
|
||||
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;
|
||||
|
||||
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 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 };
|
||||
|
||||
constructor(terminal: Terminal, terminalContent: HTMLElement) {
|
||||
this.terminal = terminal;
|
||||
this.terminalContent = terminalContent;
|
||||
this.originalPosition = terminalContent.style.position;
|
||||
|
||||
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.overlay.append(this.startHandle, this.endHandle);
|
||||
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 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 => {
|
||||
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 !== 1) {
|
||||
this.clearTapHoldTimeout();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
private onTerminalTouchEnd = (): void => {
|
||||
this.clearTapHoldTimeout();
|
||||
this.touchStart = null;
|
||||
|
||||
if (!this.pendingClearTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging;
|
||||
this.pendingClearTouch = null;
|
||||
|
||||
if (shouldClear) {
|
||||
this.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
private onTerminalTouchCancel = (): void => {
|
||||
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();
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
updateHandles(): void {
|
||||
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);
|
||||
|
||||
if (startPosition) {
|
||||
this.startHandle.style.display = 'block';
|
||||
this.startHandle.style.left = `${startPosition.x - HANDLE_SIZE_PX / 2}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 = `${endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2}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.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.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,
|
||||
): MobileTerminalSelectionManager | null {
|
||||
if (!isTouchSelectionEnvironment() || !terminal.element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ShellMobileSelectionCore(terminal, terminalContent);
|
||||
}
|
||||
Reference in New Issue
Block a user