Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 75 additions & 10 deletions lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class SelectionManager {
private terminal: Terminal;
private renderer: CanvasRenderer;
private wasmTerm: GhosttyTerminal;
private textarea: HTMLTextAreaElement;

// Selection state
private selectionStart: { col: number; row: number } | null = null;
Expand All @@ -49,11 +50,18 @@ export class SelectionManager {

// Store bound event handlers for cleanup
private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null;

constructor(terminal: Terminal, renderer: CanvasRenderer, wasmTerm: GhosttyTerminal) {
private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null;

constructor(
terminal: Terminal,
renderer: CanvasRenderer,
wasmTerm: GhosttyTerminal,
textarea: HTMLTextAreaElement
) {
this.terminal = terminal;
this.renderer = renderer;
this.wasmTerm = wasmTerm;
this.textarea = textarea;

// Attach mouse event listeners
this.attachEventListeners();
Expand Down Expand Up @@ -253,6 +261,13 @@ export class SelectionManager {
this.boundMouseUpHandler = null;
}

// Clean up context menu event listener
if (this.boundContextMenuHandler) {
const canvas = this.renderer.getCanvas();
canvas.removeEventListener('contextmenu', this.boundContextMenuHandler);
this.boundContextMenuHandler = null;
}

// Canvas event listeners will be cleaned up when canvas is removed from DOM
}

Expand Down Expand Up @@ -345,17 +360,67 @@ export class SelectionManager {
}
});

// Right-click (context menu) - copy selection if exists
canvas.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault(); // Prevent default context menu

// Right-click (context menu) - position textarea to show browser's native menu
// This allows Copy/Paste options to appear in the context menu
this.boundContextMenuHandler = (e: MouseEvent) => {
// Position textarea at mouse cursor
const canvas = this.renderer.getCanvas();
const rect = canvas.getBoundingClientRect();

this.textarea.style.position = 'fixed';
this.textarea.style.left = `${e.clientX}px`;
this.textarea.style.top = `${e.clientY}px`;
this.textarea.style.width = '1px';
this.textarea.style.height = '1px';
this.textarea.style.zIndex = '1000';
this.textarea.style.opacity = '0';

// Enable pointer events temporarily so context menu targets the textarea
this.textarea.style.pointerEvents = 'auto';

// If there's a selection, populate textarea with it and select the text
if (this.hasSelection()) {
const text = this.getSelection();
if (text) {
this.copyToClipboard(text);
}
this.textarea.value = text;
this.textarea.select();
this.textarea.setSelectionRange(0, text.length);
} else {
// No selection - clear textarea but still show menu (for paste)
this.textarea.value = '';
}
});

// Focus the textarea so the context menu appears on it
this.textarea.focus();

// After a short delay, restore the textarea to its hidden state
// This allows the context menu to appear first
setTimeout(() => {
// Listen for when the context menu closes (user clicks away or selects an option)
const resetTextarea = () => {
this.textarea.style.pointerEvents = 'none';
this.textarea.style.zIndex = '-10';
this.textarea.style.width = '0';
this.textarea.style.height = '0';
this.textarea.style.left = '0';
this.textarea.style.top = '0';
this.textarea.value = '';

// Remove the one-time listeners
document.removeEventListener('click', resetTextarea);
document.removeEventListener('contextmenu', resetTextarea);
this.textarea.removeEventListener('blur', resetTextarea);
};

// Reset on any of these events (menu closed)
document.addEventListener('click', resetTextarea, { once: true });
document.addEventListener('contextmenu', resetTextarea, { once: true });
this.textarea.addEventListener('blur', resetTextarea, { once: true });
}, 10);

// Don't prevent default - let browser show the context menu on the textarea
};

canvas.addEventListener('contextmenu', this.boundContextMenuHandler);
}

/**
Expand Down
42 changes: 40 additions & 2 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,29 @@ export class Terminal implements ITerminalCore {
this.canvas.style.display = 'block';
parent.appendChild(this.canvas);

// Create hidden textarea for clipboard operations (xterm.js pattern)
// This textarea will be positioned under the mouse cursor during right-clicks
// to enable the browser's native context menu with Copy/Paste options
this.textarea = document.createElement('textarea');
this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false');
this.textarea.setAttribute('tabindex', '-1'); // Don't interfere with tab navigation
this.textarea.setAttribute('aria-label', 'Terminal input');
this.textarea.style.position = 'absolute';
this.textarea.style.left = '0';
this.textarea.style.top = '0';
this.textarea.style.width = '0';
this.textarea.style.height = '0';
this.textarea.style.zIndex = '-10';
this.textarea.style.opacity = '0';
this.textarea.style.overflow = 'hidden';
this.textarea.style.pointerEvents = 'none'; // Don't interfere with mouse events normally
this.textarea.style.resize = 'none';
this.textarea.style.border = 'none';
this.textarea.style.outline = 'none';
parent.appendChild(this.textarea);

// Create renderer
this.renderer = new CanvasRenderer(this.canvas, {
fontSize: this.options.fontSize,
Expand Down Expand Up @@ -192,8 +215,13 @@ export class Terminal implements ITerminalCore {
this.customKeyEventHandler
);

// Create selection manager
this.selectionManager = new SelectionManager(this, this.renderer, this.wasmTerm);
// Create selection manager (pass textarea for context menu positioning)
this.selectionManager = new SelectionManager(
this,
this.renderer,
this.wasmTerm,
this.textarea
);

// Connect selection manager to renderer
this.renderer.setSelectionManager(this.selectionManager);
Expand All @@ -203,6 +231,16 @@ export class Terminal implements ITerminalCore {
this.selectionChangeEmitter.fire();
});

// Setup paste event handler on textarea
this.textarea.addEventListener('paste', (e: ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData?.getData('text');
if (text) {
// Use the paste() method which will handle bracketed paste mode in the future
this.paste(text);
}
});

// Setup wheel event handling for scrolling (Phase 2)
// Use capture phase to ensure we get the event before browser scrolling
parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
Expand Down