diff --git a/gulpfile.js b/gulpfile.js index 0043d7b128..2e58d26cb3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -6,7 +6,6 @@ const gulp = require('gulp'); const istanbul = require('gulp-istanbul'); const merge = require('merge-stream'); const mocha = require('gulp-mocha'); -const mochaPhantomJs = require('gulp-mocha-phantomjs'); const sorcery = require('sorcery'); const source = require('vinyl-source-stream'); const sourcemaps = require('gulp-sourcemaps'); @@ -80,17 +79,12 @@ gulp.task('instrument-test', function () { .pipe(istanbul.hookRequire()); }); -gulp.task('test-mocha', ['instrument-test'], function () { +gulp.task('mocha', ['instrument-test'], function () { return gulp.src(['lib/*test.js', 'lib/**/*test.js'], {read: false}) .pipe(mocha()) .pipe(istanbul.writeReports()); }); -gulp.task('test-mocha-phantomjs', function () { - return gulp.src('test-harness.html') - .pipe(mochaPhantomJs()); -}); - /** * Use `sorcery` to resolve the source map chain and point back to the TypeScript files. * (Without this task the source maps produced for the JavaScript bundle points into the @@ -111,5 +105,5 @@ gulp.task('coveralls', function () { }); gulp.task('build', ['sorcery']); -gulp.task('test', ['test-mocha', 'test-mocha-phantomjs']); +gulp.task('test', ['mocha']); gulp.task('default', ['build']); diff --git a/package.json b/package.json index e657c1d1d0..239580439f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ ], "devDependencies": { "@types/chai": "^3.4.34", + "@types/jsdom": "^2.0.30", "@types/mocha": "^2.2.33", "@types/node": "^6.0.41", "browserify": "^13.1.0", @@ -50,17 +51,17 @@ "gulp-coveralls": "^0.1.4", "gulp-istanbul": "^1.1.1", "gulp-mocha": "^3.0.1", - "gulp-mocha-phantomjs": "^0.12.0", "gulp-sourcemaps": "1.9.1", "gulp-typescript": "^3.1.3", "jsdoc": "3.4.3", + "jsdom": "^9.11.0", "merge-stream": "^1.0.1", "node-pty": "^0.4.1", "nodemon": "1.10.2", "sleep": "^3.0.1", "sorcery": "^0.10.0", "tslint": "^4.0.2", - "typescript": "~2.1.0", + "typescript": "~2.2.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0" }, diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 92094c9553..d2f8e8726d 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -2,6 +2,9 @@ * @license MIT */ +import { LinkMatcherOptions } from './Interfaces'; +import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types'; + export interface IBrowser { isNode: boolean; userAgent: string; @@ -50,6 +53,13 @@ export interface ICharMeasure { measure(): void; } +export interface ILinkifier { + linkifyRow(rowIndex: number): void; + attachHypertextLinkHandler(handler: LinkMatcherHandler): void; + registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: LinkMatcherOptions): number; + deregisterLinkMatcher(matcherId: number): boolean; +} + interface ICircularList { length: number; maxLength: number; @@ -64,6 +74,19 @@ interface ICircularList { shiftElements(start: number, count: number, offset: number): void; } +export interface LinkMatcherOptions { + /** + * The index of the link from the regex.match(text) call. This defaults to 0 + * (for regular expressions without capture groups). + */ + matchIndex?: number; + /** + * A callback that validates an individual link, returning true if valid and + * false if invalid. + */ + validationCallback?: LinkMatcherValidationCallback; +} + /** * Handles actions generated by the parser. */ diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts new file mode 100644 index 0000000000..b1c1541df1 --- /dev/null +++ b/src/Linkifier.test.ts @@ -0,0 +1,76 @@ +/** + * @license MIT + */ +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { ITerminal, ILinkifier } from './Interfaces'; +import { Linkifier } from './Linkifier'; + +class TestLinkifier extends Linkifier { + constructor(document: Document, rows: HTMLElement[]) { + Linkifier.TIME_BEFORE_LINKIFY = 0; + super(document, rows); + } +} + +describe('Linkifier', () => { + let window: Window; + let document: Document; + + let container: HTMLElement; + let rows: HTMLElement[]; + let linkifier: ILinkifier; + + beforeEach(done => { + rows = []; + jsdom.env('', (err, w) => { + window = w; + document = window.document; + linkifier = new Linkifier(document, rows); + container = document.createElement('div'); + document.body.appendChild(container); + done(); + }); + }); + + function addRow(text: string) { + const element = document.createElement('div'); + element.textContent = text; + container.appendChild(element); + rows.push(element); + } + + function clickElement(element: Node) { + const event = document.createEvent('MouseEvent'); + event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); + element.dispatchEvent(event); + } + + describe('validationCallback', () => { + it('should enable link if true', done => { + addRow('test'); + linkifier.registerLinkMatcher(/test/, () => done(), { + validationCallback: (url, cb) => { + cb(true); + assert.equal((rows[0].firstChild).tagName, 'A'); + setTimeout(() => clickElement(rows[0].firstChild), 0); + } + }); + linkifier.linkifyRow(0); + }); + + it('should disable link if false', done => { + addRow('test'); + linkifier.registerLinkMatcher(/test/, () => assert.fail(), { + validationCallback: (url, cb) => { + cb(false); + assert.equal((rows[0].firstChild).tagName, 'A'); + setTimeout(() => clickElement(rows[0].firstChild), 0); + } + }); + linkifier.linkifyRow(0); + // Allow time for the click to be performed + setTimeout(() => done(), 10); + }); + }); +}); diff --git a/src/Linkifier.ts b/src/Linkifier.ts index e0f5eb926f..f8b8ca895f 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -2,9 +2,18 @@ * @license MIT */ -export type LinkHandler = (uri: string) => void; +import { LinkMatcherOptions } from './Interfaces'; +import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types'; -type LinkMatcher = {id: number, regex: RegExp, matchIndex?: number, handler: LinkHandler}; +type LinkMatcher = { + id: number, + regex: RegExp, + handler: LinkMatcherHandler, + matchIndex?: number, + validationCallback?: LinkMatcherValidationCallback; +}; + +const INVALID_LINK_CLASS = 'xterm-invalid-link'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -27,23 +36,25 @@ const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end); */ const HYPERTEXT_LINK_MATCHER_ID = 0; -/** - * The time to wait after a row is changed before it is linkified. This prevents - * the costly operation of searching every row multiple times, pntentially a - * huge aount of times. - */ -const TIME_BEFORE_LINKIFY = 200; - /** * The Linkifier applies links to rows shortly after they have been refreshed. */ export class Linkifier { + /** + * The time to wait after a row is changed before it is linkified. This prevents + * the costly operation of searching every row multiple times, pntentially a + * huge aount of times. + */ + protected static TIME_BEFORE_LINKIFY = 200; + + private _document: Document; private _rows: HTMLElement[]; private _rowTimeoutIds: number[]; private _linkMatchers: LinkMatcher[]; private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; - constructor(rows: HTMLElement[]) { + constructor(document: Document, rows: HTMLElement[]) { + this._document = document; this._rows = rows; this._rowTimeoutIds = []; this._linkMatchers = []; @@ -59,7 +70,7 @@ export class Linkifier { if (timeoutId) { clearTimeout(timeoutId); } - this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY); + this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), Linkifier.TIME_BEFORE_LINKIFY); } /** @@ -68,7 +79,7 @@ export class Linkifier { * @param {LinkHandler} handler The handler to use, this can be cleared with * null. */ - public attachHypertextLinkHandler(handler: LinkHandler): void { + public attachHypertextLinkHandler(handler: LinkMatcherHandler): void { this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler; } @@ -79,11 +90,10 @@ export class Linkifier { * this searches the textContent of the rows. You will want to use \s to match * a space ' ' character for example. * @param {LinkHandler} handler The callback when the link is called. - * @param {number} matchIndex The index of the link from the regex.match(text) - * call. This defaults to 0 (for regular expressions without capture groups). + * @param {LinkMatcherOptions} [options] Options for the link matcher. * @return {number} The ID of the new matcher, this can be used to deregister. */ - public registerLinkMatcher(regex: RegExp, handler: LinkHandler, matchIndex?: number): number { + public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number { if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) { throw new Error('handler cannot be falsy'); } @@ -91,7 +101,8 @@ export class Linkifier { id: this._nextLinkMatcherId++, regex, handler, - matchIndex + matchIndex: options.matchIndex, + validationCallback: options.validationCallback }; this._linkMatchers.push(matcher); return matcher.id; @@ -123,11 +134,20 @@ export class Linkifier { return; } const text = row.textContent; + // TODO: Onl execute handler if isValid for (let i = 0; i < this._linkMatchers.length; i++) { const matcher = this._linkMatchers[i]; const uri = this._findLinkMatch(text, matcher.regex, matcher.matchIndex); if (uri) { - this._doLinkifyRow(rowIndex, uri, matcher.handler); + const linkElement = this._doLinkifyRow(rowIndex, uri, matcher.handler); + // Fire validation callback + if (matcher.validationCallback) { + matcher.validationCallback(uri, isValid => { + if (!isValid) { + linkElement.classList.add(INVALID_LINK_CLASS); + } + }); + } // Only allow a single LinkMatcher to trigger on any given row. return; } @@ -139,8 +159,9 @@ export class Linkifier { * @param {number} rowIndex The index of the row to linkify. * @param {string} uri The uri that has been found. * @param {handler} handler The handler to trigger when the link is triggered. + * @return The link element if it was added, otherwise undefined. */ - private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void { + private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkMatcherHandler): HTMLElement { // Iterate over nodes as we want to consider text nodes const nodes = this._rows[rowIndex].childNodes; for (let i = 0; i < nodes.length; i++) { @@ -150,7 +171,8 @@ export class Linkifier { const linkElement = this._createAnchorElement(uri, handler); if (node.textContent.length === uri.length) { // Matches entire string - if (node.nodeType === Node.TEXT_NODE) { + + if (node.nodeType === 3 /*Node.TEXT_NODE*/) { this._replaceNode(node, linkElement); } else { const element = (node); @@ -165,6 +187,7 @@ export class Linkifier { // Matches part of string this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex); } + return linkElement; } } } @@ -188,11 +211,16 @@ export class Linkifier { * @param {string} uri The uri of the link. * @return {HTMLAnchorElement} The link. */ - private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement { - const element = document.createElement('a'); + private _createAnchorElement(uri: string, handler: LinkMatcherHandler): HTMLAnchorElement { + const element = this._document.createElement('a'); element.textContent = uri; if (handler) { - element.addEventListener('click', () => handler(uri)); + element.addEventListener('click', () => { + // Only execute the handler if the link is not flagged as invalid + if (!element.classList.contains(INVALID_LINK_CLASS)) { + handler(uri); + } + }); } else { element.href = uri; // Force link on another tab so work is not lost @@ -224,7 +252,7 @@ export class Linkifier { */ private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void { let node = targetNode; - if (node.nodeType !== Node.TEXT_NODE) { + if (node.nodeType !== 3/*Node.TEXT_NODE*/) { node = node.childNodes[0]; } @@ -240,19 +268,19 @@ export class Linkifier { if (substringIndex === 0) { // Replace with const rightText = fullText.substring(substring.length); - const rightTextNode = document.createTextNode(rightText); + const rightTextNode = this._document.createTextNode(rightText); this._replaceNode(node, newNode, rightTextNode); } else if (substringIndex === targetNode.textContent.length - substring.length) { // Replace with const leftText = fullText.substring(0, substringIndex); - const leftTextNode = document.createTextNode(leftText); + const leftTextNode = this._document.createTextNode(leftText); this._replaceNode(node, leftTextNode, newNode); } else { // Replace with const leftText = fullText.substring(0, substringIndex); - const leftTextNode = document.createTextNode(leftText); + const leftTextNode = this._document.createTextNode(leftText); const rightText = fullText.substring(substringIndex + substring.length); - const rightTextNode = document.createTextNode(rightText); + const rightTextNode = this._document.createTextNode(rightText); this._replaceNode(node, leftTextNode, newNode, rightTextNode); } } diff --git a/src/Types.ts b/src/Types.ts new file mode 100644 index 0000000000..038a1f7a2c --- /dev/null +++ b/src/Types.ts @@ -0,0 +1,6 @@ +/** + * @license MIT + */ + +export type LinkMatcherHandler = (uri: string) => void; +export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void; diff --git a/src/utils/CharMeasure.phantom.ts b/src/utils/CharMeasure.phantom.ts deleted file mode 100644 index f8173dc628..0000000000 --- a/src/utils/CharMeasure.phantom.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license MIT - */ -import { ICharMeasure, ITerminal } from '../Interfaces'; - -declare var assert: Chai.Assert; -declare var Terminal: ITerminal; - -// Do not describe tests unless in PhantomJS environment -if (typeof Terminal !== 'undefined') { - - const CharMeasure = (Terminal).CharMeasure; - - describe('CharMeasure', () => { - const parentElement = document.createElement('div'); - let charMeasure: ICharMeasure; - - beforeEach(() => { - charMeasure = new CharMeasure(parentElement); - document.querySelector('#xterm').appendChild(parentElement); - }); - - afterEach(() => { - if (parentElement && parentElement.parentElement) { - parentElement.parentElement.removeChild(parentElement); - } - }); - - describe('measure', () => { - it('should be performed async on first call', done => { - assert.equal(charMeasure.width, null); - charMeasure.measure(); - assert.equal(charMeasure.width, null); - setTimeout(() => { - assert.isTrue(charMeasure.width > 0); - done(); - }, 0); - }); - - it('should be performed sync on successive calls', done => { - charMeasure.measure(); - setTimeout(() => { - const firstWidth = charMeasure.width; - parentElement.style.fontSize = '2em'; - charMeasure.measure(); - assert.equal(charMeasure.width, firstWidth * 2); - done(); - }, 0); - }); - - it('should NOT do a measure when the parent is hidden', done => { - charMeasure.measure(); - setTimeout(() => { - const firstWidth = charMeasure.width; - parentElement.style.display = 'none'; - parentElement.style.fontSize = '2em'; - charMeasure.measure(); - assert.equal(charMeasure.width, firstWidth); - done(); - }, 0); - }); - }); - }); - -} diff --git a/src/utils/CharMeasure.test.ts b/src/utils/CharMeasure.test.ts new file mode 100644 index 0000000000..17e675d1cd --- /dev/null +++ b/src/utils/CharMeasure.test.ts @@ -0,0 +1,77 @@ +/** + * @license MIT + */ +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { ICharMeasure, ITerminal } from '../Interfaces'; +import { CharMeasure } from './CharMeasure'; + +describe('CharMeasure', () => { + let window: Window; + let document: Document; + + let container: HTMLElement; + let charMeasure: ICharMeasure; + + beforeEach(done => { + jsdom.env('', (err, w) => { + window = w; + document = window.document; + container = document.createElement('div'); + document.body.appendChild(container); + charMeasure = new CharMeasure(document, container); + done(); + }); + }); + + describe('measure', () => { + it('should set _measureElement on first call', () => { + charMeasure.measure(); + assert.isDefined((charMeasure)._measureElement, 'CharMeasure.measure should have created _measureElement'); + }); + + it('should be performed async on first call', done => { + assert.equal(charMeasure.width, null); + charMeasure.measure(); + // Mock getBoundingClientRect since jsdom doesn't have a layout engine + (charMeasure)._measureElement.getBoundingClientRect = () => { + return { width: 1, height: 1 }; + }; + assert.equal(charMeasure.width, null); + setTimeout(() => { + assert.equal(charMeasure.width, 1); + done(); + }, 0); + }); + + it('should be performed sync on successive calls', done => { + charMeasure.measure(); + // Mock getBoundingClientRect since jsdom doesn't have a layout engine + (charMeasure)._measureElement.getBoundingClientRect = () => { + return { width: 1, height: 1 }; + }; + setTimeout(() => { + const firstWidth = charMeasure.width; + // Mock getBoundingClientRect since jsdom doesn't have a layout engine + (charMeasure)._measureElement.getBoundingClientRect = () => { + return { width: 2, height: 2 }; + }; + charMeasure.measure(); + assert.equal(charMeasure.width, firstWidth * 2); + done(); + }, 0); + }); + + it('should NOT do a measure when the parent is hidden', done => { + charMeasure.measure(); + setTimeout(() => { + const firstWidth = charMeasure.width; + container.style.display = 'none'; + container.style.fontSize = '2em'; + charMeasure.measure(); + assert.equal(charMeasure.width, firstWidth); + done(); + }, 0); + }); + }); +}); diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index fdad40a949..45b06f5916 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -9,13 +9,15 @@ import { EventEmitter } from '../EventEmitter.js'; * Utility class that measures the size of a character. */ export class CharMeasure extends EventEmitter { + private _document: Document; private _parentElement: HTMLElement; private _measureElement: HTMLElement; private _width: number; private _height: number; - constructor(parentElement: HTMLElement) { + constructor(document: Document, parentElement: HTMLElement) { super(); + this._document = document; this._parentElement = parentElement; } @@ -29,7 +31,7 @@ export class CharMeasure extends EventEmitter { public measure(): void { if (!this._measureElement) { - this._measureElement = document.createElement('span'); + this._measureElement = this._document.createElement('span'); this._measureElement.style.position = 'absolute'; this._measureElement.style.top = '0'; this._measureElement.style.left = '-9999em'; diff --git a/src/xterm.css b/src/xterm.css index f99eb68857..0401e607b2 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -81,6 +81,11 @@ text-decoration: underline; } +.terminal a.xterm-invalid-link:hover { + cursor: default; + text-decoration: none; +} + .terminal:not(.xterm-cursor-style-underline):not(.xterm-cursor-style-bar) .terminal-cursor { background-color: #fff; color: #000; diff --git a/src/xterm.js b/src/xterm.js index d09d5fb2e4..a08e9dd145 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -612,7 +612,7 @@ Terminal.prototype.open = function(parent) { this.rowContainer.classList.add('xterm-rows'); this.element.appendChild(this.rowContainer); this.children = []; - this.linkifier = new Linkifier(this.children); + this.linkifier = new Linkifier(document, this.children); // Create the container that will hold helpers like the textarea for // capturing DOM Events. Then produce the helpers. @@ -647,7 +647,7 @@ Terminal.prototype.open = function(parent) { } this.parent.appendChild(this.element); - this.charMeasure = new CharMeasure(this.helperContainer); + this.charMeasure = new CharMeasure(document, this.helperContainer); this.charMeasure.on('charsizechanged', function () { self.updateCharSizeCSS(); }); @@ -1303,13 +1303,12 @@ Terminal.prototype.attachHypertextLinkHandler = function(handler) { * this searches the textContent of the rows. You will want to use \s to match * a space ' ' character for example. * @param {LinkHandler} handler The callback when the link is called. - * @param {number} matchIndex The index of the link from the regex.match(text) - * call. This defaults to 0 (for regular expressions without capture groups). + * @param {LinkMatcherOptions} [options] Options for the link matcher. * @return {number} The ID of the new matcher, this can be used to deregister. */ -Terminal.prototype.registerLinkMatcher = function(regex, handler, matchIndex) { +Terminal.prototype.registerLinkMatcher = function(regex, handler, options) { if (this.linkifier) { - var matcherId = this.linkifier.registerLinkMatcher(regex, handler, matchIndex); + var matcherId = this.linkifier.registerLinkMatcher(regex, handler, options); this.refresh(0, this.rows - 1); return matcherId; } @@ -2301,9 +2300,6 @@ function keys(obj) { Terminal.EventEmitter = EventEmitter; Terminal.inherits = inherits; -// Expose for Phantom.JS tests -Terminal.CharMeasure = CharMeasure; - /** * Adds an event listener to the terminal. * diff --git a/test-harness.html b/test-harness.html deleted file mode 100644 index 8c5f0b303a..0000000000 --- a/test-harness.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - -
-
- - - - - - - -