diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts index 563108d2..1fd5693f 100644 --- a/addons/xterm-addon-web-links/src/WebLinkProvider.ts +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -15,19 +15,20 @@ export class WebLinkProvider implements ILinkProvider { } - public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { - callback(LinkComputer.computeLink(position, this._regex, this._terminal, this._handler)); + public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + callback(LinkComputer.computeLink(y, this._regex, this._terminal, this._handler)); } } export class LinkComputer { - public static computeLink(position: IBufferCellPosition, regex: RegExp, terminal: Terminal, handler: (event: MouseEvent, uri: string) => void): ILink | undefined { + public static computeLink(y: number, regex: RegExp, terminal: Terminal, handler: (event: MouseEvent, uri: string) => void): ILink[] { const rex = new RegExp(regex.source, (regex.flags || '') + 'g'); - const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(position.y - 1, false, terminal); + const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(y - 1, false, terminal); let match; let stringIndex = -1; + const result: ILink[] = []; while ((match = rex.exec(line)) !== null) { const text = match[1]; @@ -68,8 +69,11 @@ export class LinkComputer { } }; - return { range, text, activate: handler }; + result.push({ range, text, activate: handler }); } + + console.log('computed', result); + return result; } /** diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 39f750cc..eb8b700a 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -8,7 +8,7 @@ import { IDisposable } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; import { IBufferService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { Disposable, getDisposeArrayDisposable } from 'common/Lifecycle'; +import { Disposable, getDisposeArrayDisposable, disposeArray } from 'common/Lifecycle'; import { addDisposableDomListener } from 'browser/Lifecycle'; interface ILinkState { @@ -16,17 +16,23 @@ interface ILinkState { isHovered: boolean; } +interface ILinkWithState { + link: ILink; + state?: ILinkState; +} + export class Linkifier2 extends Disposable implements ILinkifier2 { private _element: HTMLElement | undefined; private _mouseService: IMouseService | undefined; private _renderService: IRenderService | undefined; private _linkProviders: ILinkProvider[] = []; - private _currentLink: ILink | undefined; - protected _currentLinkState: ILinkState | undefined; + private _currentLink: ILinkWithState | undefined; private _lastMouseEvent: MouseEvent | undefined; private _linkCacheDisposables: IDisposable[] = []; private _lastBufferCell: IBufferCellPosition | undefined; private _isMouseOut: boolean = true; + private _activeProviderReplies: Map | undefined; + private _activeLine: number = -1; private _onShowLinkUnderline = this.register(new EventEmitter()); public get onShowLinkUnderline(): IEvent { return this._onShowLinkUnderline.event; } @@ -101,61 +107,90 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } private _onHover(position: IBufferCellPosition): void { - if (this._currentLink) { - // Check the if the link is in the mouse position - const isInPosition = this._linkAtPosition(this._currentLink, position); + // TODO: This currently does not cache link provider results across wrapped lines, activeLine should be something like `activeRange: {startY, endY}` + // Check if we need to clear the link + if (this._activeLine !== position.y) { + this._clearCurrentLink(); + this._askForLink(position, false); + return; + } - // Check if we need to clear the link - if (!isInPosition) { - this._clearCurrentLink(); - this._askForLink(position); - } - } else { - this._askForLink(position); + // Check the if the link is in the mouse position + const isCurrentLinkInPosition = this._currentLink && this._linkAtPosition(this._currentLink.link, position); + if (!isCurrentLinkInPosition) { + this._clearCurrentLink(); + this._askForLink(position, true); } } - private _askForLink(position: IBufferCellPosition): void { - const providerReplies: Map = new Map(); + private _askForLink(position: IBufferCellPosition, useLineCache: boolean): void { + if (!this._activeProviderReplies || !useLineCache) { + this._activeProviderReplies = new Map(); + this._activeLine = position.y; + } let linkProvided = false; // There is no link cached, so ask for one this._linkProviders.forEach((linkProvider, i) => { - linkProvider.provideLink(position, (link: ILink | undefined) => { - if (this._isMouseOut) { - return; - } - providerReplies.set(i, link); - - // Check if every provider before this one has come back undefined - let hasLinkBefore = false; - for (let j = 0; j < i; j++) { - if (!providerReplies.has(j) || providerReplies.get(j)) { - hasLinkBefore = true; + const existingReply = this._activeProviderReplies?.get(i); + if (existingReply) { + console.log('existing reply', existingReply); + linkProvided = this._checkLinkProviderResult(i, position, linkProvided); + } else { + linkProvider.provideLinks(position.y, (links: ILink[] | undefined) => { + console.log('provided links', links); + if (this._isMouseOut) { + return; } - } - - // If all providers with higher priority came back undefined, then this link should be used - if (!hasLinkBefore && link) { - linkProvided = true; - this._handleNewLink(link); - } - - // Check if all the providers have responded - if (providerReplies.size === this._linkProviders.length && !linkProvided) { - // Respect the order of the link providers - for (let j = 0; j < providerReplies.size; j++) { - const currentLink = providerReplies.get(j); - if (currentLink) { - this._handleNewLink(currentLink); - break; - } - } - } - }); + const linksWithState: ILinkWithState[] | undefined = links?.map(link => ({ link })); + this._activeProviderReplies?.set(i, linksWithState); + linkProvided = this._checkLinkProviderResult(i, position, linkProvided); + }); + } }); } + private _checkLinkProviderResult(index: number, position: IBufferCellPosition, linkProvided: boolean): boolean { + if (!this._activeProviderReplies) { + return linkProvided; + } + + const links = this._activeProviderReplies.get(index); + + // Check if every provider before this one has come back undefined + let hasLinkBefore = false; + for (let j = 0; j < index; j++) { + if (!this._activeProviderReplies.has(j) || this._activeProviderReplies.get(j)) { + hasLinkBefore = true; + } + } + + // If all providers with higher priority came back undefined, then this provider's link for + // the position should be used + if (!hasLinkBefore && links) { + const linkAtPosition = links.find(link => this._linkAtPosition(link.link, position)); + if (linkAtPosition) { + linkProvided = true; + this._handleNewLink(linkAtPosition); + } + } + + // Check if all the providers have responded + if (this._activeProviderReplies.size === this._linkProviders.length && !linkProvided) { + // Respect the order of the link providers + for (let j = 0; j < this._activeProviderReplies.size; j++) { + const currentLink = this._activeProviderReplies.get(j)?.find(link => this._linkAtPosition(link.link, position)); + if (currentLink) { + linkProvided = true; + this._handleNewLink(currentLink); + break; + } + } + } + + return linkProvided; + } + private _onClick(event: MouseEvent): void { if (!this._element || !this._mouseService || !this._currentLink) { return; @@ -167,8 +202,8 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { return; } - if (this._linkAtPosition(this._currentLink, position)) { - this._currentLink.activate(event, this._currentLink.text); + if (this._linkAtPosition(this._currentLink.link, position)) { + this._currentLink.link.activate(event, this._currentLink.link.text); } } @@ -178,16 +213,14 @@ 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)) { - this._linkLeave(this._element, this._currentLink, this._lastMouseEvent); + if (!startRow || !endRow || (this._currentLink.link.range.start.y >= startRow && this._currentLink.link.range.end.y <= endRow)) { + this._linkLeave(this._element, this._currentLink.link, this._lastMouseEvent); this._currentLink = undefined; - this._currentLinkState = undefined; - this._linkCacheDisposables.forEach(l => l.dispose()); - this._linkCacheDisposables = []; + disposeArray(this._linkCacheDisposables); } } - private _handleNewLink(link: ILink): void { + private _handleNewLink(linkWithState: ILinkWithState): void { if (!this._element || !this._lastMouseEvent || !this._mouseService) { return; } @@ -199,38 +232,38 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } // Trigger hover if the we have a link at the position - if (this._linkAtPosition(link, position)) { - this._currentLink = link; - this._currentLinkState = { + if (this._linkAtPosition(linkWithState.link, position)) { + this._currentLink = linkWithState; + this._currentLink.state = { decorations: { - underline: link.decorations === undefined ? true : link.decorations.underline, - pointerCursor: link.decorations === undefined ? true : link.decorations.pointerCursor + underline: linkWithState.link.decorations === undefined ? true : linkWithState.link.decorations.underline, + pointerCursor: linkWithState.link.decorations === undefined ? true : linkWithState.link.decorations.pointerCursor }, isHovered: true }; - this._linkHover(this._element, link, this._lastMouseEvent); + this._linkHover(this._element, linkWithState.link, this._lastMouseEvent); // Add listener for tracking decorations changes - link.decorations = {} as ILinkDecorations; - Object.defineProperties(link.decorations, { + linkWithState.link.decorations = {} as ILinkDecorations; + Object.defineProperties(linkWithState.link.decorations, { pointerCursor: { - get: () => this._currentLinkState?.decorations.pointerCursor, + get: () => this._currentLink?.state?.decorations.pointerCursor, set: v => { - if (this._currentLinkState && this._currentLinkState?.decorations.pointerCursor !== v) { - this._currentLinkState.decorations.pointerCursor = v; - if (this._currentLinkState.isHovered) { + if (this._currentLink?.state && this._currentLink.state.decorations.pointerCursor !== v) { + this._currentLink.state.decorations.pointerCursor = v; + if (this._currentLink.state.isHovered) { this._element?.classList.toggle('xterm-cursor-pointer', v); } } } }, underline: { - get: () => this._currentLinkState?.decorations.underline, + get: () => this._currentLink?.state?.decorations.underline, set: v => { - if (this._currentLinkState && this._currentLinkState?.decorations.underline !== v) { - this._currentLinkState.decorations.underline = v; - if (this._currentLinkState.isHovered) { - this._fireUnderlineEvent(link, v); + if (this._currentLink?.state && this._currentLink?.state?.decorations.underline !== v) { + this._currentLink.state.decorations.underline = v; + if (this._currentLink.state.isHovered) { + this._fireUnderlineEvent(linkWithState.link, v); } } } @@ -250,12 +283,13 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } protected _linkHover(element: HTMLElement, link: ILink, event: MouseEvent): void { - if (this._currentLinkState) { - this._currentLinkState.isHovered = true; - if (this._currentLinkState.decorations.underline) { + if (this._currentLink?.state) { + this._currentLink.state.isHovered = true; + if (this._currentLink.state.decorations.underline) { + console.log('add underline'); this._fireUnderlineEvent(link, true); } - if (this._currentLinkState.decorations.pointerCursor) { + if (this._currentLink.state.decorations.pointerCursor) { element.classList.add('xterm-cursor-pointer'); } } @@ -274,12 +308,13 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } protected _linkLeave(element: HTMLElement, link: ILink, event: MouseEvent): void { - if (this._currentLinkState) { - this._currentLinkState.isHovered = false; - if (this._currentLinkState.decorations.underline) { + if (this._currentLink?.state) { + this._currentLink.state.isHovered = false; + if (this._currentLink.state.decorations.underline) { + console.log('lose underline'); this._fireUnderlineEvent(link, false); } - if (this._currentLinkState.decorations.pointerCursor) { + if (this._currentLink.state.decorations.pointerCursor) { element.classList.remove('xterm-cursor-pointer'); } } diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 76aa0c8c..d0f8da15 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -264,7 +264,7 @@ export interface IMouseZone { } interface ILinkProvider { - provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void; + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; } interface ILink { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index dfda8583..13448d78 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1100,11 +1100,11 @@ declare module 'xterm' { interface ILinkProvider { /** * Provides a link a buffer position - * @param position The position of the buffer that is currently active. - * @param callback The callback to be fired with the resulting link or - * `undefined` when ready. + * @param y The y position of the buffer that is currently active. + * @param callback The callback to be fired when ready with the resulting + * link(s) for the line or `undefined`. */ - provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void; + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; } /**