diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 6e3bd5f..a6acb3e 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -166,6 +166,7 @@ export class InputHandler { private onKeyCallback?: (keyEvent: IKeyEvent) => void; private customKeyEventHandler?: (event: KeyboardEvent) => boolean; private getModeCallback?: (mode: number) => boolean; + private onCopyCallback?: () => boolean; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; @@ -184,6 +185,7 @@ export class InputHandler { * @param onKey - Optional callback for raw key events * @param customKeyEventHandler - Optional custom key event handler * @param getMode - Optional callback to query terminal mode state (for application cursor mode) + * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) */ constructor( ghostty: Ghostty, @@ -192,7 +194,8 @@ export class InputHandler { onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, - getMode?: (mode: number) => boolean + getMode?: (mode: number) => boolean, + onCopy?: () => boolean ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -201,6 +204,7 @@ export class InputHandler { this.onKeyCallback = onKey; this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; + this.onCopyCallback = onCopy; // Attach event listeners this.attach(); @@ -327,11 +331,15 @@ export class InputHandler { return; } - // Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) - // SelectionManager handles the actual copying + // Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) // Note: Ctrl+C on all platforms sends interrupt signal (0x03) if (event.metaKey && event.code === 'KeyC') { - // Let browser/SelectionManager handle copy + // Try to copy selection via callback + // If there's a selection and copy succeeds, prevent default + // If no selection, let it fall through (browser may have other text selected) + if (this.onCopyCallback && this.onCopyCallback()) { + event.preventDefault(); + } return; } diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index b54fc84..d88c66d 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -216,6 +216,21 @@ export class SelectionManager { ); } + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + copySelection(): boolean { + if (!this.hasSelection()) return false; + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + return true; + } + return false; + } + /** * Clear the selection */ @@ -841,26 +856,72 @@ export class SelectionManager { /** * Copy text to clipboard + * + * Strategy (modern APIs first): + * 1. Try ClipboardItem API (works in Safari and modern browsers) + * - Safari requires the ClipboardItem to be created synchronously within user gesture + * 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari) + * 3. Fall back to execCommand (legacy, for older browsers) */ - private async copyToClipboard(text: string): Promise { - // First try: modern async clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { + private copyToClipboard(text: string): void { + // First try: ClipboardItem API (modern, Safari-compatible) + // Safari allows this because we create the ClipboardItem synchronously + // within the user gesture, even though the write is async + if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { try { - await navigator.clipboard.writeText(text); + const blob = new Blob([text], { type: 'text/plain' }); + const clipboardItem = new ClipboardItem({ + 'text/plain': blob, + }); + navigator.clipboard.write([clipboardItem]).catch((err) => { + console.warn('ClipboardItem write failed, trying writeText:', err); + // Try writeText as fallback + this.copyWithWriteText(text); + }); return; } catch (err) { - // Clipboard API failed (common in non-HTTPS or non-focused contexts) - // Fall through to legacy method + // ClipboardItem not supported or failed, fall through } } - // Second try: legacy execCommand method via textarea + // Second try: basic async writeText (works in Chrome, may fail in Safari) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + // Fall back to execCommand + this.copyWithExecCommand(text); + }); + return; + } + + // Third try: legacy execCommand fallback + this.copyWithExecCommand(text); + } + + /** + * Copy using navigator.clipboard.writeText + */ + private copyWithWriteText(text: string): void { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + this.copyWithExecCommand(text); + }); + } else { + this.copyWithExecCommand(text); + } + } + + /** + * Copy using legacy execCommand (fallback for older browsers) + */ + private copyWithExecCommand(text: string): void { const previouslyFocused = document.activeElement as HTMLElement; try { // Position textarea offscreen but in a way that allows selection const textarea = this.textarea; textarea.value = text; - textarea.style.position = 'fixed'; // Avoid scrolling to bottom + textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '0'; textarea.style.width = '1px'; @@ -880,11 +941,11 @@ export class SelectionManager { } if (!success) { - console.error('❌ execCommand copy failed'); + console.warn('execCommand copy failed'); } } catch (err) { - console.error('❌ Fallback copy failed:', err); - // Still try to restore focus even on error + console.warn('execCommand copy threw:', err); + // Restore focus on error if (previouslyFocused) { previouslyFocused.focus(); } diff --git a/lib/terminal.ts b/lib/terminal.ts index 3c2fc2f..15e82de 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -445,6 +445,10 @@ export class Terminal implements ITerminalCore { (mode: number) => { // Query terminal mode state (e.g., mode 1 for application cursor mode) return this.wasmTerm?.getMode(mode, false) ?? false; + }, + () => { + // Handle Cmd+C copy - returns true if there was a selection to copy + return this.copySelection(); } ); @@ -755,6 +759,14 @@ export class Terminal implements ITerminalCore { this.selectionManager?.clearSelection(); } + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + public copySelection(): boolean { + return this.selectionManager?.copySelection() || false; + } + /** * Select all text in the terminal */