diff --git a/.eslintrc.json b/.eslintrc.json index f234e93cc5..20b111cbce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,7 @@ "test/benchmark/tsconfig.json", "addons/xterm-addon-attach/src/tsconfig.json", "addons/xterm-addon-fit/src/tsconfig.json", + "addons/xterm-addon-hyperlinks/src/tsconfig.json", "addons/xterm-addon-search/src/tsconfig.json", "addons/xterm-addon-unicode11/src/tsconfig.json", "addons/xterm-addon-web-links/src/tsconfig.json", diff --git a/addons/xterm-addon-hyperlinks/.gitignore b/addons/xterm-addon-hyperlinks/.gitignore new file mode 100644 index 0000000000..3063f07d55 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules diff --git a/addons/xterm-addon-hyperlinks/.npmignore b/addons/xterm-addon-hyperlinks/.npmignore new file mode 100644 index 0000000000..1c79444565 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/.npmignore @@ -0,0 +1,5 @@ +**/*.api.js +**/*.api.ts +tsconfig.json +.yarnrc +webpack.config.js diff --git a/addons/xterm-addon-hyperlinks/LICENSE b/addons/xterm-addon-hyperlinks/LICENSE new file mode 100644 index 0000000000..c1735c41a7 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/xterm-addon-hyperlinks/README.md b/addons/xterm-addon-hyperlinks/README.md new file mode 100644 index 0000000000..a6db2910e8 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/README.md @@ -0,0 +1,18 @@ +## xterm-addon-hyperlinks + +An addon providing support for the hyperlinks terminal sequence with `OSC 8`. + +### Install + +```bash +npm install --save xterm-addon-hyperlinks +``` + +### Usage + +```ts +import { Terminal } from 'xterm'; +import { HyperlinksAddon } from 'xterm-addon-hyperlinks'; + +// TODO... +``` diff --git a/addons/xterm-addon-hyperlinks/package.json b/addons/xterm-addon-hyperlinks/package.json new file mode 100644 index 0000000000..2caec78e99 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/package.json @@ -0,0 +1,21 @@ +{ + "name": "xterm-addon-hyperlinks", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-hyperlinks.js", + "types": "typings/xterm-addon-hyperlinks.d.ts", + "repository": "https://github.com/xtermjs/xterm.js", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "^4.0.0" + } +} diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts new file mode 100644 index 0000000000..7282546e7c --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { openTerminal, getBrowserType } from '../../../out-test/api/TestUtils'; +import { Browser, Page } from 'playwright-core'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: Browser; +let page: Page; +const width = 800; +const height = 600; + +describe('HyperlinksAddon', () => { + before(async function(): Promise<any> { + const browserType = getBrowserType(); + browser = await browserType.launch({ dumpio: true, + headless: process.argv.indexOf('--headless') !== -1 + }); + page = await (await browser.newContext()).newPage(); + await page.setViewportSize({ width, height }); + }); + + after(async () => { + await browser.close(); + }); + + beforeEach(async function(): Promise<any> { + await page.goto(APP); + await openTerminal(page); + }); + + // TODO: write some tests +}); diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts new file mode 100644 index 0000000000..1e06f188f3 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts @@ -0,0 +1,302 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * UnicodeVersionProvider for V11. + */ + +import { Terminal, ITerminalAddon, IDisposable, IBufferRange, ILinkProvider, IBufferCellPosition, ILink } from 'xterm'; + +/** + * TODO: + * Need the following changes in xterm.js: + * - allow LinkProvider callback to contain multiple ranges (currently sloppy hacked into Linkifier2) + * - make extended attributes contributable by outer code + * - extend public API by extended attributes + */ + +/** + * Configurable scheme link handler. + */ +interface ISchemeHandler { + matcher: RegExp; + opener: (event: MouseEvent, text: string) => void; +} + +interface IUrlWithHandler { + url: string; + schemeHandler: ISchemeHandler; +} + + +/** + * Default link handler. Opens the link in a new browser tab. + * Used by the default scheme handlers for http, https and ftp. + */ +function handleLink(event: MouseEvent, uri: string): void { + const newWindow = window.open(); + if (newWindow) { + newWindow.opener = null; + newWindow.location.href = uri; + } else { + console.warn('Opening link blocked as opener could not be cleared'); + } +} + + +/** + * Some default scheme handlers. Handled in-browser. + */ +export const DEFAULT_SCHEMES = { + HTTP: {matcher: new RegExp('^http://.*'), opener: handleLink}, + HTTPS: {matcher: new RegExp('^http://.*'), opener: handleLink}, + FTP: {matcher: new RegExp('^ftp://.*'), opener: handleLink} +}; + + +// limit stored URLs to avoid OOM +// cached by the addon (FIFO): limit <= n <= limit * 2 +const CACHE_LIMIT = 500; +// values taken from spec as used by VTE +const MAX_URL_LENGTH = 2083; +const MAX_ID_LENGTH = 250; + + +export class HyperlinksAddon implements ITerminalAddon { + private _oscHandler: IDisposable | undefined; + private _linkProvider: IDisposable | undefined; + private _internalId = 1; + private _lowestId = 1; + private _idMap: Map<string, number> = new Map(); + private _urlMap: Map<number, IUrlWithHandler> = new Map(); + private _schemes: ISchemeHandler[] = []; + + constructor( + public cacheLimit: number = CACHE_LIMIT, + public maxUrlLength: number = MAX_URL_LENGTH, + public maxIdLength: number = MAX_ID_LENGTH + ) {} + + /** + * Parse params part of the data. + * Converts into a key-value mapping object if: + * - fully empty (returns empty object) + * - all keys and values are set + * - key-value pairs are properly separated by ':' + * - keys and values are separated by '=' + * - a key-value pair can be empty + * Any other case will drop to `false` as return value. + */ + private _parseParams(paramString: string): {[key: string]: string} | void { + const result: {[key: string]: string} = {}; + const params = paramString.split(':').filter(Boolean).map(el => el.split('=')); + for (const p of params) { + if (p.length !== 2) { + return; + } + const [key, value] = p; + if (!key || !value || value.length > this.maxIdLength) { + return; + } + result[key] = value; + } + return result; + } + + /** + * Method to filter allowed URL schemes. + * Returns the url with the matching handler, or nothing. + */ + private _filterUrl(urlString: string): IUrlWithHandler | void { + if (!urlString || urlString.length > this.maxUrlLength) { + return; + } + for (const schemeHandler of this._schemes) { + const m = urlString.match(schemeHandler.matcher); + if (m) { + return {url: m[0], schemeHandler}; + } + } + return; + } + + /** + * Update the terminal cell attributes. + * `hoverId` is used to identify cells of the same link. + * A `hoverId` of 0 disables any link handling of a cell (default). + */ + private _updateAttrs(terminal: Terminal, hoverId: number = 0): void { + // hack: remove url notion from extended attrs by private access + // TODO: API to contribute to extended attributes + const attr = (terminal as any)._core._inputHandler._curAttrData; + attr.extended = attr.extended.clone(); + attr.extended.urlId = hoverId; + attr.updateExtended(); + } + + private _limitCache(): void { + if (this._internalId - this._lowestId > this.cacheLimit * 2) { + this._lowestId = this._internalId - this.cacheLimit; + [...this._urlMap.keys()] + .filter(key => key < this._lowestId) + .forEach(key => this._urlMap.delete(key)); + [...this._idMap.entries()] + .filter(([unused, value]) => value < this._lowestId) + .forEach(([key, unused]) => this._idMap.delete(key)); + } + } + + public activate(terminal: Terminal): void { + // register the OSC 8 sequence handler + this._oscHandler = terminal.parser.registerOscHandler(8, data => { + // always reset URL notion / link handling in buffer + // This is a safety measure, any OSC 8 invocations (even malformed) + // are treated as an attempt to finalize a previous url start. + this._updateAttrs(terminal); + + // malformed, exit early + if (data.indexOf(';') === -1) { + return true; + } + + // extract needed bits of the sequence: params (might hold an id), url + const params = this._parseParams(data.slice(0, data.indexOf(';'))); + const urlData = this._filterUrl(data.slice(data.indexOf(';') + 1)); + + // OSC 8 ; ; ST - official URL anchor end marker + if (!urlData) { + return true; + } + + // OSC 8 ; [id=value] ; url ST - official URL anchor starter + + let hoverId; + if (params && params.id) { + // an id was given, thus try to match with earlier sequences + // we only consider full equality (id && url) as match, + // as the id might get reused by a later program with different url + const oldInternal = this._idMap.get(params.id); + if (oldInternal && this._urlMap.get(oldInternal)?.url === urlData.url) { + hoverId = oldInternal; + } else { + hoverId = this._internalId++; + this._idMap.set(params.id, hoverId); + this._urlMap.set(hoverId, urlData); + } + } else { + hoverId = this._internalId++; + this._urlMap.set(hoverId, urlData); + } + + // cleanup maps + this._limitCache(); + + // update extended cell attributes with hoverId + this._updateAttrs(terminal, hoverId); + return true; + }); + + // register a linkprovider to handle the extended attribute set by the sequence handler + this._linkProvider = terminal.registerLinkProvider(new HyperlinkProvider(terminal, this._urlMap)); + } + + public dispose(): void { + this._oscHandler?.dispose(); + this._linkProvider?.dispose(); + this._idMap.clear(); + this._urlMap.clear(); + this._schemes.length = 0; + } + + /** + * Register a scheme handler for the hyperlinks addon. + */ + public registerSchemeHandler(exe: ISchemeHandler): IDisposable { + const schemes = this._schemes; + if (schemes.indexOf(exe) === -1) { + schemes.push(exe); + } + return { + dispose: () => { + if (schemes.indexOf(exe) !== -1) { + schemes.splice(schemes.indexOf(exe), 1); + } + } + }; + } +} + + +class HyperlinkProvider implements ILinkProvider { + constructor( + private readonly _terminal: Terminal, + private readonly _urlMap: Map<number, IUrlWithHandler> + ) {} + + public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { + // fix position to 0-based right exclusive + const pos = {x: position.x - 1, y: position.y - 1}; + + // test whether the pointer is over a cell with an urlId + // also test that we actually have an url stored for the id + // TODO: need API extension + const urlId = (this._terminal.buffer.active.getLine(pos.y)?.getCell(pos.x) as any).extended.urlId; + if (!urlId || !this._urlMap.get(urlId)) { + callback(undefined); + return; + } + + // walk all viewport cells and collect cells with the same urlId in buffer ranges + const yOffset = this._terminal.buffer.active.viewportY; + const ranges: IBufferRange[] = []; + let r: IBufferRange | null = null; + for (let y = yOffset; y < this._terminal.rows + yOffset; ++y) { + const line = this._terminal.buffer.active.getLine(y); + if (!line) { + break; + } + for (let x = 0; x < this._terminal.rows; ++x) { + const cell = line.getCell(x); + if (!cell) { + break; + } + if ((cell as any).extended.urlId === urlId) { + if (!r) { + r = {start: {x, y}, end: {x: x + 1, y}}; + } else { + r.end.x = x + 1; + r.end.y = y; + } + } else { + if (r) { + r.end.x = x; + r.end.y = y; + ranges.push(r); + r = null; + } + } + } + } + if (r) { + ranges.push(r); + } + + // fix ranges to 1-based, right inclusive + for (const r of ranges) { + r.start.x++; + r.start.y++; + r.end.y++; + } + + // TODO: make this better customizable from outside + callback({ + ranges, + text: this._urlMap.get(urlId)!.url, + decorations: {pointerCursor: true, underline: true}, + activate: this._urlMap.get(urlId)!.schemeHandler.opener, + hover: (event: MouseEvent, text: string) => { + console.log('tooltip to show:', text); + } + }); + } +} diff --git a/addons/xterm-addon-hyperlinks/src/tsconfig.json b/addons/xterm-addon-hyperlinks/src/tsconfig.json new file mode 100644 index 0000000000..f9d0ab5fab --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "lib": [ + "dom", + "es2015" + ], + "rootDir": ".", + "outDir": "../out", + "sourceMap": true, + "removeComments": true, + "strict": true, + "baseUrl": ".", + "paths": { + "common/*": [ + "../../../src/common/*" + ] + }, + "types": [ + "../../../node_modules/@types/mocha", + "../../../out-test/api/TestUtils" + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/common" + } + ] +} diff --git a/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts new file mode 100644 index 0000000000..07acd01500 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon, IDisposable } from 'xterm'; + +export interface ISchemeHandler { + matcher: RegExp; + opener: (event: MouseEvent, text: string) => void +} + +declare module 'xterm-addon-hyperlinks' { + export class HyperlinksAddon implements ITerminalAddon { + constructor(); + public activate(terminal: Terminal): void; + public dispose(): void; + public registerSchemeHandler(exe: ISchemeHandler): IDisposable; + } +} diff --git a/addons/xterm-addon-hyperlinks/webpack.config.js b/addons/xterm-addon-hyperlinks/webpack.config.js new file mode 100644 index 0000000000..47901f6a67 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/webpack.config.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'HyperlinksAddon'; +const mainFile = 'xterm-addon-hyperlinks.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common') + } + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts index 563108d29e..1d1fa621b0 100644 --- a/addons/xterm-addon-web-links/src/WebLinkProvider.ts +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -68,7 +68,7 @@ export class LinkComputer { } }; - return { range, text, activate: handler }; + return { ranges: [range], text, activate: handler }; } } diff --git a/demo/client.ts b/demo/client.ts index 3cb1b3ec73..da400a7f8c 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -11,6 +11,7 @@ import { Terminal } from '../out/browser/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; +import { HyperlinksAddon, DEFAULT_SCHEMES } from '../addons/xterm-addon-hyperlinks/out/HyperlinksAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; @@ -36,6 +37,7 @@ export interface IWindowWithTerminal extends Window { Terminal?: typeof TerminalType; AttachAddon?: typeof AttachAddon; FitAddon?: typeof FitAddon; + HyperlinksAddon?: typeof HyperlinksAddon; SearchAddon?: typeof SearchAddon; SerializeAddon?: typeof SerializeAddon; WebLinksAddon?: typeof WebLinksAddon; @@ -50,7 +52,7 @@ let socketURL; let socket; let pid; -type AddonType = 'attach' | 'fit' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; +type AddonType = 'attach' | 'fit' | 'hyperlinks' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; interface IDemoAddon<T extends AddonType> { name: T; @@ -58,6 +60,7 @@ interface IDemoAddon<T extends AddonType> { ctor: T extends 'attach' ? typeof AttachAddon : T extends 'fit' ? typeof FitAddon : + T extends 'hyperlinks' ? typeof HyperlinksAddon: T extends 'search' ? typeof SearchAddon : T extends 'serialize' ? typeof SerializeAddon : T extends 'web-links' ? typeof WebLinksAddon : @@ -66,6 +69,7 @@ interface IDemoAddon<T extends AddonType> { instance?: T extends 'attach' ? AttachAddon : T extends 'fit' ? FitAddon : + T extends 'hyperlinks' ? typeof HyperlinksAddon: T extends 'search' ? SearchAddon : T extends 'serialize' ? SerializeAddon : T extends 'web-links' ? WebLinksAddon : @@ -77,6 +81,7 @@ interface IDemoAddon<T extends AddonType> { const addons: { [T in AddonType]: IDemoAddon<T>} = { attach: { name: 'attach', ctor: AttachAddon, canChange: false }, fit: { name: 'fit', ctor: FitAddon, canChange: false }, + hyperlinks: { name: 'hyperlinks', ctor: HyperlinksAddon, canChange: true }, search: { name: 'search', ctor: SearchAddon, canChange: true }, serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true }, 'web-links': { name: 'web-links', ctor: WebLinksAddon, canChange: true }, @@ -114,6 +119,7 @@ const disposeRecreateButtonHandler = () => { socket = null; addons.attach.instance = undefined; addons.fit.instance = undefined; + addons.hyperlinks.instance = undefined; addons.search.instance = undefined; addons.serialize.instance = undefined; addons.unicode11.instance = undefined; @@ -130,6 +136,7 @@ if (document.location.pathname === '/test') { window.Terminal = Terminal; window.AttachAddon = AttachAddon; window.FitAddon = FitAddon; + window.HyperlinksAddon = HyperlinksAddon; window.SearchAddon = SearchAddon; window.SerializeAddon = SerializeAddon; window.Unicode11Addon = Unicode11Addon; @@ -157,10 +164,15 @@ function createTerminal(): void { addons.search.instance = new SearchAddon(); addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); + addons.hyperlinks.instance = new HyperlinksAddon(); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTP); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTPS); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.FTP); addons.unicode11.instance = new Unicode11Addon(); // TODO: Remove arguments when link provider API is the default addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); typedTerm.loadAddon(addons.fit.instance); + typedTerm.loadAddon(addons.hyperlinks.instance); typedTerm.loadAddon(addons.search.instance); typedTerm.loadAddon(addons.serialize.instance); typedTerm.loadAddon(addons.unicode11.instance); @@ -367,6 +379,11 @@ function initAddons(term: TerminalType): void { if (checkbox.checked) { addon.instance = new addon.ctor(); term.loadAddon(addon.instance); + if (name === 'hyperlinks') { + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTP); + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTPS); + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.FTP); + } if (name === 'webgl') { setTimeout(() => { document.body.appendChild((addon.instance as WebglAddon).textureAtlas); diff --git a/src/browser/Linkifier2.test.ts b/src/browser/Linkifier2.test.ts index fb71b6aaa4..f309a3b301 100644 --- a/src/browser/Linkifier2.test.ts +++ b/src/browser/Linkifier2.test.ts @@ -42,7 +42,7 @@ describe('Linkifier2', () => { const link: ILink = { text: 'foo', - range: { + ranges: [{ start: { x: 5, y: 1 @@ -51,16 +51,16 @@ describe('Linkifier2', () => { x: 7, y: 1 } - }, + }], activate: () => { } }; it('onShowLinkUnderline event range is correct', done => { linkifier.onShowLinkUnderline(e => { - assert.equal(link.range.start.x - 1, e.x1); - assert.equal(link.range.start.y - 1, e.y1); - assert.equal(link.range.end.x, e.x2); - assert.equal(link.range.end.y - 1, e.y2); + assert.equal(link.ranges[0].start.x - 1, e.x1); + assert.equal(link.ranges[0].start.y - 1, e.y1); + assert.equal(link.ranges[0].end.x, e.x2); + assert.equal(link.ranges[0].end.y - 1, e.y2); done(); }); @@ -70,10 +70,10 @@ describe('Linkifier2', () => { it('onHideLinkUnderline event range is correct', done => { linkifier.onHideLinkUnderline(e => { - assert.equal(link.range.start.x - 1, e.x1); - assert.equal(link.range.start.y - 1, e.y1); - assert.equal(link.range.end.x, e.x2); - assert.equal(link.range.end.y - 1, e.y2); + assert.equal(link.ranges[0].start.x - 1, e.x1); + assert.equal(link.ranges[0].start.y - 1, e.y1); + assert.equal(link.ranges[0].end.x, e.x2); + assert.equal(link.ranges[0].end.y - 1, e.y2); done(); }); diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 39f750cc02..c317ebe22a 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -178,7 +178,8 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } // If we have a start and end row, check that the link is within it - if (!startRow || !endRow || (this._currentLink.range.start.y >= startRow && this._currentLink.range.end.y <= endRow)) { + // FIXME: needs proper handling of multiple ranges + if (!startRow || !endRow || (this._currentLink.ranges[0].start.y >= startRow && this._currentLink.ranges[0].end.y <= endRow)) { this._linkLeave(this._element, this._currentLink, this._lastMouseEvent); this._currentLink = undefined; this._currentLinkState = undefined; @@ -266,11 +267,13 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } private _fireUnderlineEvent(link: ILink, showEvent: boolean): void { - const range = link.range; - const scrollOffset = this._bufferService.buffer.ydisp; - const event = this._createLinkUnderlineEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x, range.end.y - scrollOffset - 1, undefined); - const emitter = showEvent ? this._onShowLinkUnderline : this._onHideLinkUnderline; - emitter.fire(event); + // FIXME: dirty hack to get everything underlined, needs proper handling + for (const range of link.ranges) { + const scrollOffset = this._bufferService.buffer.ydisp; + const event = this._createLinkUnderlineEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x, range.end.y - scrollOffset - 1, undefined); + const emitter = showEvent ? this._onShowLinkUnderline : this._onHideLinkUnderline; + emitter.fire(event); + } } protected _linkLeave(element: HTMLElement, link: ILink, event: MouseEvent): void { @@ -294,19 +297,28 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { * @param link * @param position */ - private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { - const sameLine = link.range.start.y === link.range.end.y; - const wrappedFromLeft = link.range.start.y < position.y; - const wrappedToRight = link.range.end.y > position.y; - - // If the start and end have the same y, then the position must be between start and end x - // If not, then handle each case seperately, depending on which way it wraps - return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || - (wrappedFromLeft && link.range.end.x >= position.x) || - (wrappedToRight && link.range.start.x <= position.x) || - (wrappedFromLeft && wrappedToRight)) && - link.range.start.y <= position.y && - link.range.end.y >= position.y; + // private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { + // const sameLine = link.range.start.y === link.range.end.y; + // const wrappedFromLeft = link.range.start.y < position.y; + // const wrappedToRight = link.range.end.y > position.y; + // + // // If the start and end have the same y, then the position must be between start and end x + // // If not, then handle each case seperately, depending on which way it wraps + // return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || + // (wrappedFromLeft && link.range.end.x >= position.x) || + // (wrappedToRight && link.range.start.x <= position.x) || + // (wrappedFromLeft && wrappedToRight)) && + // link.range.start.y <= position.y && + // link.range.end.y >= position.y; + // } + // FIXME: proper test against multiple ranges + private _linkAtPosition(link: ILink, pos: IBufferCellPosition): boolean { + for (const r of link.ranges) { + if (r.end.y >= pos.y && r.end.x >= pos.x && r.start.y <= pos.y && r.start.x <= pos.x) { + return true; + } + } + return false; } /** diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 76aa0c8cfe..15d6c6da1b 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -268,7 +268,7 @@ interface ILinkProvider { } interface ILink { - range: IBufferRange; + ranges: IBufferRange[]; text: string; decorations?: ILinkDecorations; activate(event: MouseEvent, text: string): void; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 6ce69483d9..b7809ec85f 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -87,6 +87,7 @@ export type IColorRGB = [number, number, number]; export interface IExtendedAttrs { underlineStyle: number; underlineColor: number; + urlId: number; clone(): IExtendedAttrs; isEmpty(): boolean; } diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index c7217a2a0c..8173ec9f90 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -130,11 +130,13 @@ export class ExtendedAttrs implements IExtendedAttrs { // underline style, NONE is empty public underlineStyle: UnderlineStyle = UnderlineStyle.NONE, // underline color, -1 is empty (same as FG) - public underlineColor: number = -1 + public underlineColor: number = -1, + // url hover id, 0 is empty + public urlId = 0 ) {} public clone(): IExtendedAttrs { - return new ExtendedAttrs(this.underlineStyle, this.underlineColor); + return new ExtendedAttrs(this.underlineStyle, this.underlineColor, this.urlId); } /** @@ -142,6 +144,6 @@ export class ExtendedAttrs implements IExtendedAttrs { * that needs to be persistant in the buffer. */ public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE; + return this.underlineStyle === UnderlineStyle.NONE && !this.urlId; } } diff --git a/tsconfig.all.json b/tsconfig.all.json index a4286deba8..a7bd9a91c2 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -7,6 +7,7 @@ { "path": "./test/benchmark" }, { "path": "./addons/xterm-addon-attach/src" }, { "path": "./addons/xterm-addon-fit/src" }, + { "path": "./addons/xterm-addon-hyperlinks/src" }, { "path": "./addons/xterm-addon-ligatures/src" }, { "path": "./addons/xterm-addon-search/src" }, { "path": "./addons/xterm-addon-serialize/src" }, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index dfda85831b..da02e95d80 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1114,7 +1114,7 @@ declare module 'xterm' { /** * The buffer range of the link. */ - range: IBufferRange; + ranges: IBufferRange[]; /** * The text of the link.