mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Fix CJK IME issues: language detection, visible input, performance optimizations (#495)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
ab2e57ab05
commit
31e48b6674
15 changed files with 953 additions and 50 deletions
|
|
@ -34,6 +34,46 @@ SessionView
|
|||
|
||||
## Implementation Details
|
||||
|
||||
### Cursor Position Tracking
|
||||
|
||||
**File**: `cursor-position.ts`
|
||||
|
||||
The cursor position tracking system uses a shared utility function that works consistently across both terminal types (XTerm.js and binary buffer modes):
|
||||
|
||||
#### Coordinate System
|
||||
```typescript
|
||||
export function calculateCursorPosition(
|
||||
cursorX: number, // 0-based column position
|
||||
cursorY: number, // 0-based row position
|
||||
fontSize: number, // Terminal font size in pixels
|
||||
container: Element, // Terminal container element
|
||||
sessionStatus: string // Session status for validation
|
||||
): { x: number; y: number } | null
|
||||
```
|
||||
|
||||
#### Position Calculation Process
|
||||
1. **Character Measurement**: Dynamically measures actual character width using font metrics
|
||||
2. **Absolute Positioning**: Calculates page-absolute cursor coordinates
|
||||
3. **Container Relative**: Converts to position relative to `#session-terminal` container
|
||||
4. **IME Positioning**: Returns coordinates suitable for IME input placement
|
||||
|
||||
#### Terminal Type Support
|
||||
- **XTerm Terminal (`vibe-terminal`)**: Uses `terminal.buffer.active.cursorX/Y` from XTerm.js
|
||||
- **Binary Terminal (`vibe-terminal-binary`)**: Uses `buffer.cursorX/Y` from WebSocket buffer data
|
||||
|
||||
#### Key Features
|
||||
- **Precise Alignment**: Accounts for exact character width and line height
|
||||
- **Container Aware**: Handles side panels and complex layouts
|
||||
- **Font Responsive**: Adapts to different font sizes and families
|
||||
- **Platform Consistent**: Same calculation logic across all terminal types
|
||||
|
||||
#### Error Handling
|
||||
The function includes comprehensive error handling and graceful fallbacks:
|
||||
- Returns `null` when session is not running
|
||||
- Returns `null` when container element is not found
|
||||
- Returns `null` when character measurement fails
|
||||
- Falls back to absolute coordinates if session container is missing
|
||||
|
||||
### Platform Detection
|
||||
**File**: `mobile-utils.ts`
|
||||
|
||||
|
|
@ -244,6 +284,11 @@ OS shows IME candidates → User selects → Text appears in terminal
|
|||
## Code Reference
|
||||
|
||||
### Primary Files
|
||||
- `cursor-position.ts` - **Shared cursor position calculation**
|
||||
- `14-20` - Main `calculateCursorPosition()` function signature
|
||||
- `32-46` - Character width measurement using test elements
|
||||
- `48-69` - Coordinate conversion (absolute → container-relative)
|
||||
- `70-72` - Error handling and cleanup
|
||||
- `ime-input.ts` - Desktop IME component implementation
|
||||
- `32-48` - DesktopIMEInput class definition
|
||||
- `50-80` - Invisible input element creation
|
||||
|
|
@ -259,11 +304,13 @@ OS shows IME candidates → User selects → Text appears in terminal
|
|||
- `mobile-utils.ts` - Mobile detection utilities
|
||||
|
||||
### Supporting Files
|
||||
- `terminal.ts` - XTerm cursor position API via `getCursorInfo()`
|
||||
- `vibe-terminal-binary.ts` - Binary terminal cursor position API
|
||||
- `cursor-position.ts` - **Shared cursor position calculation utility**
|
||||
- `terminal.ts` - XTerm cursor position API via `getCursorInfo()` (uses shared utility)
|
||||
- `vibe-terminal-binary.ts` - Binary terminal cursor position API (uses shared utility)
|
||||
- `session-view.ts` - Container element and terminal integration
|
||||
- `lifecycle-event-manager.ts` - Event coordination and interception
|
||||
- `ime-constants.ts` - IME-related key filtering utilities
|
||||
- `terminal-constants.ts` - **Centralized terminal element IDs and selectors**
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
|
|
@ -309,7 +356,29 @@ Comprehensive logging available in browser console:
|
|||
|
||||
---
|
||||
|
||||
## Recent Improvements (v1.0.0-beta.16+)
|
||||
|
||||
### Unified Cursor Position Tracking
|
||||
- **Shared Utility**: Created `cursor-position.ts` for consistent cursor calculation across all terminal types
|
||||
- **Container-Aware Positioning**: Fixed IME positioning issues with side panels and complex layouts
|
||||
- **Precise Alignment**: Improved character width measurement for pixel-perfect cursor alignment
|
||||
- **Debug Logging**: Enhanced debug output with comprehensive coordinate information
|
||||
|
||||
### Technical Improvements
|
||||
- **Code Deduplication**: Eliminated ~120 lines of duplicate cursor calculation code
|
||||
- **Maintainability**: Single source of truth for cursor positioning logic
|
||||
- **Type Safety**: Improved TypeScript interfaces and error handling
|
||||
- **Performance**: More efficient coordinate conversion with optimized calculations
|
||||
|
||||
### Element ID Centralization
|
||||
- **Constants File**: Created `terminal-constants.ts` to centralize all critical terminal element IDs
|
||||
- **Prevention of Breakage**: Changes to IDs like `session-terminal`, `buffer-container`, or `terminal-container` now only require updates in one location
|
||||
- **Consistent References**: All components now import `TERMINAL_IDS` constants instead of using hardcoded strings
|
||||
- **Type Safety**: Constants are strongly typed to prevent typos and ensure consistent usage across the codebase
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Production Ready
|
||||
**Platforms**: Desktop (Windows, macOS, Linux) and Mobile (iOS, Android)
|
||||
**Version**: VibeTunnel Web v1.0.0-beta.15+
|
||||
**Last Updated**: 2025-01-22
|
||||
**Version**: VibeTunnel Web v1.0.0-beta.16+
|
||||
**Last Updated**: 2025-08-02
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
import { Z_INDEX } from '../utils/constants.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { IME_VERTICAL_OFFSET_PX, TERMINAL_FONT_FAMILY } from '../utils/terminal-constants.js';
|
||||
|
||||
const logger = createLogger('ime-input');
|
||||
|
||||
|
|
@ -23,6 +24,8 @@ export interface DesktopIMEInputOptions {
|
|||
onSpecialKey?: (key: string) => void;
|
||||
/** Optional callback to get cursor position for positioning the input */
|
||||
getCursorInfo?: () => { x: number; y: number } | null;
|
||||
/** Optional callback to get font size from terminal */
|
||||
getFontSize?: () => number;
|
||||
/** Whether to auto-focus the input on creation */
|
||||
autoFocus?: boolean;
|
||||
/** Additional class name for the input element */
|
||||
|
|
@ -52,22 +55,28 @@ export class DesktopIMEInput {
|
|||
private createInput(): HTMLInputElement {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
// Use a more standard IME input approach - always visible but positioned
|
||||
input.style.position = 'absolute';
|
||||
input.style.top = '0px';
|
||||
input.style.left = '0px';
|
||||
input.style.top = '-9999px'; // Start off-screen
|
||||
input.style.left = '-9999px';
|
||||
input.style.transform = 'none';
|
||||
input.style.width = '1px';
|
||||
input.style.height = '1px';
|
||||
input.style.fontSize = '16px';
|
||||
input.style.padding = '0';
|
||||
input.style.width = '200px'; // Fixed width for better IME compatibility
|
||||
input.style.height = '24px';
|
||||
// Use terminal font size if available, otherwise default to 14px
|
||||
const fontSize = this.options.getFontSize?.() || 14;
|
||||
input.style.fontSize = `${fontSize}px`;
|
||||
input.style.padding = '2px 4px';
|
||||
input.style.border = 'none';
|
||||
input.style.borderRadius = '0';
|
||||
input.style.backgroundColor = 'transparent';
|
||||
input.style.color = 'transparent';
|
||||
input.style.color = '#e2e8f0';
|
||||
input.style.zIndex = String(this.options.zIndex || Z_INDEX.IME_INPUT);
|
||||
input.style.opacity = '0';
|
||||
input.style.pointerEvents = 'none';
|
||||
input.placeholder = 'CJK Input';
|
||||
input.style.opacity = '1';
|
||||
input.style.visibility = 'visible';
|
||||
input.style.pointerEvents = 'auto';
|
||||
input.style.fontFamily = TERMINAL_FONT_FAMILY;
|
||||
input.style.outline = 'none';
|
||||
input.style.caretColor = 'transparent'; // Hide the blinking cursor
|
||||
input.autocapitalize = 'off';
|
||||
input.setAttribute('autocorrect', 'off');
|
||||
input.autocomplete = 'off';
|
||||
|
|
@ -136,12 +145,16 @@ export class DesktopIMEInput {
|
|||
private handleCompositionStart = () => {
|
||||
this.isComposing = true;
|
||||
document.body.setAttribute('data-ime-composing', 'true');
|
||||
// Keep input visible during composition
|
||||
this.showInput();
|
||||
this.updatePosition();
|
||||
logger.log('IME composition started');
|
||||
};
|
||||
|
||||
private handleCompositionUpdate = (e: CompositionEvent) => {
|
||||
logger.log('IME composition update:', e.data);
|
||||
// Update position during composition as well
|
||||
this.updatePosition();
|
||||
};
|
||||
|
||||
private handleCompositionEnd = (e: CompositionEvent) => {
|
||||
|
|
@ -155,6 +168,14 @@ export class DesktopIMEInput {
|
|||
|
||||
this.input.value = '';
|
||||
logger.log('IME composition ended:', finalText);
|
||||
|
||||
// Hide input after composition if not focused
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== this.input) {
|
||||
this.hideInput();
|
||||
}
|
||||
this.updatePosition();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
private handleInput = (e: Event) => {
|
||||
|
|
@ -170,6 +191,12 @@ export class DesktopIMEInput {
|
|||
if (text) {
|
||||
this.options.onTextInput(text);
|
||||
input.value = '';
|
||||
// Hide input after sending text if not focused
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== this.input) {
|
||||
this.hideInput();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -179,7 +206,7 @@ export class DesktopIMEInput {
|
|||
return;
|
||||
}
|
||||
|
||||
// During IME composition, let the browser handle ALL keys
|
||||
// During IME composition, let the browser handle ALL keys including Enter
|
||||
if (this.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -188,12 +215,16 @@ export class DesktopIMEInput {
|
|||
if (this.options.onSpecialKey) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this.input.value.trim()) {
|
||||
// Send the text content and clear input
|
||||
e.preventDefault();
|
||||
this.options.onTextInput(this.input.value);
|
||||
this.input.value = '';
|
||||
} else {
|
||||
// Send Enter key to terminal only if input is empty
|
||||
e.preventDefault();
|
||||
this.options.onSpecialKey('enter');
|
||||
}
|
||||
this.options.onSpecialKey('enter');
|
||||
break;
|
||||
case 'Backspace':
|
||||
if (!this.input.value) {
|
||||
|
|
@ -251,6 +282,9 @@ export class DesktopIMEInput {
|
|||
document.body.setAttribute('data-ime-input-focused', 'true');
|
||||
logger.log('IME input focused');
|
||||
|
||||
// Show the input when focused
|
||||
this.showInput();
|
||||
|
||||
// Start focus retention to prevent losing focus
|
||||
this.startFocusRetention();
|
||||
};
|
||||
|
|
@ -264,13 +298,30 @@ export class DesktopIMEInput {
|
|||
if (document.activeElement !== this.input) {
|
||||
document.body.removeAttribute('data-ime-input-focused');
|
||||
this.stopFocusRetention();
|
||||
// Hide the input when not focused and not composing
|
||||
if (!this.isComposing) {
|
||||
this.hideInput();
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
private showInput(): void {
|
||||
// Position will be updated by updatePosition()
|
||||
logger.log('IME input shown');
|
||||
}
|
||||
|
||||
private hideInput(): void {
|
||||
// Move input off-screen instead of hiding
|
||||
this.input.style.top = '-9999px';
|
||||
this.input.style.left = '-9999px';
|
||||
logger.log('IME input hidden');
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.options.getCursorInfo) {
|
||||
// Fallback to safe positioning when no cursor info provider
|
||||
logger.warn('No getCursorInfo callback provided, using fallback position');
|
||||
this.input.style.left = '10px';
|
||||
this.input.style.top = '10px';
|
||||
return;
|
||||
|
|
@ -279,21 +330,31 @@ export class DesktopIMEInput {
|
|||
const cursorInfo = this.options.getCursorInfo();
|
||||
if (!cursorInfo) {
|
||||
// Fallback to safe positioning when cursor info unavailable
|
||||
logger.warn('getCursorInfo returned null, using fallback position');
|
||||
this.input.style.left = '10px';
|
||||
this.input.style.top = '10px';
|
||||
return;
|
||||
}
|
||||
|
||||
// Position IME input at cursor location
|
||||
this.input.style.left = `${Math.max(10, cursorInfo.x)}px`;
|
||||
this.input.style.top = `${Math.max(10, cursorInfo.y)}px`;
|
||||
// Position IME input at cursor location with upward adjustment for better alignment
|
||||
const x = Math.max(10, cursorInfo.x);
|
||||
const y = Math.max(10, cursorInfo.y - IME_VERTICAL_OFFSET_PX);
|
||||
|
||||
logger.log(`Positioning CJK input at x=${x}, y=${y}`);
|
||||
this.input.style.left = `${x}px`;
|
||||
this.input.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
// Update position first to bring input into view
|
||||
this.updatePosition();
|
||||
this.showInput();
|
||||
|
||||
// Use immediate focus
|
||||
this.input.focus();
|
||||
|
||||
// Verify focus worked
|
||||
requestAnimationFrame(() => {
|
||||
this.input.focus();
|
||||
// If focus didn't work, try once more
|
||||
if (document.activeElement !== this.input) {
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement !== this.input) {
|
||||
|
|
@ -304,6 +365,24 @@ export class DesktopIMEInput {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the IME input position based on cursor location
|
||||
* Can be called externally when cursor moves
|
||||
*/
|
||||
refreshPosition(): void {
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the font size of the IME input
|
||||
* Should be called when terminal font size changes
|
||||
*/
|
||||
updateFontSize(): void {
|
||||
const fontSize = this.options.getFontSize?.() || 14;
|
||||
this.input.style.fontSize = `${fontSize}px`;
|
||||
logger.log(`Updated IME input font size to ${fontSize}px`);
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.input.blur();
|
||||
}
|
||||
|
|
@ -326,15 +405,11 @@ export class DesktopIMEInput {
|
|||
return;
|
||||
}
|
||||
|
||||
// Don't use aggressive focus retention - it interferes with IME
|
||||
// Just ensure focus stays during composition
|
||||
if (this.focusRetentionInterval) {
|
||||
clearInterval(this.focusRetentionInterval);
|
||||
}
|
||||
|
||||
this.focusRetentionInterval = setInterval(() => {
|
||||
if (document.activeElement !== this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}, 100) as unknown as number;
|
||||
}
|
||||
|
||||
private stopFocusRetention(): void {
|
||||
|
|
|
|||
|
|
@ -503,6 +503,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -535,6 +536,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file2,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -567,6 +569,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -596,6 +599,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -625,6 +629,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -647,6 +652,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
type: 'text/plain',
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -673,6 +679,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
@ -707,6 +714,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
{ kind: 'file', getAsFile: () => file2 },
|
||||
{ kind: 'file', getAsFile: () => file3 },
|
||||
],
|
||||
getData: () => '', // Return empty string for text data
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { fixture, html } from '@open-wc/testing';
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clickElement,
|
||||
pressKey,
|
||||
resetViewport,
|
||||
setupFetchMock,
|
||||
setViewport,
|
||||
|
|
@ -304,8 +303,10 @@ describe('SessionView', () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Simulate typing
|
||||
await pressKey(element, 'a');
|
||||
// Use the input manager directly instead of simulating keyboard events
|
||||
// biome-ignore lint/suspicious/noExplicitAny: need to access private property for testing
|
||||
const inputManager = (element as any).inputManager;
|
||||
await inputManager.sendInputText('a');
|
||||
|
||||
// Wait for async operation
|
||||
await waitForAsync();
|
||||
|
|
@ -325,8 +326,12 @@ describe('SessionView', () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Use the input manager directly instead of simulating keyboard events
|
||||
// biome-ignore lint/suspicious/noExplicitAny: need to access private property for testing
|
||||
const inputManager = (element as any).inputManager;
|
||||
|
||||
// Test Enter key
|
||||
await pressKey(element, 'Enter');
|
||||
await inputManager.sendInput('enter');
|
||||
await waitForAsync();
|
||||
expect(inputCapture).toHaveBeenCalledWith({ key: 'enter' });
|
||||
|
||||
|
|
@ -334,7 +339,7 @@ describe('SessionView', () => {
|
|||
inputCapture.mockClear();
|
||||
|
||||
// Test Escape key
|
||||
await pressKey(element, 'Escape');
|
||||
await inputManager.sendInput('escape');
|
||||
await waitForAsync();
|
||||
expect(inputCapture).toHaveBeenCalledWith({ key: 'escape' });
|
||||
});
|
||||
|
|
@ -870,8 +875,21 @@ describe('SessionView', () => {
|
|||
element.session = mockSession;
|
||||
await element.updateComplete;
|
||||
|
||||
// Press escape on exited session
|
||||
await pressKey(element, 'Escape');
|
||||
// Ensure we're in desktop mode by setting localStorage preference
|
||||
localStorage.setItem('touchKeyboardPreference', 'never');
|
||||
|
||||
// Force the lifecycle manager to re-evaluate mobile status
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await waitForAsync();
|
||||
|
||||
// Press escape on exited session - dispatch on document since lifecycle manager listens there
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
await waitForAsync();
|
||||
|
||||
expect(navigateHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import './worktree-manager.js';
|
|||
import { authClient } from '../services/auth-client.js';
|
||||
import { GitService } from '../services/git-service.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
||||
// Manager imports
|
||||
import { ConnectionManager } from './session-view/connection-manager.js';
|
||||
|
|
@ -1185,7 +1186,7 @@ export class SessionView extends LitElement {
|
|||
? html`
|
||||
<!-- Enhanced Terminal Component -->
|
||||
<terminal-renderer
|
||||
id="session-terminal"
|
||||
id="${TERMINAL_IDS.SESSION_TERMINAL}"
|
||||
.session=${this.session}
|
||||
.useBinaryMode=${uiState.useBinaryMode}
|
||||
.terminalFontSize=${uiState.terminalFontSize}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { consumeEvent } from '../../utils/event-utils.js';
|
|||
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
import { detectMobile } from '../../utils/mobile-utils.js';
|
||||
import { CJK_LANGUAGE_CODES, TERMINAL_IDS } from '../../utils/terminal-constants.js';
|
||||
import { DesktopIMEInput } from '../ime-input.js';
|
||||
import type { Terminal } from '../terminal.js';
|
||||
import type { VibeTerminalBinary } from '../vibe-terminal-binary.js';
|
||||
|
|
@ -32,6 +33,7 @@ export class InputManager {
|
|||
private lastEscapeTime = 0;
|
||||
private readonly DOUBLE_ESCAPE_THRESHOLD = 500; // ms
|
||||
private imeInput: DesktopIMEInput | null = null;
|
||||
private globalCompositionListener: ((e: CompositionEvent) => void) | null = null;
|
||||
|
||||
setSession(session: Session | null): void {
|
||||
// Clean up IME input when session is null
|
||||
|
|
@ -41,11 +43,16 @@ export class InputManager {
|
|||
|
||||
this.session = session;
|
||||
|
||||
// Setup IME input when session is available
|
||||
// Setup IME input when session is available and CJK language is active
|
||||
if (session && !this.imeInput) {
|
||||
this.setupIMEInput();
|
||||
}
|
||||
|
||||
// Set up global composition event listener to detect CJK input dynamically
|
||||
if (session && !detectMobile()) {
|
||||
this.setupGlobalCompositionListener();
|
||||
}
|
||||
|
||||
// Check URL parameter for WebSocket input feature flag
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const socketInputParam = urlParams.get('socket_input');
|
||||
|
|
@ -68,19 +75,114 @@ export class InputManager {
|
|||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private setupIMEInput(): void {
|
||||
/**
|
||||
* Check if a CJK (Chinese, Japanese, Korean) language is currently active
|
||||
* This detects both system language and input method editor (IME) state
|
||||
*/
|
||||
private isCJKLanguageActive(): boolean {
|
||||
// Check system/browser language first
|
||||
const languages = [navigator.language, ...(navigator.languages || [])];
|
||||
|
||||
// Check if any of the user's languages are CJK
|
||||
const hasCJKLanguage = languages.some((lang) =>
|
||||
CJK_LANGUAGE_CODES.some((cjkLang) => lang.toLowerCase().startsWith(cjkLang.toLowerCase()))
|
||||
);
|
||||
|
||||
if (hasCJKLanguage) {
|
||||
logger.log('CJK language detected in browser languages:', languages);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Additional check: look for common CJK input method indicators
|
||||
// This is more of a heuristic since there's no direct IME detection API
|
||||
const hasIMEKeyboard = this.hasIMEKeyboard();
|
||||
if (hasIMEKeyboard) {
|
||||
logger.log('IME keyboard detected, likely CJK input method');
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.log('No CJK language or IME detected', { languages, hasIMEKeyboard });
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic check for IME keyboard presence
|
||||
* This is not 100% reliable but provides a reasonable fallback
|
||||
*/
|
||||
private hasIMEKeyboard(): boolean {
|
||||
// Check for composition events support (indicates IME capability)
|
||||
if (!('CompositionEvent' in window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the virtual keyboard API indicates composition support
|
||||
if ('virtualKeyboard' in navigator) {
|
||||
try {
|
||||
const nav = navigator as Navigator & { virtualKeyboard?: { overlaysContent?: boolean } };
|
||||
const vk = nav.virtualKeyboard;
|
||||
// Some IME keyboards set overlaysContent to true
|
||||
if (vk && vk.overlaysContent !== undefined) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors accessing virtual keyboard API
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: assume IME is possible if composition events are supported
|
||||
// and we're on a platform that commonly uses IME
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isCommonIMEPlatform =
|
||||
userAgent.includes('windows') || userAgent.includes('mac') || userAgent.includes('linux');
|
||||
|
||||
return isCommonIMEPlatform;
|
||||
}
|
||||
|
||||
private setupIMEInput(retryCount = 0): void {
|
||||
const MAX_RETRIES = 10;
|
||||
const IME_SETUP_RETRY_DELAY_MS = 100;
|
||||
|
||||
// Skip IME input setup on mobile devices (they have their own IME handling)
|
||||
if (detectMobile()) {
|
||||
console.log('🔍 Skipping IME input setup on mobile device');
|
||||
logger.log('Skipping IME input setup on mobile device');
|
||||
return;
|
||||
}
|
||||
console.log('🔍 Setting up IME input on desktop device');
|
||||
|
||||
// Skip if IME input already exists
|
||||
if (this.imeInput) {
|
||||
logger.log('IME input already exists, skipping setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only enable IME input for CJK languages
|
||||
if (!this.isCJKLanguageActive()) {
|
||||
logger.log('Skipping IME input setup - no CJK language detected');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('Setting up IME input on desktop device for CJK language');
|
||||
|
||||
// Check if terminal element exists first - if not, defer setup
|
||||
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||
if (!terminalElement) {
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
logger.error('Failed to setup IME after maximum retries');
|
||||
return;
|
||||
}
|
||||
logger.log(
|
||||
`Terminal element not ready yet, deferring IME setup (retry ${retryCount + 1}/${MAX_RETRIES})`
|
||||
);
|
||||
// Retry after a short delay when terminal should be ready
|
||||
setTimeout(() => {
|
||||
this.setupIMEInput(retryCount + 1);
|
||||
}, IME_SETUP_RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the terminal container to position the IME input correctly
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
const terminalContainer = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||
if (!terminalContainer) {
|
||||
console.warn('🌏 InputManager: Terminal container not found, cannot setup IME input');
|
||||
logger.warn('Terminal container not found, cannot setup IME input');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -94,10 +196,36 @@ export class InputManager {
|
|||
this.sendInput(key);
|
||||
},
|
||||
getCursorInfo: () => {
|
||||
// For now, return null to use fallback positioning
|
||||
// TODO: Implement cursor position tracking when Terminal/VibeTerminalBinary support it
|
||||
// Get cursor position from the terminal element
|
||||
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||
if (!terminalElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the terminal element has getCursorInfo method
|
||||
if (
|
||||
'getCursorInfo' in terminalElement &&
|
||||
typeof terminalElement.getCursorInfo === 'function'
|
||||
) {
|
||||
return terminalElement.getCursorInfo();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getFontSize: () => {
|
||||
// Get font size from the terminal element
|
||||
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||
if (!terminalElement) {
|
||||
return 14; // Default font size
|
||||
}
|
||||
|
||||
// Check if the terminal element has fontSize property
|
||||
if ('fontSize' in terminalElement && typeof terminalElement.fontSize === 'number') {
|
||||
return terminalElement.fontSize;
|
||||
}
|
||||
|
||||
return 14; // Default font size
|
||||
},
|
||||
autoFocus: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -307,12 +435,18 @@ export class InputManager {
|
|||
// sendInputText is used for pasted content - always treat as literal text
|
||||
// Never interpret pasted text as special keys to avoid ambiguity
|
||||
await this.sendInputInternal({ text }, 'send input to session');
|
||||
|
||||
// Update IME input position after sending text
|
||||
this.refreshIMEPosition();
|
||||
}
|
||||
|
||||
async sendControlSequence(controlChar: string): Promise<void> {
|
||||
// sendControlSequence is for control characters - always send as literal text
|
||||
// Control characters like '\x12' (Ctrl+R) should be sent directly
|
||||
await this.sendInputInternal({ text: controlChar }, 'send control sequence to session');
|
||||
|
||||
// Update IME input position after sending control sequence
|
||||
this.refreshIMEPosition();
|
||||
}
|
||||
|
||||
async sendInput(inputText: string): Promise<void> {
|
||||
|
|
@ -350,6 +484,23 @@ export class InputManager {
|
|||
|
||||
const input = specialKeys.includes(inputText) ? { key: inputText } : { text: inputText };
|
||||
await this.sendInputInternal(input, 'send input to session');
|
||||
|
||||
// Update IME input position after sending input
|
||||
this.refreshIMEPosition();
|
||||
}
|
||||
|
||||
private refreshIMEPosition(): void {
|
||||
// Update IME input position if it exists
|
||||
if (this.imeInput?.isFocused()) {
|
||||
// Update immediately first
|
||||
this.imeInput?.refreshPosition();
|
||||
|
||||
// Debounced update after allowing terminal to update cursor position
|
||||
// Use a single setTimeout to avoid race conditions
|
||||
setTimeout(() => {
|
||||
this.imeInput?.refreshPosition();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
isKeyboardShortcut(e: KeyboardEvent): boolean {
|
||||
|
|
@ -434,6 +585,12 @@ export class InputManager {
|
|||
this.imeInput = null;
|
||||
}
|
||||
|
||||
// Remove global composition listener
|
||||
if (this.globalCompositionListener) {
|
||||
document.removeEventListener('compositionstart', this.globalCompositionListener);
|
||||
this.globalCompositionListener = null;
|
||||
}
|
||||
|
||||
// Disconnect WebSocket if feature was enabled
|
||||
if (this.useWebSocketInput) {
|
||||
websocketInputClient.disconnect();
|
||||
|
|
@ -444,6 +601,116 @@ export class InputManager {
|
|||
this.callbacks = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry IME setup - useful when terminal becomes ready after initial setup attempt
|
||||
*/
|
||||
retryIMESetup(): void {
|
||||
if (!this.imeInput && !detectMobile()) {
|
||||
logger.log('Retrying IME setup after terminal became ready');
|
||||
this.setupIMEInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a global composition event listener to detect CJK input dynamically
|
||||
* This allows enabling IME input when the user starts composing CJK text
|
||||
*/
|
||||
private setupGlobalCompositionListener(): void {
|
||||
if (this.globalCompositionListener) {
|
||||
return; // Already set up
|
||||
}
|
||||
|
||||
this.globalCompositionListener = (e: CompositionEvent) => {
|
||||
// Only enable IME input if it's not already set up
|
||||
if (!this.imeInput && this.session && !detectMobile()) {
|
||||
logger.log('Composition event detected, enabling IME input:', e.type, e.data);
|
||||
this.enableIMEInput();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for composition start events globally
|
||||
document.addEventListener('compositionstart', this.globalCompositionListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable IME input dynamically when CJK input is detected
|
||||
* This can be called when composition events are detected or user explicitly enables CJK input
|
||||
*/
|
||||
enableIMEInput(): void {
|
||||
if (detectMobile()) {
|
||||
logger.log('Skipping IME input enable on mobile device');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.imeInput) {
|
||||
logger.log('IME input already enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.session) {
|
||||
logger.log('Cannot enable IME input - no session available');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('Dynamically enabling IME input for CJK composition');
|
||||
// Force enable by skipping the language check since composition was detected
|
||||
this.forceSetupIMEInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force setup IME input without language checks (used when composition is detected)
|
||||
*/
|
||||
private forceSetupIMEInput(): void {
|
||||
const terminalContainer = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||
if (!terminalContainer) {
|
||||
logger.warn('Terminal container not found, cannot setup IME input');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create IME input component
|
||||
this.imeInput = new DesktopIMEInput({
|
||||
container: terminalContainer,
|
||||
onTextInput: (text: string) => {
|
||||
this.sendInputText(text);
|
||||
},
|
||||
onSpecialKey: (key: string) => {
|
||||
this.sendInput(key);
|
||||
},
|
||||
getCursorInfo: () => {
|
||||
// Get cursor position from the terminal element
|
||||
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||
if (!terminalElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the terminal element has getCursorInfo method
|
||||
if (
|
||||
'getCursorInfo' in terminalElement &&
|
||||
typeof terminalElement.getCursorInfo === 'function'
|
||||
) {
|
||||
return terminalElement.getCursorInfo();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getFontSize: () => {
|
||||
// Get font size from the terminal element
|
||||
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||
if (!terminalElement) {
|
||||
return 14; // Default font size
|
||||
}
|
||||
|
||||
// Check if the terminal element has fontSize property
|
||||
if ('fontSize' in terminalElement && typeof terminalElement.fontSize === 'number') {
|
||||
return terminalElement.fontSize;
|
||||
}
|
||||
|
||||
return 14; // Default font size
|
||||
},
|
||||
autoFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// For testing purposes only
|
||||
getIMEInputForTesting(): DesktopIMEInput | null {
|
||||
return this.imeInput;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { Session } from '../../../shared/types.js';
|
||||
import { clearCharacterWidthCache } from '../../utils/cursor-position.js';
|
||||
import { consumeEvent } from '../../utils/event-utils.js';
|
||||
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
|
|
@ -198,6 +199,9 @@ export class LifecycleEventManager extends ManagerEventEmitter {
|
|||
handleWindowResize = (): void => {
|
||||
if (!this.callbacks) return;
|
||||
|
||||
// Clear character width cache when window is resized (may affect font rendering)
|
||||
clearCharacterWidthCache();
|
||||
|
||||
// Clear cache to re-evaluate capabilities (in case of device mode changes in dev tools)
|
||||
this.touchCapabilityCache = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* - Settings persistence via TerminalPreferencesManager
|
||||
*/
|
||||
import type { Session } from '../../../shared/types.js';
|
||||
import { clearCharacterWidthCache } from '../../utils/cursor-position.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
import {
|
||||
COMMON_TERMINAL_WIDTHS,
|
||||
|
|
@ -170,6 +171,9 @@ export class TerminalSettingsManager {
|
|||
this.preferencesManager.setFontSize(clampedSize);
|
||||
this.callbacks.setTerminalFontSize(clampedSize);
|
||||
|
||||
// Clear character width cache when font size changes
|
||||
clearCharacterWidthCache();
|
||||
|
||||
// Update the terminal lifecycle manager
|
||||
const lifecycleManager = this.callbacks.getTerminalLifecycleManager();
|
||||
if (lifecycleManager) {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@
|
|||
import { type IBufferCell, type IBufferLine, Terminal as XtermTerminal } from '@xterm/headless';
|
||||
import { html, LitElement, type PropertyValues } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { calculateCursorPosition } from '../utils/cursor-position.js';
|
||||
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
||||
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||
|
|
@ -458,7 +460,7 @@ export class Terminal extends LitElement {
|
|||
logger.debug('initializeTerminal starting');
|
||||
this.requestUpdate();
|
||||
|
||||
this.container = this.querySelector('#terminal-container') as HTMLElement;
|
||||
this.container = this.querySelector(`#${TERMINAL_IDS.TERMINAL_CONTAINER}`) as HTMLElement;
|
||||
|
||||
if (!this.container) {
|
||||
const error = new Error('Terminal container not found');
|
||||
|
|
@ -1690,7 +1692,7 @@ export class Terminal extends LitElement {
|
|||
</style>
|
||||
<div class="relative w-full h-full p-0 m-0">
|
||||
<div
|
||||
id="terminal-container"
|
||||
id="${TERMINAL_IDS.TERMINAL_CONTAINER}"
|
||||
class="terminal-container w-full h-full overflow-hidden p-0 m-0"
|
||||
tabindex="0"
|
||||
contenteditable="false"
|
||||
|
|
@ -1741,4 +1743,28 @@ export class Terminal extends LitElement {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position information for IME input positioning
|
||||
* Returns null if terminal is not available or cursor is not visible
|
||||
*/
|
||||
getCursorInfo(): { x: number; y: number } | null {
|
||||
if (!this.terminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get cursor position from xterm.js
|
||||
const buffer = this.terminal.buffer.active;
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY;
|
||||
|
||||
// Find the terminal container element
|
||||
const container = this.querySelector(`#${TERMINAL_IDS.TERMINAL_CONTAINER}`);
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use shared cursor position calculation
|
||||
return calculateCursorPosition(cursorX, cursorY, this.fontSize, container, this.sessionStatus);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import { html, type PropertyValues } from 'lit';
|
|||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { HttpMethod } from '../../shared/types.js';
|
||||
import { authClient } from '../services/auth-client.js';
|
||||
import { calculateCursorPosition } from '../utils/cursor-position.js';
|
||||
import { consumeEvent } from '../utils/event-utils.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
||||
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||
|
|
@ -399,4 +401,27 @@ export class VibeTerminalBinary extends VibeTerminalBuffer {
|
|||
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position information for IME input positioning
|
||||
* Returns null if buffer is not available or session is not running
|
||||
*/
|
||||
getCursorInfo(): { x: number; y: number } | null {
|
||||
if (!this.buffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get cursor position from buffer data
|
||||
const cursorX = this.buffer.cursorX;
|
||||
const cursorY = this.buffer.cursorY;
|
||||
|
||||
// Find the terminal container element
|
||||
const container = this.querySelector(`#${TERMINAL_IDS.BUFFER_CONTAINER}`);
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use shared cursor position calculation
|
||||
return calculateCursorPosition(cursorX, cursorY, this.fontSize, container, this.sessionStatus);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { html, LitElement } from 'lit';
|
|||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
||||
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||
import { type BufferCell, TerminalRenderer } from '../utils/terminal-renderer.js';
|
||||
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||
|
|
@ -35,7 +36,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
@property({ type: String }) theme: TerminalThemeId = 'auto';
|
||||
@property({ type: String }) sessionStatus = 'running'; // Track session status for cursor control
|
||||
|
||||
@state() private buffer: BufferSnapshot | null = null;
|
||||
@state() protected buffer: BufferSnapshot | null = null;
|
||||
@state() private error: string | null = null;
|
||||
@state() private displayedFontSize = 16;
|
||||
@state() private visibleRows = 0;
|
||||
|
|
@ -70,7 +71,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.container = this.querySelector('#buffer-container') as HTMLElement;
|
||||
this.container = this.querySelector(`#${TERMINAL_IDS.BUFFER_CONTAINER}`) as HTMLElement;
|
||||
if (this.container) {
|
||||
this.setupResize();
|
||||
if (this.sessionId) {
|
||||
|
|
@ -250,7 +251,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
`
|
||||
: html`
|
||||
<div
|
||||
id="buffer-container"
|
||||
id="${TERMINAL_IDS.BUFFER_CONTAINER}"
|
||||
class="terminal-container w-full h-full overflow-x-auto overflow-y-hidden font-mono antialiased"
|
||||
></div>
|
||||
`
|
||||
|
|
|
|||
251
web/src/client/utils/cursor-position.test.ts
Normal file
251
web/src/client/utils/cursor-position.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateCursorPosition, clearCharacterWidthCache } from './cursor-position.js';
|
||||
import { TERMINAL_IDS } from './terminal-constants.js';
|
||||
|
||||
describe('cursor-position', () => {
|
||||
let mockContainer: HTMLElement;
|
||||
let mockSessionTerminal: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the cache before each test
|
||||
clearCharacterWidthCache();
|
||||
|
||||
// Reset any existing mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock DOM elements
|
||||
mockContainer = {
|
||||
style: {},
|
||||
appendChild: vi.fn(),
|
||||
removeChild: vi.fn(),
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 800,
|
||||
height: 600,
|
||||
right: 900,
|
||||
bottom: 650,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
mockSessionTerminal = {
|
||||
id: TERMINAL_IDS.SESSION_TERMINAL,
|
||||
style: {},
|
||||
remove: vi.fn(),
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({
|
||||
left: 20,
|
||||
top: 10,
|
||||
width: 1000,
|
||||
height: 700,
|
||||
right: 1020,
|
||||
bottom: 710,
|
||||
x: 20,
|
||||
y: 10,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Mock getElementById to return our mock session terminal
|
||||
vi.spyOn(document, 'getElementById').mockImplementation((id) => {
|
||||
if (id === TERMINAL_IDS.SESSION_TERMINAL) {
|
||||
return mockSessionTerminal;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCursorPosition', () => {
|
||||
it('should calculate correct position for given cursor coordinates', () => {
|
||||
const fontSize = 14;
|
||||
const cursorX = 5; // 5 characters from left
|
||||
const cursorY = 3; // 3 lines from top
|
||||
|
||||
// Mock getBoundingClientRect for the test element to provide consistent char width
|
||||
const mockTestElement = {
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }), // Mock char width
|
||||
style: {},
|
||||
textContent: '',
|
||||
};
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockTestElement as any);
|
||||
|
||||
const result = calculateCursorPosition(cursorX, cursorY, fontSize, mockContainer, 'running');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.x).toBeGreaterThan(0);
|
||||
expect(result?.y).toBeGreaterThan(0);
|
||||
|
||||
// The position should be relative to the session terminal container
|
||||
// x = (containerLeft + cursorX * charWidth) - sessionTerminalLeft
|
||||
// y = (containerTop + cursorY * lineHeight) - sessionTerminalTop
|
||||
const expectedRelativeX = 100 + cursorX * 8.4 - 20; // Using mocked char width
|
||||
const expectedRelativeY = 50 + cursorY * (fontSize * 1.2) - 10; // Using actual line height calculation
|
||||
|
||||
expect(result?.x).toBeCloseTo(expectedRelativeX, 1);
|
||||
expect(result?.y).toBeCloseTo(expectedRelativeY, 1);
|
||||
});
|
||||
|
||||
it('should return null when session is not running', () => {
|
||||
const result = calculateCursorPosition(5, 3, 14, mockContainer, 'exited');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing container gracefully', () => {
|
||||
// Mock a container that throws an error during getBoundingClientRect
|
||||
const errorContainer = {
|
||||
getBoundingClientRect: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Container error');
|
||||
}),
|
||||
appendChild: vi.fn(),
|
||||
removeChild: vi.fn(),
|
||||
};
|
||||
|
||||
const result = calculateCursorPosition(5, 3, 14, errorContainer as any, 'running');
|
||||
|
||||
// Should return null when calculation fails
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache character width measurements', () => {
|
||||
const fontSize = 14;
|
||||
|
||||
// Mock a test element with consistent measurement
|
||||
const mockTestElement = {
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||
style: {},
|
||||
textContent: '',
|
||||
};
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockTestElement as any);
|
||||
|
||||
// Mock appendChild and removeChild to track element creation
|
||||
const appendChildSpy = vi.spyOn(mockContainer, 'appendChild');
|
||||
const removeChildSpy = vi.spyOn(mockContainer, 'removeChild');
|
||||
|
||||
// First call should create a test element and cache the result
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(1);
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeChildSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset spies
|
||||
createElementSpy.mockClear();
|
||||
appendChildSpy.mockClear();
|
||||
removeChildSpy.mockClear();
|
||||
|
||||
// Second call with same font size should use cached value (no new element creation)
|
||||
calculateCursorPosition(2, 2, fontSize, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(0); // Cached value, no new element
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(0); // No new appendChild
|
||||
expect(removeChildSpy).toHaveBeenCalledTimes(0); // No new removeChild
|
||||
|
||||
// Different font size should create new measurement
|
||||
calculateCursorPosition(1, 1, 16, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(1); // New font size, new element
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeChildSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should clean up test elements even on error', () => {
|
||||
const fontSize = 14;
|
||||
|
||||
// Mock a test element that will throw during getBoundingClientRect
|
||||
const testElement = {
|
||||
style: {},
|
||||
textContent: '',
|
||||
getBoundingClientRect: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Test error');
|
||||
}),
|
||||
};
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(testElement as any);
|
||||
|
||||
// This should not throw and should still clean up
|
||||
expect(() => {
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify cleanup was called even though getBoundingClientRect failed
|
||||
expect(mockContainer.removeChild).toHaveBeenCalledWith(testElement);
|
||||
});
|
||||
|
||||
it('should handle missing session terminal element', () => {
|
||||
// Mock getElementById to return null (session terminal not found)
|
||||
vi.spyOn(document, 'getElementById').mockImplementation(() => null);
|
||||
|
||||
// Mock a test element for char width measurement
|
||||
const mockTestElement = {
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||
style: {},
|
||||
textContent: '',
|
||||
};
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockTestElement as any);
|
||||
|
||||
const result = calculateCursorPosition(5, 3, 14, mockContainer, 'running');
|
||||
|
||||
// Should still return a position (absolute coordinates)
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.x).toBeGreaterThan(0);
|
||||
expect(result?.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use correct font family for measurements', () => {
|
||||
const fontSize = 14;
|
||||
|
||||
// Mock a test element that tracks style assignments
|
||||
const testElement = {
|
||||
style: {} as any,
|
||||
textContent: '',
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||
};
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(testElement as any);
|
||||
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('span');
|
||||
expect(testElement.style.fontFamily).toBe(
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
||||
);
|
||||
expect(testElement.style.fontSize).toBe('14px');
|
||||
expect(testElement.textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCharacterWidthCache', () => {
|
||||
it('should clear the character width cache', () => {
|
||||
const fontSize = 14;
|
||||
|
||||
// Mock a test element with consistent measurement
|
||||
const mockTestElement = {
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||
style: {},
|
||||
textContent: '',
|
||||
};
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockTestElement as any);
|
||||
|
||||
// Make a call to populate the cache
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
createElementSpy.mockClear();
|
||||
|
||||
// This should use cached value (no new element creation)
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(0); // Uses cached value
|
||||
|
||||
// Clear the cache
|
||||
clearCharacterWidthCache();
|
||||
|
||||
// This should create a new measurement after cache clear
|
||||
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(1); // Cache cleared, new measurement needed
|
||||
});
|
||||
});
|
||||
});
|
||||
111
web/src/client/utils/cursor-position.ts
Normal file
111
web/src/client/utils/cursor-position.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Shared cursor position calculation utility for terminal components
|
||||
*/
|
||||
import { TERMINAL_FONT_FAMILY, TERMINAL_IDS } from './terminal-constants.js';
|
||||
|
||||
// Cache for character width measurements per font size
|
||||
const charWidthCache = new Map<number, number>();
|
||||
|
||||
/**
|
||||
* Measure character width for a given font size, with caching
|
||||
* @param fontSize - Font size in pixels
|
||||
* @param container - Container element to append test element to
|
||||
* @returns Character width in pixels
|
||||
*/
|
||||
function measureCharacterWidth(fontSize: number, container: Element): number {
|
||||
// Return cached value if available
|
||||
if (charWidthCache.has(fontSize)) {
|
||||
const cachedWidth = charWidthCache.get(fontSize);
|
||||
if (cachedWidth !== undefined) {
|
||||
return cachedWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// Create test element to measure character width
|
||||
const testElement = document.createElement('span');
|
||||
testElement.style.position = 'absolute';
|
||||
testElement.style.visibility = 'hidden';
|
||||
testElement.style.fontSize = `${fontSize}px`;
|
||||
testElement.style.fontFamily = TERMINAL_FONT_FAMILY;
|
||||
testElement.textContent = '0';
|
||||
|
||||
try {
|
||||
container.appendChild(testElement);
|
||||
const charWidth = testElement.getBoundingClientRect().width;
|
||||
|
||||
// Cache the measurement
|
||||
charWidthCache.set(fontSize, charWidth);
|
||||
return charWidth;
|
||||
} finally {
|
||||
// Ensure cleanup even if measurement fails
|
||||
container.removeChild(testElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the character width cache
|
||||
* Call when font size changes or on window resize/zoom
|
||||
*/
|
||||
export function clearCharacterWidthCache(): void {
|
||||
charWidthCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cursor position for IME input positioning
|
||||
* @param cursorX - Cursor column position (0-based)
|
||||
* @param cursorY - Cursor row position (0-based)
|
||||
* @param fontSize - Terminal font size in pixels
|
||||
* @param container - Terminal container element
|
||||
* @param sessionStatus - Session status ('running' or other)
|
||||
* @returns Cursor position relative to #session-terminal container, or null if unavailable
|
||||
*/
|
||||
export function calculateCursorPosition(
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
fontSize: number,
|
||||
container: Element,
|
||||
sessionStatus: string
|
||||
): { x: number; y: number } | null {
|
||||
if (sessionStatus !== 'running') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Calculate character dimensions based on font size
|
||||
const lineHeight = fontSize * 1.2;
|
||||
|
||||
// Get character width with caching
|
||||
const charWidth = measureCharacterWidth(fontSize, container);
|
||||
|
||||
// Calculate cursor position within the terminal container
|
||||
const terminalRect = container.getBoundingClientRect();
|
||||
const cursorOffsetX = cursorX * charWidth;
|
||||
const cursorOffsetY = cursorY * lineHeight;
|
||||
|
||||
// Calculate absolute position on the page
|
||||
const absoluteX = terminalRect.left + cursorOffsetX;
|
||||
const absoluteY = terminalRect.top + cursorOffsetY;
|
||||
|
||||
// Convert to position relative to #session-terminal container
|
||||
// (The IME input is positioned relative to this container)
|
||||
const sessionTerminal = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||
if (!sessionTerminal) {
|
||||
return { x: absoluteX, y: absoluteY };
|
||||
}
|
||||
|
||||
const sessionRect = sessionTerminal.getBoundingClientRect();
|
||||
const relativeX = absoluteX - sessionRect.left;
|
||||
const relativeY = absoluteY - sessionRect.top;
|
||||
|
||||
return {
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
/**
|
||||
* Keys that are allowed to be processed even when IME input is focused
|
||||
*/
|
||||
export const IME_ALLOWED_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End'] as const;
|
||||
export const IME_ALLOWED_KEYS = ['Home', 'End', 'Escape'] as const;
|
||||
|
||||
/**
|
||||
* Check if a keyboard event is allowed during IME input focus
|
||||
|
|
|
|||
43
web/src/client/utils/terminal-constants.ts
Normal file
43
web/src/client/utils/terminal-constants.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Terminal component constants and selectors
|
||||
*
|
||||
* Centralized definitions to prevent breaking changes when IDs or classes are modified
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML element IDs used across terminal components
|
||||
*/
|
||||
export const TERMINAL_IDS = {
|
||||
/** Main session container element */
|
||||
SESSION_TERMINAL: 'session-terminal',
|
||||
/** Buffer container for vibe-terminal-buffer component */
|
||||
BUFFER_CONTAINER: 'buffer-container',
|
||||
/** Terminal container for terminal.ts component */
|
||||
TERMINAL_CONTAINER: 'terminal-container',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard terminal font family used across the application
|
||||
*/
|
||||
export const TERMINAL_FONT_FAMILY =
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace';
|
||||
|
||||
/**
|
||||
* IME input vertical offset in pixels for better alignment
|
||||
*/
|
||||
export const IME_VERTICAL_OFFSET_PX = 3;
|
||||
|
||||
/**
|
||||
* CJK (Chinese, Japanese, Korean) language codes for IME detection
|
||||
*/
|
||||
export const CJK_LANGUAGE_CODES = [
|
||||
'zh',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'zh-HK',
|
||||
'zh-SG', // Chinese variants
|
||||
'ja',
|
||||
'ja-JP', // Japanese
|
||||
'ko',
|
||||
'ko-KR', // Korean
|
||||
] as const;
|
||||
Loading…
Reference in a new issue