Skip to content

Commit 9f5523b

Browse files
authored
feat: add right click menu (#36)
1 parent 776140f commit 9f5523b

File tree

2 files changed

+115
-12
lines changed

2 files changed

+115
-12
lines changed

lib/selection-manager.ts

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class SelectionManager {
3535
private terminal: Terminal;
3636
private renderer: CanvasRenderer;
3737
private wasmTerm: GhosttyTerminal;
38+
private textarea: HTMLTextAreaElement;
3839

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

5051
// Store bound event handlers for cleanup
5152
private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null;
52-
53-
constructor(terminal: Terminal, renderer: CanvasRenderer, wasmTerm: GhosttyTerminal) {
53+
private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null;
54+
55+
constructor(
56+
terminal: Terminal,
57+
renderer: CanvasRenderer,
58+
wasmTerm: GhosttyTerminal,
59+
textarea: HTMLTextAreaElement
60+
) {
5461
this.terminal = terminal;
5562
this.renderer = renderer;
5663
this.wasmTerm = wasmTerm;
64+
this.textarea = textarea;
5765

5866
// Attach mouse event listeners
5967
this.attachEventListeners();
@@ -253,6 +261,13 @@ export class SelectionManager {
253261
this.boundMouseUpHandler = null;
254262
}
255263

264+
// Clean up context menu event listener
265+
if (this.boundContextMenuHandler) {
266+
const canvas = this.renderer.getCanvas();
267+
canvas.removeEventListener('contextmenu', this.boundContextMenuHandler);
268+
this.boundContextMenuHandler = null;
269+
}
270+
256271
// Canvas event listeners will be cleaned up when canvas is removed from DOM
257272
}
258273

@@ -345,17 +360,67 @@ export class SelectionManager {
345360
}
346361
});
347362

348-
// Right-click (context menu) - copy selection if exists
349-
canvas.addEventListener('contextmenu', (e: MouseEvent) => {
350-
e.preventDefault(); // Prevent default context menu
351-
363+
// Right-click (context menu) - position textarea to show browser's native menu
364+
// This allows Copy/Paste options to appear in the context menu
365+
this.boundContextMenuHandler = (e: MouseEvent) => {
366+
// Position textarea at mouse cursor
367+
const canvas = this.renderer.getCanvas();
368+
const rect = canvas.getBoundingClientRect();
369+
370+
this.textarea.style.position = 'fixed';
371+
this.textarea.style.left = `${e.clientX}px`;
372+
this.textarea.style.top = `${e.clientY}px`;
373+
this.textarea.style.width = '1px';
374+
this.textarea.style.height = '1px';
375+
this.textarea.style.zIndex = '1000';
376+
this.textarea.style.opacity = '0';
377+
378+
// Enable pointer events temporarily so context menu targets the textarea
379+
this.textarea.style.pointerEvents = 'auto';
380+
381+
// If there's a selection, populate textarea with it and select the text
352382
if (this.hasSelection()) {
353383
const text = this.getSelection();
354-
if (text) {
355-
this.copyToClipboard(text);
356-
}
384+
this.textarea.value = text;
385+
this.textarea.select();
386+
this.textarea.setSelectionRange(0, text.length);
387+
} else {
388+
// No selection - clear textarea but still show menu (for paste)
389+
this.textarea.value = '';
357390
}
358-
});
391+
392+
// Focus the textarea so the context menu appears on it
393+
this.textarea.focus();
394+
395+
// After a short delay, restore the textarea to its hidden state
396+
// This allows the context menu to appear first
397+
setTimeout(() => {
398+
// Listen for when the context menu closes (user clicks away or selects an option)
399+
const resetTextarea = () => {
400+
this.textarea.style.pointerEvents = 'none';
401+
this.textarea.style.zIndex = '-10';
402+
this.textarea.style.width = '0';
403+
this.textarea.style.height = '0';
404+
this.textarea.style.left = '0';
405+
this.textarea.style.top = '0';
406+
this.textarea.value = '';
407+
408+
// Remove the one-time listeners
409+
document.removeEventListener('click', resetTextarea);
410+
document.removeEventListener('contextmenu', resetTextarea);
411+
this.textarea.removeEventListener('blur', resetTextarea);
412+
};
413+
414+
// Reset on any of these events (menu closed)
415+
document.addEventListener('click', resetTextarea, { once: true });
416+
document.addEventListener('contextmenu', resetTextarea, { once: true });
417+
this.textarea.addEventListener('blur', resetTextarea, { once: true });
418+
}, 10);
419+
420+
// Don't prevent default - let browser show the context menu on the textarea
421+
};
422+
423+
canvas.addEventListener('contextmenu', this.boundContextMenuHandler);
359424
}
360425

361426
/**

lib/terminal.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,29 @@ export class Terminal implements ITerminalCore {
157157
this.canvas.style.display = 'block';
158158
parent.appendChild(this.canvas);
159159

160+
// Create hidden textarea for clipboard operations (xterm.js pattern)
161+
// This textarea will be positioned under the mouse cursor during right-clicks
162+
// to enable the browser's native context menu with Copy/Paste options
163+
this.textarea = document.createElement('textarea');
164+
this.textarea.setAttribute('autocorrect', 'off');
165+
this.textarea.setAttribute('autocapitalize', 'off');
166+
this.textarea.setAttribute('spellcheck', 'false');
167+
this.textarea.setAttribute('tabindex', '-1'); // Don't interfere with tab navigation
168+
this.textarea.setAttribute('aria-label', 'Terminal input');
169+
this.textarea.style.position = 'absolute';
170+
this.textarea.style.left = '0';
171+
this.textarea.style.top = '0';
172+
this.textarea.style.width = '0';
173+
this.textarea.style.height = '0';
174+
this.textarea.style.zIndex = '-10';
175+
this.textarea.style.opacity = '0';
176+
this.textarea.style.overflow = 'hidden';
177+
this.textarea.style.pointerEvents = 'none'; // Don't interfere with mouse events normally
178+
this.textarea.style.resize = 'none';
179+
this.textarea.style.border = 'none';
180+
this.textarea.style.outline = 'none';
181+
parent.appendChild(this.textarea);
182+
160183
// Create renderer
161184
this.renderer = new CanvasRenderer(this.canvas, {
162185
fontSize: this.options.fontSize,
@@ -192,8 +215,13 @@ export class Terminal implements ITerminalCore {
192215
this.customKeyEventHandler
193216
);
194217

195-
// Create selection manager
196-
this.selectionManager = new SelectionManager(this, this.renderer, this.wasmTerm);
218+
// Create selection manager (pass textarea for context menu positioning)
219+
this.selectionManager = new SelectionManager(
220+
this,
221+
this.renderer,
222+
this.wasmTerm,
223+
this.textarea
224+
);
197225

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

234+
// Setup paste event handler on textarea
235+
this.textarea.addEventListener('paste', (e: ClipboardEvent) => {
236+
e.preventDefault();
237+
const text = e.clipboardData?.getData('text');
238+
if (text) {
239+
// Use the paste() method which will handle bracketed paste mode in the future
240+
this.paste(text);
241+
}
242+
});
243+
206244
// Setup wheel event handling for scrolling (Phase 2)
207245
// Use capture phase to ensure we get the event before browser scrolling
208246
parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });

0 commit comments

Comments
 (0)