From ea8dbbd29fd9d61ffa0e4adf510363773daafdc9 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 7 Sep 2017 10:20:48 -0700 Subject: [PATCH] Underline links on hover --- src/Interfaces.ts | 22 +++++++--- src/Linkifier.ts | 31 ++++++++------ src/Terminal.ts | 4 +- src/Types.ts | 16 ++++++- src/input/Interfaces.ts | 5 ++- src/input/MouseZoneManager.ts | 60 ++++++++++++++++++--------- src/renderer/BaseRenderLayer.ts | 4 +- src/renderer/CursorRenderLayer.ts | 2 +- src/renderer/ForegroundRenderLayer.ts | 2 +- src/renderer/LinkRenderLayer.ts | 44 ++++++++++++++++++++ src/renderer/Renderer.ts | 4 +- src/utils/TestUtils.test.ts | 3 +- src/xterm.css | 2 +- typings/xterm.d.ts | 9 ++-- 14 files changed, 153 insertions(+), 55 deletions(-) create mode 100644 src/renderer/LinkRenderLayer.ts diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 790bf419..b7779688 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -5,6 +5,7 @@ import { ILinkMatcherOptions } from './Interfaces'; import { LinkMatcherHandler, LinkMatcherValidationCallback, Charset, LineData } from './Types'; import { IColorSet } from './renderer/Interfaces'; +import { IMouseZoneManager } from './input/Interfaces'; export interface IBrowser { isNode: boolean; @@ -22,8 +23,15 @@ export interface IBufferAccessor { buffer: IBuffer; } -export interface ITerminal extends IBufferAccessor, IEventEmitter { +export interface IElementAccessor { element: HTMLElement; +} + +export interface ILinkifierAccessor { + linkifier: ILinkifier; +} + +export interface ITerminal extends ILinkifierAccessor, IBufferAccessor, IElementAccessor, IEventEmitter { selectionManager: ISelectionManager; charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; @@ -197,9 +205,11 @@ export interface ICharMeasure { measure(options: ITerminalOptions): void; } -export interface ILinkifier { - linkifyRow(rowIndex: number): void; - attachHypertextLinkHandler(handler: LinkMatcherHandler): void; +export interface ILinkifier extends IEventEmitter { + attachToDom(mouseZoneManager: IMouseZoneManager): void; + linkifyRows(start: number, end: number): void; + setHypertextLinkHandler(handler: LinkMatcherHandler): void; + setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void; registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number; deregisterLinkMatcher(matcherId: number): boolean; } @@ -243,11 +253,11 @@ export interface ILinkMatcherOptions { /** * A callback that fires when the mouse hovers over a link. */ - hoverStartCallback?: LinkMatcherHandler; + tooltipCallback?: LinkMatcherHandler; /** * A callback that fires when the mouse leaves a link that was hovered. */ - hoverEndCallback?: () => void; + leaveCallback?: () => void; /** * The priority of the link matcher, this defines the order in which the link * matcher is evaluated relative to others, from highest to lowest. The diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 2adb6f63..55d8d68d 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -2,12 +2,11 @@ * @license MIT */ -import { ILinkMatcherOptions, ITerminal, IBufferAccessor } from './Interfaces'; -import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback, LineData } from './Types'; +import { ILinkMatcherOptions, ITerminal, IBufferAccessor, ILinkifier, IElementAccessor } from './Interfaces'; +import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback, LineData, LinkHoverEvent, LinkHoverEventTypes } from './Types'; import { IMouseZoneManager } from './input/Interfaces'; import { MouseZone } from './input/MouseZoneManager'; - -const INVALID_LINK_CLASS = 'xterm-invalid-link'; +import { EventEmitter } from './EventEmitter'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -36,7 +35,7 @@ const HYPERTEXT_LINK_MATCHER_ID = 0; /** * The Linkifier applies links to rows shortly after they have been refreshed. */ -export class Linkifier { +export class Linkifier extends EventEmitter implements ILinkifier { /** * The time to wait after a row is changed before it is linkified. This prevents * the costly operation of searching every row multiple times, potentially a @@ -51,8 +50,9 @@ export class Linkifier { private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; constructor( - private _terminal: IBufferAccessor + private _terminal: IBufferAccessor & IElementAccessor ) { + super(); this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 }); } @@ -133,8 +133,8 @@ export class Linkifier { handler, matchIndex: options.matchIndex, validationCallback: options.validationCallback, - hoverStartCallback: options.hoverStartCallback, - hoverEndCallback: options.hoverEndCallback, + hoverTooltipCallback: options.tooltipCallback, + hoverLeaveCallback: options.leaveCallback, priority: options.priority || 0 }; this._addLinkMatcherToList(matcher); @@ -260,13 +260,20 @@ export class Linkifier { window.open(uri, '_blank'); }, e => { - if (matcher.hoverStartCallback) { - matcher.hoverStartCallback(e, uri); + this.emit(LinkHoverEventTypes.HOVER, { x, y, length: uri.length}); + this._terminal.element.style.cursor = 'pointer'; + }, + e => { + this.emit(LinkHoverEventTypes.TOOLTIP, { x, y, length: uri.length}); + if (matcher.hoverTooltipCallback) { + matcher.hoverTooltipCallback(e, uri); } }, () => { - if (matcher.hoverEndCallback) { - matcher.hoverEndCallback(); + this.emit(LinkHoverEventTypes.LEAVE, { x, y, length: uri.length}); + this._terminal.element.style.cursor = ''; + if (matcher.hoverLeaveCallback) { + matcher.hoverLeaveCallback(); } } )); diff --git a/src/Terminal.ts b/src/Terminal.ts index d42978f7..1a180cc1 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -38,7 +38,7 @@ import * as Mouse from './utils/Mouse'; import { CHARSETS } from './Charsets'; import { getRawByteCoords } from './utils/Mouse'; import { CustomKeyEventHandler, Charset, LinkMatcherHandler, LinkMatcherValidationCallback, CharData, LineData } from './Types'; -import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions, IViewport, ICompositionHelper, ITheme } from './Interfaces'; +import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions, IViewport, ICompositionHelper, ITheme, ILinkifier } from './Interfaces'; import { BellSound } from './utils/Sounds'; import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; import { IMouseZoneManager } from './input/Interfaces'; @@ -188,7 +188,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private parser: Parser; private renderer: IRenderer; public selectionManager: SelectionManager; - private linkifier: Linkifier; + public linkifier: ILinkifier; public buffers: BufferSet; public buffer: Buffer; public viewport: IViewport; diff --git a/src/Types.ts b/src/Types.ts index 1af21e78..06d49502 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -6,8 +6,8 @@ export type LinkMatcher = { id: number, regex: RegExp, handler: LinkMatcherHandler, - hoverStartCallback?: LinkMatcherHandler, - hoverEndCallback?: () => void, + hoverTooltipCallback?: LinkMatcherHandler, + hoverLeaveCallback?: () => void, matchIndex?: number, validationCallback?: LinkMatcherValidationCallback, priority?: number @@ -20,3 +20,15 @@ export type Charset = {[key: string]: string}; export type CharData = [number, string, number, number]; export type LineData = CharData[]; + +export type LinkHoverEvent = { + x: number, + y: number, + length: number +}; + +export enum LinkHoverEventTypes { + HOVER = 'linkhover', + TOOLTIP = 'linktooltip', + LEAVE = 'linkleave' +}; diff --git a/src/input/Interfaces.ts b/src/input/Interfaces.ts index 8756e540..19125d08 100644 --- a/src/input/Interfaces.ts +++ b/src/input/Interfaces.ts @@ -8,6 +8,7 @@ export interface IMouseZone { x2: number; y: number; clickCallback: (e: MouseEvent) => any; - hoverStartCallback?: (e: MouseEvent) => any; - hoverEndCallback?: () => any; + hoverCallback?: (e: MouseEvent) => any; + tooltipCallback?: (e: MouseEvent) => any; + leaveCallback?: () => any; } diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index 7789082e..1a6cedad 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -20,7 +20,7 @@ export class MouseZoneManager implements IMouseZoneManager { private _mouseDownListener: (e: MouseEvent) => any; private _clickListener: (e: MouseEvent) => any; - private _hoverTimeout: number = null; + private _tooltipTimeout: number = null; private _currentZone: IMouseZone = null; private _lastHoverCoords: [number, number] = [null, null]; @@ -63,30 +63,49 @@ export class MouseZoneManager implements IMouseZoneManager { private _onMouseMove(e: MouseEvent): void { // TODO: Ideally this would only clear the hover state when the mouse moves // outside of the mouse zone - if (this._lastHoverCoords[0] !== e.pageX && this._lastHoverCoords[1] !== e.pageY) { - // Restart the timeout - if (this._hoverTimeout) { - clearTimeout(this._hoverTimeout); - } - this._hoverTimeout = setTimeout(() => this._onHover(e), HOVER_DURATION); - - // Fire the hover end callback if a zone was being hovered - if (this._currentZone) { - this._currentZone.hoverEndCallback(); - this._currentZone = null; - } - + if (this._lastHoverCoords[0] !== e.pageX || this._lastHoverCoords[1] !== e.pageY) { + this._onHover(e); // Record the current coordinates this._lastHoverCoords = [e.pageX, e.pageY]; } } private _onHover(e: MouseEvent): void { - const coords = getCoords(e, this._terminal.element, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows); const zone = this._findZoneEventAt(e); - if (zone && zone.hoverStartCallback) { - this._currentZone = zone; - zone.hoverStartCallback(e); + + // Do nothing if the zone is the same + if (zone === this._currentZone) { + return; + } + + // Fire the hover end callback if a zone was being hovered + if (this._currentZone) { + this._currentZone.leaveCallback(); + this._currentZone = null; + } + + // Exit if there is not zone + if (!zone) { + return; + } + this._currentZone = zone; + + // Trigger the hover callback + if (zone.hoverCallback) { + zone.hoverCallback(e); + } + + // Restart the timeout + if (this._tooltipTimeout) { + clearTimeout(this._tooltipTimeout); + } + this._tooltipTimeout = setTimeout(() => this._onTooltip(e), HOVER_DURATION); + } + + private _onTooltip(e: MouseEvent): void { + const zone = this._findZoneEventAt(e); + if (zone && zone.tooltipCallback) { + zone.tooltipCallback(e); } } @@ -116,8 +135,9 @@ export class MouseZone implements IMouseZone { public x2: number, public y: number, public clickCallback: (e: MouseEvent) => any, - public hoverStartCallback?: (e: MouseEvent) => any, - public hoverEndCallback?: () => void + public hoverCallback?: (e: MouseEvent) => any, + public tooltipCallback?: (e: MouseEvent) => any, + public leaveCallback?: () => void ) { } } diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index 0e657ae7..4efc5481 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -109,11 +109,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to fill. * @param y The row to fill. */ - protected fillBottomLineAtCell(x: number, y: number): void { + protected fillBottomLineAtCells(x: number, y: number, width: number = 1): void { this._ctx.fillRect( x * this.scaledCharWidth, (y + 1) * this.scaledLineHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, - this.scaledCharWidth, + width * this.scaledCharWidth, window.devicePixelRatio); } diff --git a/src/renderer/CursorRenderLayer.ts b/src/renderer/CursorRenderLayer.ts index 9f5136ff..a888d54f 100644 --- a/src/renderer/CursorRenderLayer.ts +++ b/src/renderer/CursorRenderLayer.ts @@ -202,7 +202,7 @@ export class CursorRenderLayer extends BaseRenderLayer { private _renderUnderlineCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { this._ctx.save(); this._ctx.fillStyle = this.colors.cursor; - this.fillBottomLineAtCell(x, y); + this.fillBottomLineAtCells(x, y); this._ctx.restore(); } diff --git a/src/renderer/ForegroundRenderLayer.ts b/src/renderer/ForegroundRenderLayer.ts index 05141aed..bef57a00 100644 --- a/src/renderer/ForegroundRenderLayer.ts +++ b/src/renderer/ForegroundRenderLayer.ts @@ -139,7 +139,7 @@ export class ForegroundRenderLayer extends BaseRenderLayer { } else { this._ctx.fillStyle = this.colors.foreground; } - this.fillBottomLineAtCell(x, y); + this.fillBottomLineAtCells(x, y); } this.drawChar(terminal, char, code, width, x, y, fg, !!(flags & FLAGS.BOLD)); diff --git a/src/renderer/LinkRenderLayer.ts b/src/renderer/LinkRenderLayer.ts new file mode 100644 index 00000000..b948b70d --- /dev/null +++ b/src/renderer/LinkRenderLayer.ts @@ -0,0 +1,44 @@ +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal, ILinkifierAccessor } from '../Interfaces'; +import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; +import { GridCache } from './GridCache'; +import { FLAGS } from './Types'; +import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; +import { LinkHoverEvent, LinkHoverEventTypes } from '../Types'; + +export class LinkRenderLayer extends BaseRenderLayer { + private _state: LinkHoverEvent = null; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { + super(container, 'link', zIndex, colors); + terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: LinkHoverEvent) => this._onLinkHover(e)); + terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: LinkHoverEvent) => this._onLinkLeave(e)); + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = null; + } + + public reset(terminal: ITerminal): void { + this._clearCurrentLink(); + } + + private _clearCurrentLink(): void { + if (this._state) { + this.clearCells(this._state.x, this._state.y, this._state.length, 1); + this._state = null; + } + } + + private _onLinkHover(e: LinkHoverEvent): void { + this._ctx.fillStyle = this.colors.foreground; + this.fillBottomLineAtCells(e.x, e.y, e.length); + this._state = e; + } + + private _onLinkLeave(e: LinkHoverEvent): void { + this._clearCurrentLink(); + } +} diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index c032bc8f..ebdd0bdb 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -11,6 +11,7 @@ import { CursorRenderLayer } from './CursorRenderLayer'; import { ColorManager } from './ColorManager'; import { BaseRenderLayer } from './BaseRenderLayer'; import { IRenderLayer, IColorSet, IRenderer } from './Interfaces'; +import { LinkRenderLayer } from './LinkRenderLayer'; export class Renderer implements IRenderer { /** A queue of the rows to be refreshed */ @@ -28,7 +29,8 @@ export class Renderer implements IRenderer { new BackgroundRenderLayer(this._terminal.element, 0, this._colorManager.colors), new SelectionRenderLayer(this._terminal.element, 1, this._colorManager.colors), new ForegroundRenderLayer(this._terminal.element, 2, this._colorManager.colors), - new CursorRenderLayer(this._terminal.element, 3, this._colorManager.colors) + new LinkRenderLayer(this._terminal.element, 3, this._colorManager.colors, this._terminal), + new CursorRenderLayer(this._terminal.element, 4, this._colorManager.colors) ]; this._devicePixelRatio = window.devicePixelRatio; } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 652c3aa3..e229fc87 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -2,13 +2,14 @@ * @license MIT */ -import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme } from '../Interfaces'; +import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier } from '../Interfaces'; import { LineData } from '../Types'; import { Buffer } from '../Buffer'; import * as Browser from './Browser'; import { IColorSet, IRenderer } from '../renderer/Interfaces'; export class MockTerminal implements ITerminal { + linkifier: ILinkifier; isFocused: boolean; options: ITerminalOptions = {}; element: HTMLElement; diff --git a/src/xterm.css b/src/xterm.css index df3aff59..c26d459d 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -120,5 +120,5 @@ } .terminal:not(.enable-mouse-events) { - cursor: text; + cursor: text; } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index b219732a..7a4d6940 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -141,14 +141,15 @@ interface ILinkMatcherOptions { validationCallback?: (uri: string, callback: (isValid: boolean) => void) => void; /** - * A callback that fires when the mouse hovers over a link. + * A callback that fires when the mouse hovers over a link for a moment. */ - hoverStartCallback?: (event: MouseEvent, uri: string) => boolean | void; + tooltipCallback?: (event: MouseEvent, uri: string) => boolean | void; /** - * A callback that fires when the mouse leaves a link that was hovered. + * A callback that fires when the mouse leaves a link. Note that this can + * happen even when tooltipCallback hasn't fired for the link yet. */ - hoverEndCallback?: (event: MouseEvent, uri: string) => boolean | void; + leaveCallback?: (event: MouseEvent, uri: string) => boolean | void; /** * The priority of the link matcher, this defines the order in which the link