diff --git a/addons/addon-clipboard/package.json b/addons/addon-clipboard/package.json index 176fa861a..eb207679d 100644 --- a/addons/addon-clipboard/package.json +++ b/addons/addon-clipboard/package.json @@ -21,8 +21,5 @@ "package": "../../node_modules/.bin/webpack", "prepublishOnly": "npm run package", "start": "node ../../demo/start" - }, - "dependencies": { - "js-base64": "^3.7.5" } } diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 58425924f..5e194880c 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -4,8 +4,7 @@ */ import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm'; -import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard'; -import { Base64 as JSBase64 } from 'js-base64'; +import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard'; export class ClipboardAddon implements ITerminalAddon { private _terminal?: Terminal; @@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon { return this._disposable?.dispose(); } - private _readText(sel: ClipboardSelectionType, data: string): void { + private _readText(sel: string, data: string): void { const b64 = this._base64.encodeText(data); this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false); } @@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon { return true; } - const pc = args[0] as ClipboardSelectionType; + const pc = args[0]; const pd = args[1]; if (pd === '?') { const text = this._provider.readText(pc); @@ -70,30 +69,23 @@ export class ClipboardAddon implements ITerminalAddon { } export class BrowserClipboardProvider implements IClipboardProvider { - public async readText(selection: ClipboardSelectionType): Promise { - if (selection !== 'c') { - return Promise.resolve(''); - } + public readText(selection: string): Promise { return navigator.clipboard.readText(); } - public async writeText(selection: ClipboardSelectionType, text: string): Promise { - if (selection !== 'c') { - return Promise.resolve(); - } + public writeText(selection: string, text: string): Promise { return navigator.clipboard.writeText(text); } } export class Base64 implements IBase64 { public encodeText(data: string): string { - return JSBase64.encode(data); + return btoa(data); } public decodeText(data: string): string { - const text = JSBase64.decode(data); - if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) { - return ''; - } - return text; + try { + return atob(data); + } catch {} + return ''; } } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index d4d86d46b..2c04e619b 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => { test.beforeEach(async ({}, testInfo) => { // DEBT: This test doesn't work since the migration to @playwright/test - if (ctx.browser.browserType().name() !== 'chromium') { + if (ctx.browser.browserType().name() === 'webkit') { testInfo.skip(); return; } @@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => { await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); }); + test('primary selection', async () => { + await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); + test('empty selection (default)', async () => { + await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); test('invalid base64 string', async () => { await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); @@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => { }); test.describe('read data', async function (): Promise { - test('simple string', async () => { + test.beforeAll(async () => { await ctx.page.evaluate(` window.data = []; window.term.onData(e => data.push(e)); `); + }); + test('simple string', async () => { + await ctx.page.evaluate('window.data.length = 0;'); await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world')); await ctx.proxy.write(`\x1b]52;c;?\x07`); deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]); }); + test('primary selection', async () => { + await ctx.page.evaluate('window.data.length = 0;'); + await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world')); + await ctx.proxy.write(`\x1b]52;p;?\x07`); + deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]); + }); test('clear clipboard', async () => { await ctx.proxy.write(`\x1b]52;c;!\x07`); await ctx.proxy.write(`\x1b]52;c;?\x07`); diff --git a/addons/addon-clipboard/typings/addon-clipboard.d.ts b/addons/addon-clipboard/typings/addon-clipboard.d.ts index 4ef12e0f7..f8e024ac3 100644 --- a/addons/addon-clipboard/typings/addon-clipboard.d.ts +++ b/addons/addon-clipboard/typings/addon-clipboard.d.ts @@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' { public dispose(): void } - /** - * Clipboard selection type. This is used to specify which selection buffer to - * read or write to. - * - SYSTEM `c`: The system clipboard. - * - PRIMARY `p`: The primary clipboard. This is provided for compatibility - * with Linux X11. - */ - export const enum ClipboardSelectionType { - SYSTEM = 'c', - PRIMARY = 'p', - } - export interface IBase64 { /** * Converts a utf-8 string to a base64 string. @@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' { export interface IClipboardProvider { /** * Gets the clipboard content. - * @param selection The clipboard selection to read. + * @param selection The clipboard selection to read (see OSC 52 spec of xterm). * @returns A promise that resolves with clipboard selection data. */ - readText(selection: ClipboardSelectionType): string | Promise; + readText(selection: string): string | Promise; /** * Sets the clipboard content. - * @param selection The clipboard selection to set. + * @param selection The clipboard selection to set (see OSC 52 spec of xterm). * @param data The clipboard text to write. */ - writeText(selection: ClipboardSelectionType, text: string): void | Promise; + writeText(selection: string, text: string): void | Promise; } /** - * The clipboard provider interface that enables xterm.js to access the system clipboard. + * The clipboard provider interface that enables xterm.js to access the browser clipboard. + * + * Note this this clipboard provider does not implement own cut buffers as xterm does, + * but redirect the selection parameter always to navigator.clipboard. */ export class BrowserClipboardProvider implements IClipboardProvider{ /** * Reads text from the clipboard. - * @param selection The selection type to read from. + * @param selection The selection type to read from (always refers to navigator.clipboard). * @returns A promise that resolves with the text from the clipboard. */ - public readText(selection: ClipboardSelectionType): Promise; + public readText(selection: string): Promise; /** * Writes text to the clipboard. - * @param selection The selection type to write to. + * @param selection The selection type to write to (always refers to navigator.clipboard). * @param data The text to write to the clipboard. * @returns A promise that resolves when the text has been written to the clipboard. */ - public writeText(selection: ClipboardSelectionType, data: string): Promise; + public writeText(selection: string, data: string): Promise; } } diff --git a/package-lock.json b/package-lock.json index cccdca03f..1ff9efd73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,10 +65,7 @@ "addons/addon-clipboard": { "name": "@xterm/addon-clipboard", "version": "0.2.0", - "license": "MIT", - "dependencies": { - "js-base64": "^3.7.5" - } + "license": "MIT" }, "addons/addon-fit": { "name": "@xterm/addon-fit", @@ -5506,11 +5503,20 @@ "node": ">= 10.13.0" } }, - "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", - "license": "BSD-3-Clause" + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, "node_modules/js-tokens": { "version": "4.0.0", diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index b5aa9d643..7646c052b 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -453,9 +453,29 @@ describe('KittyKeyboard', () => { describe('event types (press/repeat/release)', () => { const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; - it('press event (default, no suffix)', () => { + it('UTF-8 text press event', () => { const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS); - assert.strictEqual(result.key, '\x1b[97u'); + assert.strictEqual(result.key, 'a'); + }); + + it('Escape key press event (default, no suffix)', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('Enter key press event → legacy \\r', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\r'); + }); + + it('Tab key press event → legacy \\t', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\t'); + }); + + it('Backspace key press event → legacy \\x7f', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x7f'); }); it('press event explicit :1 when modifiers present', () => { @@ -463,9 +483,29 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, '\x1b[97;5u'); }); - it('repeat event → :2 suffix', () => { + it('UTF-8 text repeat event', () => { const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT); - assert.strictEqual(result.key, '\x1b[97;1:2u'); + assert.strictEqual(result.key, 'a'); + }); + + it('Escape key repeat event → :2 suffix', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[27;1:2u'); + }); + + it('Enter key repeat event → legacy \\r', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\r'); + }); + + it('Tab key repeat event → legacy \\t', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\t'); + }); + + it('Backspace key repeat event → legacy \\x7f', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x7f'); }); it('release event → :3 suffix', () => { @@ -473,6 +513,26 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, '\x1b[97;1:3u'); }); + it('Escape key release event → :3 suffix', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[27;1:3u'); + }); + + it('Enter key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + + it('Tab key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + + it('Backspace key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + it('release with modifier → mod:3', () => { const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[97;5:3u'); @@ -563,6 +623,55 @@ describe('KittyKeyboard', () => { }); }); + describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag with REPORT_EVENT_TYPES', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('Enter key press event → CSI 13 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[13u'); + }); + + it('Tab key press event → CSI 9 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[9u'); + }); + + it('Backspace key press event → CSI 127 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[127u'); + }); + + it('Enter key repeat event → CSI 13;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[13;1:2u'); + }); + + it('Tab key repeat event → CSI 9;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[9;1:2u'); + }); + + it('Backspace key repeat event → CSI 127;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[127;1:2u'); + }); + + it('Enter key release event → CSI 13;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[13;1:3u'); + }); + + it('Tab key release event → CSI 9;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[9;1:3u'); + }); + + it('Backspace key release event → CSI 127;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[127;1:3u'); + }); + }); + describe('REPORT_ASSOCIATED_TEXT flag', () => { const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index e34f47070..f35cfebd5 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -471,29 +471,33 @@ export class KittyKeyboard { return result; } + // Special handling for Enter/Tab/Backspace. + const specialKey = keyCode === 13 || keyCode === 9 || keyCode === 127; + + // Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as escape + // codes" is also set. + if (specialKey && eventType === KittyKeyboardEventType.RELEASE && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) { + return result; + } + const isFunc = this._functionalKeyCodes[ev.key] !== undefined || this._getNumpadKeyCode(ev) !== undefined; - let useCsiU = false; - - if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) { - useCsiU = true; - } else if (reportEventTypes) { - useCsiU = true; - } else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) { - // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy - // mode" and consider space to be a text-generating key, so these skip the isFunc fast-path - // and only get CSI u when modifiers are present (handled below). - const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127; - if (isFunc && !isDisambiguateLegacy) { - useCsiU = true; - } else if (modifiers > 0) { - if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) { - useCsiU = false; - } else { - useCsiU = true; - } - } - } + const useCsiU = !!( + flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES || + (reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) || + (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES && + ( + // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and + // consider space to be a text-generating key, so these skip the isFunc fast-path and only + // get CSI u when modifiers are present (handled below). + (isFunc && !specialKey) || + ( + (modifiers > 0 && ev.key.length !== 1) || + modifiers - 1 > KittyKeyboardModifiers.SHIFT + ) + ) + ) + ); if (useCsiU) { result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);