Underline links on hover

This commit is contained in:
Daniel Imms
2017-09-07 10:20:48 -07:00
parent 67962a5e55
commit ea8dbbd29f
14 changed files with 153 additions and 55 deletions
+16 -6
View File
@@ -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
+19 -12
View File
@@ -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, <LinkHoverEvent>{ x, y, length: uri.length});
this._terminal.element.style.cursor = 'pointer';
},
e => {
this.emit(LinkHoverEventTypes.TOOLTIP, <LinkHoverEvent>{ x, y, length: uri.length});
if (matcher.hoverTooltipCallback) {
matcher.hoverTooltipCallback(e, uri);
}
},
() => {
if (matcher.hoverEndCallback) {
matcher.hoverEndCallback();
this.emit(LinkHoverEventTypes.LEAVE, <LinkHoverEvent>{ x, y, length: uri.length});
this._terminal.element.style.cursor = '';
if (matcher.hoverLeaveCallback) {
matcher.hoverLeaveCallback();
}
}
));
+2 -2
View File
@@ -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;
+14 -2
View File
@@ -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'
};
+3 -2
View File
@@ -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;
}
+40 -20
View File
@@ -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 = <number><any>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 = <number><any>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
) {
}
}
+2 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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));
+44
View File
@@ -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();
}
}
+3 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -120,5 +120,5 @@
}
.terminal:not(.enable-mouse-events) {
cursor: text;
cursor: text;
}
+5 -4
View File
@@ -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