Skip to content

Commit e3ec592

Browse files
authored
Fixes for copying on Safari & Firefox (#94)
1 parent c7e37fb commit e3ec592

3 files changed

Lines changed: 96 additions & 15 deletions

File tree

lib/input-handler.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export class InputHandler {
166166
private onKeyCallback?: (keyEvent: IKeyEvent) => void;
167167
private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
168168
private getModeCallback?: (mode: number) => boolean;
169+
private onCopyCallback?: () => boolean;
169170
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
170171
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
171172
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
@@ -184,6 +185,7 @@ export class InputHandler {
184185
* @param onKey - Optional callback for raw key events
185186
* @param customKeyEventHandler - Optional custom key event handler
186187
* @param getMode - Optional callback to query terminal mode state (for application cursor mode)
188+
* @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection)
187189
*/
188190
constructor(
189191
ghostty: Ghostty,
@@ -192,7 +194,8 @@ export class InputHandler {
192194
onBell: () => void,
193195
onKey?: (keyEvent: IKeyEvent) => void,
194196
customKeyEventHandler?: (event: KeyboardEvent) => boolean,
195-
getMode?: (mode: number) => boolean
197+
getMode?: (mode: number) => boolean,
198+
onCopy?: () => boolean
196199
) {
197200
this.encoder = ghostty.createKeyEncoder();
198201
this.container = container;
@@ -201,6 +204,7 @@ export class InputHandler {
201204
this.onKeyCallback = onKey;
202205
this.customKeyEventHandler = customKeyEventHandler;
203206
this.getModeCallback = getMode;
207+
this.onCopyCallback = onCopy;
204208

205209
// Attach event listeners
206210
this.attach();
@@ -327,11 +331,15 @@ export class InputHandler {
327331
return;
328332
}
329333

330-
// Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
331-
// SelectionManager handles the actual copying
334+
// Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
332335
// Note: Ctrl+C on all platforms sends interrupt signal (0x03)
333336
if (event.metaKey && event.code === 'KeyC') {
334-
// Let browser/SelectionManager handle copy
337+
// Try to copy selection via callback
338+
// If there's a selection and copy succeeds, prevent default
339+
// If no selection, let it fall through (browser may have other text selected)
340+
if (this.onCopyCallback && this.onCopyCallback()) {
341+
event.preventDefault();
342+
}
335343
return;
336344
}
337345

lib/selection-manager.ts

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,21 @@ export class SelectionManager {
216216
);
217217
}
218218

219+
/**
220+
* Copy the current selection to clipboard
221+
* @returns true if there was text to copy, false otherwise
222+
*/
223+
copySelection(): boolean {
224+
if (!this.hasSelection()) return false;
225+
226+
const text = this.getSelection();
227+
if (text) {
228+
this.copyToClipboard(text);
229+
return true;
230+
}
231+
return false;
232+
}
233+
219234
/**
220235
* Clear the selection
221236
*/
@@ -841,26 +856,72 @@ export class SelectionManager {
841856

842857
/**
843858
* Copy text to clipboard
859+
*
860+
* Strategy (modern APIs first):
861+
* 1. Try ClipboardItem API (works in Safari and modern browsers)
862+
* - Safari requires the ClipboardItem to be created synchronously within user gesture
863+
* 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari)
864+
* 3. Fall back to execCommand (legacy, for older browsers)
844865
*/
845-
private async copyToClipboard(text: string): Promise<void> {
846-
// First try: modern async clipboard API
847-
if (navigator.clipboard && navigator.clipboard.writeText) {
866+
private copyToClipboard(text: string): void {
867+
// First try: ClipboardItem API (modern, Safari-compatible)
868+
// Safari allows this because we create the ClipboardItem synchronously
869+
// within the user gesture, even though the write is async
870+
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
848871
try {
849-
await navigator.clipboard.writeText(text);
872+
const blob = new Blob([text], { type: 'text/plain' });
873+
const clipboardItem = new ClipboardItem({
874+
'text/plain': blob,
875+
});
876+
navigator.clipboard.write([clipboardItem]).catch((err) => {
877+
console.warn('ClipboardItem write failed, trying writeText:', err);
878+
// Try writeText as fallback
879+
this.copyWithWriteText(text);
880+
});
850881
return;
851882
} catch (err) {
852-
// Clipboard API failed (common in non-HTTPS or non-focused contexts)
853-
// Fall through to legacy method
883+
// ClipboardItem not supported or failed, fall through
854884
}
855885
}
856886

857-
// Second try: legacy execCommand method via textarea
887+
// Second try: basic async writeText (works in Chrome, may fail in Safari)
888+
if (navigator.clipboard && navigator.clipboard.writeText) {
889+
navigator.clipboard.writeText(text).catch((err) => {
890+
console.warn('Clipboard writeText failed, trying execCommand:', err);
891+
// Fall back to execCommand
892+
this.copyWithExecCommand(text);
893+
});
894+
return;
895+
}
896+
897+
// Third try: legacy execCommand fallback
898+
this.copyWithExecCommand(text);
899+
}
900+
901+
/**
902+
* Copy using navigator.clipboard.writeText
903+
*/
904+
private copyWithWriteText(text: string): void {
905+
if (navigator.clipboard && navigator.clipboard.writeText) {
906+
navigator.clipboard.writeText(text).catch((err) => {
907+
console.warn('Clipboard writeText failed, trying execCommand:', err);
908+
this.copyWithExecCommand(text);
909+
});
910+
} else {
911+
this.copyWithExecCommand(text);
912+
}
913+
}
914+
915+
/**
916+
* Copy using legacy execCommand (fallback for older browsers)
917+
*/
918+
private copyWithExecCommand(text: string): void {
858919
const previouslyFocused = document.activeElement as HTMLElement;
859920
try {
860921
// Position textarea offscreen but in a way that allows selection
861922
const textarea = this.textarea;
862923
textarea.value = text;
863-
textarea.style.position = 'fixed'; // Avoid scrolling to bottom
924+
textarea.style.position = 'fixed';
864925
textarea.style.left = '-9999px';
865926
textarea.style.top = '0';
866927
textarea.style.width = '1px';
@@ -880,11 +941,11 @@ export class SelectionManager {
880941
}
881942

882943
if (!success) {
883-
console.error('❌ execCommand copy failed');
944+
console.warn('execCommand copy failed');
884945
}
885946
} catch (err) {
886-
console.error('❌ Fallback copy failed:', err);
887-
// Still try to restore focus even on error
947+
console.warn('execCommand copy threw:', err);
948+
// Restore focus on error
888949
if (previouslyFocused) {
889950
previouslyFocused.focus();
890951
}

lib/terminal.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,10 @@ export class Terminal implements ITerminalCore {
445445
(mode: number) => {
446446
// Query terminal mode state (e.g., mode 1 for application cursor mode)
447447
return this.wasmTerm?.getMode(mode, false) ?? false;
448+
},
449+
() => {
450+
// Handle Cmd+C copy - returns true if there was a selection to copy
451+
return this.copySelection();
448452
}
449453
);
450454

@@ -755,6 +759,14 @@ export class Terminal implements ITerminalCore {
755759
this.selectionManager?.clearSelection();
756760
}
757761

762+
/**
763+
* Copy the current selection to clipboard
764+
* @returns true if there was text to copy, false otherwise
765+
*/
766+
public copySelection(): boolean {
767+
return this.selectionManager?.copySelection() || false;
768+
}
769+
758770
/**
759771
* Select all text in the terminal
760772
*/

0 commit comments

Comments
 (0)