Fix Safari clipboard paste (#336)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger 2025-07-12 23:13:35 +02:00 committed by GitHub
parent 29f938dcc1
commit 00933690a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 420 additions and 20 deletions

View 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

View file

@ -60,6 +60,7 @@ export class DirectKeyboardManager {
private keyboardModeTimestamp = 0; // Track when we entered keyboard mode
private keyboardActivationTimeout: number | null = null;
private captureClickHandler: ((e: Event) => void) | null = null;
private globalPasteHandler: ((e: Event) => void) | null = null;
// IME composition state tracking for Japanese/CJK input
private isComposing = false;
@ -67,6 +68,9 @@ export class DirectKeyboardManager {
constructor(instanceId: string) {
this.instanceId = instanceId;
// Add global paste listener for environments where Clipboard API doesn't work
this.setupGlobalPasteListener();
}
setInputManager(inputManager: InputManager): void {
@ -214,10 +218,17 @@ export class DirectKeyboardManager {
this.hiddenInput.style.cursor = 'default';
this.hiddenInput.style.pointerEvents = 'none'; // Start with pointer events disabled
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.setAttribute('autocorrect', 'off');
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');
// Set initial position based on mode
@ -420,7 +431,9 @@ export class DirectKeyboardManager {
handleQuickKeyPress = async (
key: string,
isModifier?: boolean,
isSpecial?: boolean
isSpecial?: boolean,
isToggle?: boolean,
pasteText?: string
): Promise<void> => {
if (!this.inputManager) {
logger.error('No input manager found');
@ -468,15 +481,50 @@ export class DirectKeyboardManager {
}
return;
} else if (key === 'Paste') {
// Handle Paste key
try {
const text = await navigator.clipboard.readText();
if (text && this.inputManager) {
this.inputManager.sendInputText(text);
// Handle Paste key - following iOS Safari best practices
logger.log('Paste button pressed - attempting clipboard read');
// Log environment details for debugging
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) {
logger.error('Failed to read clipboard:', err);
} else {
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') {
// Send Ctrl+A (start of line)
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 {
// Exit keyboard mode
this.keyboardMode = false;
@ -705,6 +865,12 @@ export class DirectKeyboardManager {
this.captureClickHandler = null;
}
// Remove global paste listener
if (this.globalPasteHandler) {
document.removeEventListener('paste', this.globalPasteHandler);
this.globalPasteHandler = null;
}
// Remove hidden input if it exists
if (this.hiddenInput) {
this.hiddenInput.remove();

View file

@ -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;"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
autocapitalize="none"
spellcheck="false"
data-autocorrect="off"
data-gramm="false"
data-ms-editor="false"
data-smartpunctuation="false"
data-form-type="other"
inputmode="text"
enterkeyhint="done"
></textarea>
</div>

View file

@ -72,7 +72,9 @@ export class TerminalQuickKeys extends LitElement {
@property({ type: Function }) onKeyPress?: (
key: string,
isModifier?: boolean,
isSpecial?: boolean
isSpecial?: boolean,
isToggle?: boolean,
pasteText?: string
) => void;
@property({ type: Boolean }) visible = false;
@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) {
// Only enable key repeat for arrow keys
if (!key.startsWith('Arrow')) return;
@ -560,7 +572,11 @@ export class TerminalQuickKeys extends LitElement {
@touchend=${(e: Event) => {
e.preventDefault();
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) => {
if (e.detail !== 0) {

View file

@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { fixture, html } from '@open-wc/testing';
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';
// Mock xterm modules before importing the component
@ -140,7 +140,6 @@ describe('Terminal', () => {
it('should handle paste events', async () => {
const pasteText = 'pasted content';
// Create and dispatch paste event
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', pasteText);
const pasteEvent = new ClipboardEvent('paste', {
@ -149,11 +148,51 @@ describe('Terminal', () => {
cancelable: true,
});
// The terminal component listens for paste on the container
const container = element.querySelector('.terminal-container');
if (container) {
container.dispatchEvent(pasteEvent);
expect(container).toBeTruthy();
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(detail.text).toBe(pasteText);
expect(mockReadText).toHaveBeenCalled();
} finally {
// Restore original clipboard
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
});
}
});
});

View file

@ -1587,11 +1587,20 @@ export class Terminal extends LitElement {
this.requestUpdate();
};
private handlePaste = (e: ClipboardEvent) => {
private handlePaste = async (e: ClipboardEvent) => {
e.preventDefault();
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) {
// Dispatch a custom event with the pasted text
this.dispatchEvent(

View file

@ -28,6 +28,13 @@ describe('DirectKeyboardManager', () => {
writable: true,
configurable: true,
});
// Mock secure context for clipboard API to work
Object.defineProperty(window, 'isSecureContext', {
value: true,
writable: true,
configurable: true,
});
});
afterEach(() => {

View file

@ -117,7 +117,7 @@ describe.sequential('Logs API Tests', () => {
});
// Wait and retry for the log file to be written and flushed
let info: any;
let info: { exists: boolean; size: number };
let attempts = 0;
const maxAttempts = 10;
const retryDelay = 200;