From 5178f6ad8577064ade2b47388f33e59320cb1ca7 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 21:11:24 +0000 Subject: [PATCH] feat: add right click menu --- lib/selection-manager.ts | 85 +++++++++++++++++++++++++++++++++++----- lib/terminal.ts | 42 +++++++++++++++++++- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 193fcc2..7ba61bf 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -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; @@ -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(); @@ -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 } @@ -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); } /** diff --git a/lib/terminal.ts b/lib/terminal.ts index f304c7a..25d7661 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -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, @@ -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); @@ -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 });