mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix Safari clipboard paste (#336)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
29f938dcc1
commit
00933690a8
8 changed files with 420 additions and 20 deletions
156
web/docs/ios-safari-paste.md
Normal file
156
web/docs/ios-safari-paste.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# iOS Safari Paste Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the implementation of paste functionality for iOS Safari in VibeTunnel, addressing the limitations of the Clipboard API on mobile Safari and providing a reliable fallback mechanism.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
### Primary Issues
|
||||||
|
|
||||||
|
1. **Clipboard API Limitations on iOS Safari**
|
||||||
|
- `navigator.clipboard.readText()` only works in secure contexts (HTTPS/localhost)
|
||||||
|
- Even in secure contexts, it requires "transient user activation" (immediate user gesture)
|
||||||
|
- iOS 15 and older versions don't expose `readText()` at all
|
||||||
|
- In HTTP contexts, `navigator.clipboard` is completely undefined
|
||||||
|
|
||||||
|
2. **Focus Management Conflicts**
|
||||||
|
- VibeTunnel uses aggressive focus retention for the hidden input field
|
||||||
|
- Focus retention runs every 100ms to maintain keyboard visibility
|
||||||
|
- "Keyboard mode" forces focus back to hidden input continuously
|
||||||
|
- This prevents any other element from maintaining focus long enough for iOS paste menu
|
||||||
|
|
||||||
|
3. **iOS Native Paste Menu Requirements**
|
||||||
|
- Requires a visible, focusable text input element
|
||||||
|
- Element must maintain focus for the paste menu to appear
|
||||||
|
- User must be able to long-press the element
|
||||||
|
- Hidden or off-screen elements don't trigger the paste menu reliably
|
||||||
|
|
||||||
|
### Failed Approaches
|
||||||
|
|
||||||
|
1. **Off-screen Textarea** (Apple's documented approach)
|
||||||
|
- Created textarea at `position: fixed; left: -9999px`
|
||||||
|
- Focus was immediately stolen by focus retention mechanism
|
||||||
|
- Even after disabling focus retention, keyboard mode continued stealing focus
|
||||||
|
- iOS couldn't show paste menu on an off-screen element
|
||||||
|
|
||||||
|
2. **Temporary Focus Retention Disable**
|
||||||
|
- Attempted to pause focus retention interval during paste
|
||||||
|
- Keyboard mode's focus management still interfered
|
||||||
|
- Complex state management led to race conditions
|
||||||
|
- Restoration of focus states was unreliable
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
### Implementation Strategy
|
||||||
|
|
||||||
|
Use the existing hidden input field and temporarily make it visible for paste operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private triggerNativePasteWithHiddenInput(): void {
|
||||||
|
// 1. Save original styles
|
||||||
|
const originalStyles = {
|
||||||
|
position: this.hiddenInput.style.position,
|
||||||
|
opacity: this.hiddenInput.style.opacity,
|
||||||
|
// ... all other styles
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Make input visible at screen center
|
||||||
|
this.hiddenInput.style.position = 'fixed';
|
||||||
|
this.hiddenInput.style.left = '50%';
|
||||||
|
this.hiddenInput.style.top = '50%';
|
||||||
|
this.hiddenInput.style.transform = 'translate(-50%, -50%)';
|
||||||
|
this.hiddenInput.style.width = '200px';
|
||||||
|
this.hiddenInput.style.height = '40px';
|
||||||
|
this.hiddenInput.style.opacity = '1';
|
||||||
|
this.hiddenInput.style.backgroundColor = 'white';
|
||||||
|
this.hiddenInput.style.border = '2px solid #007AFF';
|
||||||
|
this.hiddenInput.style.borderRadius = '8px';
|
||||||
|
this.hiddenInput.style.padding = '8px';
|
||||||
|
this.hiddenInput.style.zIndex = '10000';
|
||||||
|
this.hiddenInput.placeholder = 'Long-press to paste';
|
||||||
|
|
||||||
|
// 3. Add paste event listener
|
||||||
|
this.hiddenInput.addEventListener('paste', handlePasteEvent);
|
||||||
|
|
||||||
|
// 4. Focus and select
|
||||||
|
this.hiddenInput.focus();
|
||||||
|
this.hiddenInput.select();
|
||||||
|
|
||||||
|
// 5. Clean up after paste or timeout
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
1. **No Focus Conflicts**: Uses the same input that already has focus management
|
||||||
|
2. **Visible Target**: iOS can show paste menu on a visible, centered element
|
||||||
|
3. **User-Friendly**: Clear visual feedback with "Long-press to paste" placeholder
|
||||||
|
4. **Simple State Management**: Just style changes, no complex focus juggling
|
||||||
|
5. **Maintains User Gesture Context**: Called directly from touch event handler
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
|
||||||
|
1. User taps "Paste" button in quick keys
|
||||||
|
2. Hidden input becomes visible at screen center with blue border
|
||||||
|
3. User long-presses the visible input
|
||||||
|
4. iOS shows native paste menu
|
||||||
|
5. User taps "Paste" from menu
|
||||||
|
6. Text is pasted and sent to terminal
|
||||||
|
7. Input returns to hidden state
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
- `web/src/client/components/session-view/direct-keyboard-manager.ts:695-784` - Main paste implementation
|
||||||
|
- `web/src/client/components/terminal-quick-keys.ts:198-207` - Paste button handler
|
||||||
|
|
||||||
|
### Fallback Logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In handleQuickKeyPress for 'Paste' key:
|
||||||
|
1. Try modern Clipboard API if available (HTTPS contexts)
|
||||||
|
2. If that fails or unavailable, use triggerNativePasteWithHiddenInput()
|
||||||
|
3. Show visible input for native iOS paste menu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autocorrect Disable
|
||||||
|
|
||||||
|
To prevent iOS text editing interference, the hidden input has comprehensive attributes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.hiddenInput.autocapitalize = 'none';
|
||||||
|
this.hiddenInput.autocomplete = 'off';
|
||||||
|
this.hiddenInput.setAttribute('autocorrect', 'off');
|
||||||
|
this.hiddenInput.setAttribute('spellcheck', 'false');
|
||||||
|
this.hiddenInput.setAttribute('data-autocorrect', 'off');
|
||||||
|
this.hiddenInput.setAttribute('data-gramm', 'false');
|
||||||
|
this.hiddenInput.setAttribute('data-ms-editor', 'false');
|
||||||
|
this.hiddenInput.setAttribute('data-smartpunctuation', 'false');
|
||||||
|
this.hiddenInput.setAttribute('inputmode', 'text');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
|
||||||
|
1. **HTTPS Context**: Clipboard API should work directly
|
||||||
|
2. **HTTP Context**: Should fall back to visible input method
|
||||||
|
3. **Focus Retention Active**: Paste should still work without conflicts
|
||||||
|
4. **Multiple Paste Operations**: Each should work independently
|
||||||
|
5. **Timeout Handling**: Input should restore after 10 seconds if no paste
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
1. Requires user to long-press and select paste (two taps total)
|
||||||
|
2. Shows visible UI element temporarily
|
||||||
|
3. 10-second timeout if user doesn't paste
|
||||||
|
4. Only works with text content (no rich text/images)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [iOS Safari Clipboard API Documentation](https://developer.apple.com/documentation/webkit/clipboard_api)
|
||||||
|
- [WebKit Bug Tracker - Clipboard API Issues](https://bugs.webkit.org/)
|
||||||
|
- Original working implementation: Commit `44f69c45a`
|
||||||
|
- Issue #317: Safari paste functionality
|
||||||
|
|
@ -60,6 +60,7 @@ export class DirectKeyboardManager {
|
||||||
private keyboardModeTimestamp = 0; // Track when we entered keyboard mode
|
private keyboardModeTimestamp = 0; // Track when we entered keyboard mode
|
||||||
private keyboardActivationTimeout: number | null = null;
|
private keyboardActivationTimeout: number | null = null;
|
||||||
private captureClickHandler: ((e: Event) => void) | null = null;
|
private captureClickHandler: ((e: Event) => void) | null = null;
|
||||||
|
private globalPasteHandler: ((e: Event) => void) | null = null;
|
||||||
|
|
||||||
// IME composition state tracking for Japanese/CJK input
|
// IME composition state tracking for Japanese/CJK input
|
||||||
private isComposing = false;
|
private isComposing = false;
|
||||||
|
|
@ -67,6 +68,9 @@ export class DirectKeyboardManager {
|
||||||
|
|
||||||
constructor(instanceId: string) {
|
constructor(instanceId: string) {
|
||||||
this.instanceId = instanceId;
|
this.instanceId = instanceId;
|
||||||
|
|
||||||
|
// Add global paste listener for environments where Clipboard API doesn't work
|
||||||
|
this.setupGlobalPasteListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputManager(inputManager: InputManager): void {
|
setInputManager(inputManager: InputManager): void {
|
||||||
|
|
@ -214,10 +218,17 @@ export class DirectKeyboardManager {
|
||||||
this.hiddenInput.style.cursor = 'default';
|
this.hiddenInput.style.cursor = 'default';
|
||||||
this.hiddenInput.style.pointerEvents = 'none'; // Start with pointer events disabled
|
this.hiddenInput.style.pointerEvents = 'none'; // Start with pointer events disabled
|
||||||
this.hiddenInput.style.webkitUserSelect = 'text'; // iOS specific
|
this.hiddenInput.style.webkitUserSelect = 'text'; // iOS specific
|
||||||
this.hiddenInput.autocapitalize = 'off';
|
this.hiddenInput.autocapitalize = 'none'; // More explicit than 'off'
|
||||||
this.hiddenInput.autocomplete = 'off';
|
this.hiddenInput.autocomplete = 'off';
|
||||||
this.hiddenInput.setAttribute('autocorrect', 'off');
|
this.hiddenInput.setAttribute('autocorrect', 'off');
|
||||||
this.hiddenInput.setAttribute('spellcheck', 'false');
|
this.hiddenInput.setAttribute('spellcheck', 'false');
|
||||||
|
this.hiddenInput.setAttribute('data-autocorrect', 'off');
|
||||||
|
this.hiddenInput.setAttribute('data-gramm', 'false'); // Disable Grammarly
|
||||||
|
this.hiddenInput.setAttribute('data-ms-editor', 'false'); // Disable Microsoft Editor
|
||||||
|
this.hiddenInput.setAttribute('data-smartpunctuation', 'false'); // Disable smart quotes/dashes
|
||||||
|
this.hiddenInput.setAttribute('data-form-type', 'other'); // Hint this isn't a form field
|
||||||
|
this.hiddenInput.setAttribute('inputmode', 'text'); // Allow keyboard but disable optimizations
|
||||||
|
this.hiddenInput.setAttribute('enterkeyhint', 'done'); // Prevent iOS enter key behavior
|
||||||
this.hiddenInput.setAttribute('aria-hidden', 'true');
|
this.hiddenInput.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
// Set initial position based on mode
|
// Set initial position based on mode
|
||||||
|
|
@ -420,7 +431,9 @@ export class DirectKeyboardManager {
|
||||||
handleQuickKeyPress = async (
|
handleQuickKeyPress = async (
|
||||||
key: string,
|
key: string,
|
||||||
isModifier?: boolean,
|
isModifier?: boolean,
|
||||||
isSpecial?: boolean
|
isSpecial?: boolean,
|
||||||
|
isToggle?: boolean,
|
||||||
|
pasteText?: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!this.inputManager) {
|
if (!this.inputManager) {
|
||||||
logger.error('No input manager found');
|
logger.error('No input manager found');
|
||||||
|
|
@ -468,15 +481,50 @@ export class DirectKeyboardManager {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (key === 'Paste') {
|
} else if (key === 'Paste') {
|
||||||
// Handle Paste key
|
// Handle Paste key - following iOS Safari best practices
|
||||||
try {
|
logger.log('Paste button pressed - attempting clipboard read');
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
if (text && this.inputManager) {
|
// Log environment details for debugging
|
||||||
this.inputManager.sendInputText(text);
|
logger.log('Clipboard context:', {
|
||||||
|
hasClipboard: !!navigator.clipboard,
|
||||||
|
hasReadText: !!navigator.clipboard?.readText,
|
||||||
|
isSecureContext: window.isSecureContext,
|
||||||
|
protocol: window.location.protocol,
|
||||||
|
userAgent: navigator.userAgent.includes('Safari') ? 'Safari' : 'Other',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we're in a secure context (HTTPS/localhost/PWA)
|
||||||
|
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.readText) {
|
||||||
|
try {
|
||||||
|
logger.log('Secure context detected - trying modern clipboard API...');
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
logger.log('Clipboard read successful, text length:', text?.length || 0);
|
||||||
|
|
||||||
|
if (text && this.inputManager) {
|
||||||
|
logger.log('Sending clipboard text to terminal');
|
||||||
|
this.inputManager.sendInputText(text);
|
||||||
|
return; // Success - exit early
|
||||||
|
} else if (!text) {
|
||||||
|
logger.warn('Clipboard is empty or contains no text');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
logger.warn('Clipboard API failed despite secure context:', {
|
||||||
|
name: error?.name,
|
||||||
|
message: error?.message,
|
||||||
|
});
|
||||||
|
// Continue to fallback
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
logger.error('Failed to read clipboard:', err);
|
logger.log(
|
||||||
|
'Not in secure context (HTTP) - clipboard API unavailable, using textarea fallback'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: Use existing hidden input with paste event
|
||||||
|
logger.log('Using iOS native paste fallback with existing hidden input');
|
||||||
|
this.triggerNativePasteWithHiddenInput();
|
||||||
} else if (key === 'Ctrl+A') {
|
} else if (key === 'Ctrl+A') {
|
||||||
// Send Ctrl+A (start of line)
|
// Send Ctrl+A (start of line)
|
||||||
this.inputManager.sendControlSequence('\x01');
|
this.inputManager.sendControlSequence('\x01');
|
||||||
|
|
@ -645,6 +693,118 @@ export class DirectKeyboardManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private triggerNativePasteWithHiddenInput(): void {
|
||||||
|
if (!this.hiddenInput) {
|
||||||
|
logger.error('No hidden input available for paste fallback');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('Making hidden input temporarily visible for paste');
|
||||||
|
|
||||||
|
// Store original styles to restore later
|
||||||
|
const originalStyles = {
|
||||||
|
position: this.hiddenInput.style.position,
|
||||||
|
opacity: this.hiddenInput.style.opacity,
|
||||||
|
left: this.hiddenInput.style.left,
|
||||||
|
top: this.hiddenInput.style.top,
|
||||||
|
width: this.hiddenInput.style.width,
|
||||||
|
height: this.hiddenInput.style.height,
|
||||||
|
backgroundColor: this.hiddenInput.style.backgroundColor,
|
||||||
|
border: this.hiddenInput.style.border,
|
||||||
|
borderRadius: this.hiddenInput.style.borderRadius,
|
||||||
|
padding: this.hiddenInput.style.padding,
|
||||||
|
zIndex: this.hiddenInput.style.zIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the input visible and positioned for interaction
|
||||||
|
this.hiddenInput.style.position = 'fixed';
|
||||||
|
this.hiddenInput.style.left = '50%';
|
||||||
|
this.hiddenInput.style.top = '50%';
|
||||||
|
this.hiddenInput.style.transform = 'translate(-50%, -50%)';
|
||||||
|
this.hiddenInput.style.width = '200px';
|
||||||
|
this.hiddenInput.style.height = '40px';
|
||||||
|
this.hiddenInput.style.opacity = '1';
|
||||||
|
this.hiddenInput.style.backgroundColor = 'white';
|
||||||
|
this.hiddenInput.style.border = '2px solid #007AFF';
|
||||||
|
this.hiddenInput.style.borderRadius = '8px';
|
||||||
|
this.hiddenInput.style.padding = '8px';
|
||||||
|
this.hiddenInput.style.zIndex = '10000';
|
||||||
|
this.hiddenInput.placeholder = 'Long-press to paste';
|
||||||
|
|
||||||
|
const restoreStyles = () => {
|
||||||
|
if (!this.hiddenInput) return;
|
||||||
|
|
||||||
|
// Restore all original styles
|
||||||
|
Object.entries(originalStyles).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
(this.hiddenInput!.style as any)[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.hiddenInput.placeholder = '';
|
||||||
|
logger.log('Restored hidden input to original state');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a one-time paste event handler
|
||||||
|
const handlePasteEvent = (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const clipboardData = e.clipboardData?.getData('text/plain');
|
||||||
|
logger.log('Native paste event received, text length:', clipboardData?.length || 0);
|
||||||
|
|
||||||
|
if (clipboardData && this.inputManager) {
|
||||||
|
logger.log('Sending native paste text to terminal');
|
||||||
|
this.inputManager.sendInputText(clipboardData);
|
||||||
|
} else {
|
||||||
|
logger.warn('No clipboard data received in paste event');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
this.hiddenInput?.removeEventListener('paste', handlePasteEvent);
|
||||||
|
restoreStyles();
|
||||||
|
logger.log('Removed paste event listener and restored styles');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add paste event listener
|
||||||
|
this.hiddenInput.addEventListener('paste', handlePasteEvent);
|
||||||
|
|
||||||
|
// Focus and select the now-visible input
|
||||||
|
this.hiddenInput.focus();
|
||||||
|
this.hiddenInput.select();
|
||||||
|
|
||||||
|
logger.log('Input is now visible and focused - long-press to see paste menu');
|
||||||
|
|
||||||
|
// Clean up after timeout if no paste occurs
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.hiddenInput) {
|
||||||
|
this.hiddenInput.removeEventListener('paste', handlePasteEvent);
|
||||||
|
restoreStyles();
|
||||||
|
logger.log('Paste timeout - restored input to hidden state');
|
||||||
|
}
|
||||||
|
}, 10000); // 10 second timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGlobalPasteListener(): void {
|
||||||
|
// Listen for paste events anywhere in the document
|
||||||
|
// This catches CMD+V or context menu paste when the hidden input is focused
|
||||||
|
this.globalPasteHandler = (e: Event) => {
|
||||||
|
const pasteEvent = e as ClipboardEvent;
|
||||||
|
// Only handle if our hidden input is focused and we're in keyboard mode
|
||||||
|
if (this.hiddenInput && document.activeElement === this.hiddenInput && this.showQuickKeys) {
|
||||||
|
const clipboardData = pasteEvent.clipboardData?.getData('text/plain');
|
||||||
|
if (clipboardData && this.inputManager) {
|
||||||
|
logger.log('Global paste event captured, text length:', clipboardData.length);
|
||||||
|
this.inputManager.sendInputText(clipboardData);
|
||||||
|
pasteEvent.preventDefault();
|
||||||
|
pasteEvent.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', this.globalPasteHandler);
|
||||||
|
logger.log('Global paste listener setup for CMD+V support');
|
||||||
|
}
|
||||||
|
|
||||||
private dismissKeyboard(): void {
|
private dismissKeyboard(): void {
|
||||||
// Exit keyboard mode
|
// Exit keyboard mode
|
||||||
this.keyboardMode = false;
|
this.keyboardMode = false;
|
||||||
|
|
@ -705,6 +865,12 @@ export class DirectKeyboardManager {
|
||||||
this.captureClickHandler = null;
|
this.captureClickHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove global paste listener
|
||||||
|
if (this.globalPasteHandler) {
|
||||||
|
document.removeEventListener('paste', this.globalPasteHandler);
|
||||||
|
this.globalPasteHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove hidden input if it exists
|
// Remove hidden input if it exists
|
||||||
if (this.hiddenInput) {
|
if (this.hiddenInput) {
|
||||||
this.hiddenInput.remove();
|
this.hiddenInput.remove();
|
||||||
|
|
|
||||||
|
|
@ -244,8 +244,15 @@ export class MobileInputOverlay extends LitElement {
|
||||||
style="height: 120px; background: rgb(var(--color-bg)); color: rgb(var(--color-text)); border: none; padding: 12px;"
|
style="height: 120px; background: rgb(var(--color-bg)); color: rgb(var(--color-text)); border: none; padding: 12px;"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
data-autocorrect="off"
|
||||||
|
data-gramm="false"
|
||||||
|
data-ms-editor="false"
|
||||||
|
data-smartpunctuation="false"
|
||||||
|
data-form-type="other"
|
||||||
|
inputmode="text"
|
||||||
|
enterkeyhint="done"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,9 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
@property({ type: Function }) onKeyPress?: (
|
@property({ type: Function }) onKeyPress?: (
|
||||||
key: string,
|
key: string,
|
||||||
isModifier?: boolean,
|
isModifier?: boolean,
|
||||||
isSpecial?: boolean
|
isSpecial?: boolean,
|
||||||
|
isToggle?: boolean,
|
||||||
|
pasteText?: string
|
||||||
) => void;
|
) => void;
|
||||||
@property({ type: Boolean }) visible = false;
|
@property({ type: Boolean }) visible = false;
|
||||||
@property({ type: Number }) keyboardHeight = 0;
|
@property({ type: Number }) keyboardHeight = 0;
|
||||||
|
|
@ -194,6 +196,16 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handlePasteImmediate(e: Event) {
|
||||||
|
console.log('[QuickKeys] Paste button touched - delegating to paste handler');
|
||||||
|
|
||||||
|
// Always delegate to the main paste handler in direct-keyboard-manager
|
||||||
|
// This preserves user gesture context while keeping all clipboard logic in one place
|
||||||
|
if (this.onKeyPress) {
|
||||||
|
this.onKeyPress('Paste', false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private startKeyRepeat(key: string, isModifier: boolean, isSpecial: boolean) {
|
private startKeyRepeat(key: string, isModifier: boolean, isSpecial: boolean) {
|
||||||
// Only enable key repeat for arrow keys
|
// Only enable key repeat for arrow keys
|
||||||
if (!key.startsWith('Arrow')) return;
|
if (!key.startsWith('Arrow')) return;
|
||||||
|
|
@ -560,7 +572,11 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
@touchend=${(e: Event) => {
|
@touchend=${(e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.handleKeyPress(key, modifier || combo, special, toggle, e);
|
if (key === 'Paste') {
|
||||||
|
this.handlePasteImmediate(e);
|
||||||
|
} else {
|
||||||
|
this.handleKeyPress(key, modifier || combo, special, toggle, e);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
@click=${(e: MouseEvent) => {
|
@click=${(e: MouseEvent) => {
|
||||||
if (e.detail !== 0) {
|
if (e.detail !== 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { fixture, html } from '@open-wc/testing';
|
import { fixture, html } from '@open-wc/testing';
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { resetViewport, waitForElement } from '@/test/utils/component-helpers';
|
import { resetViewport, waitForElement, waitForEvent } from '@/test/utils/component-helpers';
|
||||||
import { MockResizeObserver, MockTerminal } from '@/test/utils/terminal-mocks';
|
import { MockResizeObserver, MockTerminal } from '@/test/utils/terminal-mocks';
|
||||||
|
|
||||||
// Mock xterm modules before importing the component
|
// Mock xterm modules before importing the component
|
||||||
|
|
@ -140,7 +140,6 @@ describe('Terminal', () => {
|
||||||
it('should handle paste events', async () => {
|
it('should handle paste events', async () => {
|
||||||
const pasteText = 'pasted content';
|
const pasteText = 'pasted content';
|
||||||
|
|
||||||
// Create and dispatch paste event
|
|
||||||
const clipboardData = new DataTransfer();
|
const clipboardData = new DataTransfer();
|
||||||
clipboardData.setData('text/plain', pasteText);
|
clipboardData.setData('text/plain', pasteText);
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -149,11 +148,51 @@ describe('Terminal', () => {
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The terminal component listens for paste on the container
|
|
||||||
const container = element.querySelector('.terminal-container');
|
const container = element.querySelector('.terminal-container');
|
||||||
if (container) {
|
expect(container).toBeTruthy();
|
||||||
container.dispatchEvent(pasteEvent);
|
|
||||||
|
const detail = await waitForEvent<{ text: string }>(element, 'terminal-paste', () => {
|
||||||
|
container?.dispatchEvent(pasteEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pasteEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(detail.text).toBe(pasteText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle paste events with navigator.clipboard fallback', async () => {
|
||||||
|
const pasteText = 'fallback content';
|
||||||
|
|
||||||
|
// Mock navigator.clipboard for fallback test
|
||||||
|
const originalClipboard = navigator.clipboard;
|
||||||
|
const mockReadText = vi.fn().mockResolvedValue(pasteText);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { readText: mockReadText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create paste event without clipboardData (Safari scenario)
|
||||||
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = element.querySelector('.terminal-container');
|
||||||
|
expect(container).toBeTruthy();
|
||||||
|
|
||||||
|
const detail = await waitForEvent<{ text: string }>(element, 'terminal-paste', () => {
|
||||||
|
container?.dispatchEvent(pasteEvent);
|
||||||
|
});
|
||||||
|
|
||||||
expect(pasteEvent.defaultPrevented).toBe(true);
|
expect(pasteEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(detail.text).toBe(pasteText);
|
||||||
|
expect(mockReadText).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
// Restore original clipboard
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: originalClipboard,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1587,11 +1587,20 @@ export class Terminal extends LitElement {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handlePaste = (e: ClipboardEvent) => {
|
private handlePaste = async (e: ClipboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const clipboardData = e.clipboardData?.getData('text/plain');
|
let clipboardData = e.clipboardData?.getData('text/plain');
|
||||||
|
|
||||||
|
if (!clipboardData && navigator.clipboard) {
|
||||||
|
try {
|
||||||
|
clipboardData = await navigator.clipboard.readText();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to read clipboard via navigator API', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (clipboardData) {
|
if (clipboardData) {
|
||||||
// Dispatch a custom event with the pasted text
|
// Dispatch a custom event with the pasted text
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ describe('DirectKeyboardManager', () => {
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock secure context for clipboard API to work
|
||||||
|
Object.defineProperty(window, 'isSecureContext', {
|
||||||
|
value: true,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ describe.sequential('Logs API Tests', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait and retry for the log file to be written and flushed
|
// Wait and retry for the log file to be written and flushed
|
||||||
let info: any;
|
let info: { exists: boolean; size: number };
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 10;
|
const maxAttempts = 10;
|
||||||
const retryDelay = 200;
|
const retryDelay = 200;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue