diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b01dc0..46c72308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [Unreleased] + +### Changed + +- **`attachCustomKeyEventHandler` no longer calls `preventDefault()` for you.** + A handler that returns `true` now takes the event away from the terminal + completely: it is not encoded, and the browser default is left untouched for + the handler to decide on. + + Previously there was no way to let a browser-reserved chord through — a + handler returning `true` had the default blocked for it, and returning + `false` ran the encoder path, which blocks it too. A page embedding the + terminal therefore could not keep Cmd+R (reload), Cmd+L (address bar) or the + zoom chords working while the terminal had focus. + + **Migration:** a handler that returns `true` and relied on the terminal to + suppress the default must now call `event.preventDefault()` itself. Handlers + that return `false` are unaffected. + +--- + ## [0.4.0] — 2026-05-24 This release is a major feature expansion maintained by the diff --git a/README.md b/README.md index 1d2400fa..0604ea4e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,36 @@ If you need byte-for-byte xterm.js behavior for a specific key (e.g. Shift+Enter term.attachCustomKeyEventHandler((e) => { if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { term.input('\r', true); // fires onData with '\r' - return true; // suppress the default encoder path + e.preventDefault(); // the terminal no longer does this for you + return true; // skip the default encoder path + } + return false; +}); +``` + +### Browser-reserved shortcuts + +Returning `true` takes the key away from the terminal completely — it is not +encoded, and `preventDefault()` is **not** called on your behalf. That is what +lets an embedding page keep browser chords working while the terminal has +focus; without it, a focused terminal swallows Cmd+R, Cmd+L and friends. + +```ts +const isMac = navigator.userAgent.includes('Mac'); + +// Copy and paste are handled by the terminal itself (after this handler runs), +// so they must not be handed back to the browser here. +const TERMINAL_OWNED = new Set(['KeyC', 'KeyV']); + +term.attachCustomKeyEventHandler((e) => { + if (TERMINAL_OWNED.has(e.code)) return false; + + // Let the browser reload, focus the address bar, zoom, and so on. On macOS + // no Cmd chord has a PTY meaning; elsewhere plain Ctrl+R belongs to the + // shell (reverse-i-search), so only the Shift variant is handed back. + const reserved = isMac ? e.metaKey : e.ctrlKey && e.shiftKey; + if (reserved) { + return true; // consumed by "do nothing" — the browser default survives } return false; }); diff --git a/lib/input-handler.test.ts b/lib/input-handler.test.ts index e43c5698..158b6a42 100644 --- a/lib/input-handler.test.ts +++ b/lib/input-handler.test.ts @@ -996,6 +996,70 @@ describe('InputHandler', () => { }); }); + describe('Custom Key Event Handler', () => { + test('a consumed key is not encoded and its browser default is left alone', () => { + new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + () => true + ); + + // Cmd+R: the page's reload chord. The handler claims it, so the terminal + // must neither send bytes to the PTY nor block the browser's reload. + const event = createKeyEvent('KeyR', 'r', { meta: true }); + simulateKey(container, event); + + expect(dataReceived).toEqual([]); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + test('a consumed key can still be suppressed by the handler itself', () => { + new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + (e) => { + e.preventDefault(); + return true; + } + ); + + const event = createKeyEvent('KeyK', 'k', { meta: true }); + simulateKey(container, event); + + expect(dataReceived).toEqual([]); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + test('a declined key falls through to the encoder and is suppressed', () => { + new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + () => false + ); + + const event = createKeyEvent('KeyA', 'a'); + simulateKey(container, event); + + expect(dataReceived).toEqual(['a']); + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + describe('Unknown Keys', () => { test('ignores unmapped keys', () => { const handler = new InputHandler( diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 2de9c01b..39b55599 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -10,7 +10,9 @@ * * Limitations: * - Does not handle IME/composition events (CJK input) - to be added later - * - Captures all keyboard input (preventDefault on everything) + * - Captures all keyboard input it encodes (preventDefault on everything + * that reaches the encoder). A custom key event handler can opt a chord + * out of that, including out of preventDefault — see setCustomKeyEventHandler. */ import type { Ghostty, KeyEncoder } from './ghostty'; @@ -261,7 +263,15 @@ export class InputHandler { } /** - * Set custom key event handler (for runtime updates) + * Set custom key event handler (for runtime updates). + * + * The handler returns whether it consumed the event. Returning `true` takes + * the key away from the terminal entirely: no encoding, and no + * `preventDefault()` — so the handler decides what happens to the browser + * default. Call `event.preventDefault()` inside the handler to suppress it, + * or leave it alone to let a browser-reserved chord (Cmd+R, Cmd+L) work. + * Returning `false` runs the normal encoder path, which suppresses the + * default as before. */ setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void { this.customKeyEventHandler = handler; @@ -406,8 +416,16 @@ export class InputHandler { if (this.customKeyEventHandler) { const handled = this.customKeyEventHandler(event); if (handled) { - // Custom handler consumed the event - event.preventDefault(); + // The consumer owns this event: the terminal neither encodes it nor + // touches the browser default. Handlers that want the default + // suppressed call event.preventDefault() themselves. + // + // Calling preventDefault() here unconditionally left consumers with no + // way to pass a browser-reserved chord through: returning true blocked + // the default, and returning false ran the encoder path, which blocks + // it too. A page embedding the terminal could therefore never keep + // Cmd+R (reload) or Cmd+L (focus the address bar) working while the + // terminal had focus. return; } } diff --git a/lib/terminal.ts b/lib/terminal.ts index 8fe91283..4dd6b92e 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -826,6 +826,15 @@ export class Terminal extends TerminalCore { // Custom Event Handlers // ========================================================================== + /** + * Attach a handler that sees every keydown before the terminal encodes it. + * + * Return `true` to consume the event: the terminal will not encode it and + * will not call `preventDefault()`, leaving the browser default to the + * handler. Suppress it with `event.preventDefault()`, or leave it alone to + * let browser-reserved chords (Cmd+R, Cmd+L, zoom) keep working while the + * terminal has focus. Return `false` for normal terminal handling. + */ public attachCustomKeyEventHandler( customKeyEventHandler: (event: KeyboardEvent) => boolean ): void {