Support multiple links in a line, fix Linkifier tests

This commit is contained in:
Daniel Imms
2017-09-03 13:56:53 -07:00
parent b30cb58ff4
commit 17731c3de3
9 changed files with 136 additions and 114 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ export class Buffer implements IBuffer {
if (this._lines.length > 0) {
// Deal with columns increasing (we don't do anything when columns reduce)
if (this._terminal.cols < newCols) {
const ch: CharData = [this._terminal.defAttr, ' ', 1]; // does xterm use the default attr?
const ch: CharData = [this._terminal.defAttr, ' ', 1, 32]; // does xterm use the default attr?
for (let i = 0; i < this._lines.length; i++) {
// TODO: This should be removed, with tests setup for the case that was
// causing the underlying bug, see https://github.com/sourcelair/xterm.js/issues/824
+4 -4
View File
@@ -189,7 +189,7 @@ export class InputHandler implements IInputHandler {
const row = this._terminal.buffer.y + this._terminal.buffer.ybase;
let j = this._terminal.buffer.x;
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm
while (param-- && j < this._terminal.cols) {
this._terminal.buffer.lines.get(row).splice(j++, 0, ch);
@@ -488,7 +488,7 @@ export class InputHandler implements IInputHandler {
}
const row = this._terminal.buffer.y + this._terminal.buffer.ybase;
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm
while (param--) {
this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 1);
@@ -536,7 +536,7 @@ export class InputHandler implements IInputHandler {
const row = this._terminal.buffer.y + this._terminal.buffer.ybase;
let j = this._terminal.buffer.x;
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm
const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm
while (param-- && j < this._terminal.cols) {
this._terminal.buffer.lines.get(row)[j++] = ch;
@@ -590,7 +590,7 @@ export class InputHandler implements IInputHandler {
public repeatPrecedingCharacter(params: number[]): void {
let param = params[0] || 1;
const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y);
const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1];
const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32];
while (param--) {
line[this._terminal.buffer.x++] = ch;
+5 -2
View File
@@ -18,7 +18,11 @@ export interface IBrowser {
isMSWindows: boolean;
}
export interface ITerminal extends IEventEmitter {
export interface IBufferAccessor {
buffer: IBuffer;
}
export interface ITerminal extends IBufferAccessor, IEventEmitter {
element: HTMLElement;
selectionManager: ISelectionManager;
charMeasure: ICharMeasure;
@@ -32,7 +36,6 @@ export interface ITerminal extends IEventEmitter {
defAttr: number;
options: ITerminalOptions;
buffers: IBufferSet;
buffer: IBuffer;
isFocused: boolean;
/**
+87 -76
View File
@@ -2,42 +2,84 @@
* @license MIT
*/
import jsdom = require('jsdom');
import { assert } from 'chai';
import { ITerminal, ILinkifier } from './Interfaces';
import { ITerminal, ILinkifier, IBuffer, IBufferAccessor } from './Interfaces';
import { Linkifier } from './Linkifier';
import { LinkMatcher } from './Types';
import { LinkMatcher, LineData } from './Types';
import { IMouseZoneManager, IMouseZone } from './input/Interfaces';
import { MockBuffer } from './utils/TestUtils.test';
import { CircularList } from './utils/CircularList';
class TestLinkifier extends Linkifier {
constructor() {
constructor(bufferAccessor: IBufferAccessor) {
Linkifier.TIME_BEFORE_LINKIFY = 0;
super();
super(bufferAccessor);
}
public get linkMatchers(): LinkMatcher[] { return this._linkMatchers; }
}
describe('Linkifier', () => {
let dom: jsdom.JSDOM;
let window: Window;
let document: Document;
class TestMouseZoneManager implements IMouseZoneManager {
public clears: number = 0;
public zones: IMouseZone[] = [];
add(zone: IMouseZone): void {
this.zones.push(zone);
}
clearAll(): void {
this.clears++;
}
}
let container: HTMLElement;
let rows: HTMLElement[];
describe('Linkifier', () => {
let bufferAccessor: IBufferAccessor;
let linkifier: TestLinkifier;
let mouseZoneManager: TestMouseZoneManager;
beforeEach(() => {
dom = new jsdom.JSDOM('');
window = dom.window;
document = window.document;
linkifier = new TestLinkifier();
bufferAccessor = { buffer: new MockBuffer() };
bufferAccessor.buffer.lines = new CircularList<LineData>(20);
bufferAccessor.buffer.ydisp = 0;
linkifier = new TestLinkifier(bufferAccessor);
mouseZoneManager = new TestMouseZoneManager();
});
function addRow(html: string): void {
const element = document.createElement('div');
element.innerHTML = html;
container.appendChild(element);
rows.push(element);
function stringToRow(text: string): LineData {
let result: LineData = [];
for (let i = 0; i < text.length; i++) {
result.push([0, text.charAt(i), 1, text.charCodeAt(i)]);
}
return result;
}
function addRow(text: string): void {
bufferAccessor.buffer.lines.push(stringToRow(text));
}
function assertLinkifiesEntireRow(uri: string, done: MochaDone): void {
addRow(uri);
linkifier.linkifyRow(0);
setTimeout(() => {
assert.equal(mouseZoneManager.zones[0].x1, 1);
assert.equal(mouseZoneManager.zones[0].x2, uri.length + 1);
assert.equal(mouseZoneManager.zones[0].y, bufferAccessor.buffer.lines.length);
done();
}, 0);
}
function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, links: {x: number, length: number}[], done: MochaDone): void {
addRow(rowText);
linkifier.registerLinkMatcher(linkMatcherRegex, () => {});
linkifier.linkifyRow(0);
// Allow linkify to happen
setTimeout(() => {
assert.equal(mouseZoneManager.zones.length, links.length);
links.forEach((l, i) => {
assert.equal(mouseZoneManager.zones[i].x1, l.x + 1);
assert.equal(mouseZoneManager.zones[i].x2, l.x + l.length + 1);
assert.equal(mouseZoneManager.zones[i].y, bufferAccessor.buffer.lines.length);
});
done();
}, 0);
}
describe('before attachToDom', () => {
@@ -52,78 +94,42 @@ describe('Linkifier', () => {
describe('after attachToDom', () => {
beforeEach(() => {
rows = [];
linkifier.attachToDom(document, rows);
container = document.createElement('div');
document.body.appendChild(container);
linkifier.attachToDom(mouseZoneManager);
});
function clickElement(element: Node): void {
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);
}
function assertLinkifiesEntireRow(uri: string, done: MochaDone): void {
addRow(uri);
linkifier.linkifyRow(0);
setTimeout(() => {
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
assert.equal((<HTMLElement>rows[0].firstChild).textContent, uri);
done();
}, 0);
}
describe('http links', () => {
function assertLinkifiesEntireRow(uri: string, done: MochaDone): void {
addRow(uri);
linkifier.linkifyRow(0);
setTimeout(() => {
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
assert.equal((<HTMLElement>rows[0].firstChild).textContent, uri);
done();
}, 0);
}
it('should allow ~ character in URI path', done => assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done));
it('should allow ~ character in URI path', (done) => {
assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done);
});
});
describe('link matcher', () => {
function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, expectedHtml: string, done: MochaDone): void {
addRow(rowText);
linkifier.registerLinkMatcher(linkMatcherRegex, () => {});
linkifier.linkifyRow(0);
// Allow linkify to happen
setTimeout(() => {
assert.equal(rows[0].innerHTML, expectedHtml);
done();
}, 0);
}
it('should match a single link', done => {
assertLinkifiesRow('foo', /foo/, '<a>foo</a>', done);
assertLinkifiesRow('foo', /foo/, [{x: 0, length: 3}], done);
});
it('should match a single link at the start of a text node', done => {
assertLinkifiesRow('foo bar', /foo/, '<a>foo</a> bar', done);
assertLinkifiesRow('foo bar', /foo/, [{x: 0, length: 3}], done);
});
it('should match a single link in the middle of a text node', done => {
assertLinkifiesRow('foo bar baz', /bar/, 'foo <a>bar</a> baz', done);
assertLinkifiesRow('foo bar baz', /bar/, [{x: 4, length: 3}], done);
});
it('should match a single link at the end of a text node', done => {
assertLinkifiesRow('foo bar', /bar/, 'foo <a>bar</a>', done);
assertLinkifiesRow('foo bar', /bar/, [{x: 4, length: 3}], done);
});
it('should match a link after a link at the start of a text node', done => {
assertLinkifiesRow('foo bar', /foo|bar/, '<a>foo</a> <a>bar</a>', done);
assertLinkifiesRow('foo bar', /foo|bar/, [{x: 0, length: 3}, {x: 4, length: 3}], done);
});
it('should match a link after a link in the middle of a text node', done => {
assertLinkifiesRow('foo bar baz', /bar|baz/, 'foo <a>bar</a> <a>baz</a>', done);
assertLinkifiesRow('foo bar baz', /bar|baz/, [{x: 4, length: 3}, {x: 8, length: 3}], done);
});
it('should match a link immediately after a link at the end of a text node', done => {
assertLinkifiesRow('<span>foo bar</span>baz', /bar|baz/, '<span>foo <a>bar</a></span><a>baz</a>', done);
assertLinkifiesRow('foo barbaz', /bar|baz/, [{x: 4, length: 3}, {x: 7, length: 3}], done);
});
it('should not duplicate text after a unicode character (wrapped in a span)', done => {
// This is a regression test for an issue that came about when using
// an oh-my-zsh theme that added the large blue diamond unicode
// character (U+1F537) which caused the path to be duplicated. See #642.
assertLinkifiesRow('echo \'<span class="xterm-normal-char">🔷</span>foo\'', /foo/, 'echo \'<span class="xterm-normal-char">🔷</span><a>foo</a>\'', done);
assertLinkifiesRow('echo \'🔷foo\'', /foo/, [{x: 8, length: 3}], done);
});
});
@@ -131,10 +137,15 @@ describe('Linkifier', () => {
it('should enable link if true', done => {
addRow('test');
linkifier.registerLinkMatcher(/test/, () => done(), {
validationCallback: (url, element, cb) => {
validationCallback: (url, cb) => {
assert.equal(mouseZoneManager.zones.length, 0);
cb(true);
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
setTimeout(() => clickElement(rows[0].firstChild), 0);
assert.equal(mouseZoneManager.zones.length, 1);
assert.equal(mouseZoneManager.zones[0].x1, 1);
assert.equal(mouseZoneManager.zones[0].x2, 5);
assert.equal(mouseZoneManager.zones[0].y, 1);
// Fires done()
mouseZoneManager.zones[0].clickCallback(<any>{});
}
});
linkifier.linkifyRow(0);
@@ -143,14 +154,14 @@ describe('Linkifier', () => {
it('should disable link if false', done => {
addRow('test');
linkifier.registerLinkMatcher(/test/, () => assert.fail(), {
validationCallback: (url, element, cb) => {
validationCallback: (url, cb) => {
assert.equal(mouseZoneManager.zones.length, 0);
cb(false);
assert.equal((<HTMLElement>rows[0].firstChild).tagName, 'A');
setTimeout(() => clickElement(rows[0].firstChild), 0);
assert.equal(mouseZoneManager.zones.length, 0);
}
});
linkifier.linkifyRow(0);
// Allow time for the click to be performed
// Allow time for the validation callback to be performed
setTimeout(() => done(), 10);
});
@@ -158,7 +169,7 @@ describe('Linkifier', () => {
addRow('test test');
let count = 0;
linkifier.registerLinkMatcher(/test/, () => assert.fail(), {
validationCallback: (url, element, cb) => {
validationCallback: (url, cb) => {
count += 1;
if (count === 2) {
done();
+12 -9
View File
@@ -2,7 +2,7 @@
* @license MIT
*/
import { ILinkMatcherOptions, ITerminal } from './Interfaces';
import { ILinkMatcherOptions, ITerminal, IBufferAccessor } from './Interfaces';
import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback, LineData } from './Types';
import { IMouseZoneManager } from './input/Interfaces';
import { MouseZone } from './input/MouseZoneManager';
@@ -52,7 +52,7 @@ export class Linkifier {
private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
constructor(
private _terminal: ITerminal
private _terminal: IBufferAccessor
) {
this._rowTimeoutIds = [];
this._linkMatchers = [];
@@ -213,7 +213,7 @@ export class Linkifier {
* @param {LinkMatcher} matcher The link matcher for this line.
* @return The link element(s) that were added.
*/
private _doLinkifyRow(rowIndex: number, text: string, matcher: LinkMatcher): void {
private _doLinkifyRow(rowIndex: number, text: string, matcher: LinkMatcher, offset: number = 0): void {
// Iterate over nodes as we want to consider text nodes
let result = [];
const isHttpLinkMatcher = matcher.id === HYPERTEXT_LINK_MATCHER_ID;
@@ -225,10 +225,6 @@ export class Linkifier {
}
let uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
// TODO: Match more than one link per row
// Set the next searches start index
// let rowStartIndex = match.index + uri.length;
// Get index, match.index is for the outer match which includes negated chars
const index = text.indexOf(uri);
@@ -237,11 +233,18 @@ export class Linkifier {
matcher.validationCallback(text, isValid => {
if (isValid) {
// TODO: Discard link if the line has already changed?
this._addLink(index, rowIndex, uri, matcher);
this._addLink(offset + index, rowIndex, uri, matcher);
}
});
} else {
this._addLink(index, rowIndex, uri, matcher);
this._addLink(offset + index, rowIndex, uri, matcher);
}
// Recursively check for links in the rest of the text
const remainingStartIndex = index + uri.length;
const remainingText = text.substr(remainingStartIndex);
if (remainingText.length > 0) {
this._doLinkifyRow(rowIndex, remainingText, matcher, offset + remainingStartIndex);
}
}
+16 -16
View File
@@ -60,7 +60,7 @@ describe('SelectionManager', () => {
function stringToRow(text: string): LineData {
let result: LineData = [];
for (let i = 0; i < text.length; i++) {
result.push([0, text.charAt(i), 1]);
result.push([0, text.charAt(i), 1, text.charCodeAt(i)]);
}
return result;
}
@@ -99,21 +99,21 @@ describe('SelectionManager', () => {
it('should expand selection for wide characters', () => {
// Wide characters use a special format
buffer.lines.set(0, [
[null, '中', 2],
[null, '', 0],
[null, '文', 2],
[null, '', 0],
[null, ' ', 1],
[null, 'a', 1],
[null, '中', 2],
[null, '', 0],
[null, '文', 2],
[null, '', 0],
[null, 'b', 1],
[null, ' ', 1],
[null, 'f', 1],
[null, 'o', 1],
[null, 'o', 1]
[null, '中', 2, '中'.charCodeAt(0)],
[null, '', 0, null],
[null, '文', 2, '文'.charCodeAt(0)],
[null, '', 0, null],
[null, ' ', 1, ' '.charCodeAt(0)],
[null, 'a', 1, 'a'.charCodeAt(0)],
[null, '中', 2, '中'.charCodeAt(0)],
[null, '', 0, null],
[null, '文', 2, '文'.charCodeAt(0)],
[null, '', 0, ''.charCodeAt(0)],
[null, 'b', 1, 'b'.charCodeAt(0)],
[null, ' ', 1, ' '.charCodeAt(0)],
[null, 'f', 1, 'f'.charCodeAt(0)],
[null, 'o', 1, 'o'.charCodeAt(0)],
[null, 'o', 1, 'o'.charCodeAt(0)]
]);
// Ensure wide characters take up 2 columns
selectionManager.selectWordAt([0, 0]);
+1 -2
View File
@@ -17,6 +17,5 @@ export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: bo
export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;
export type Charset = {[key: string]: string};
// TODO: Add code here?
export type CharData = [number, string, number];
export type CharData = [number, string, number, number];
export type LineData = CharData[];
+5 -4
View File
@@ -4,6 +4,7 @@
import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper } from '../Interfaces';
import { LineData } from '../Types';
import { Buffer } from '../Buffer';
import * as Browser from './Browser';
import { IColorSet } from '../renderer/Interfaces';
@@ -61,7 +62,7 @@ export class MockTerminal implements ITerminal {
const line: LineData = [];
cols = cols || this.cols;
for (let i = 0; i < cols; i++) {
line.push([0, ' ', 1]);
line.push([0, ' ', 1, 32]);
}
return line;
}
@@ -130,7 +131,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal {
eraseLeft(x: number, y: number): void {
throw new Error('Method not implemented.');
}
blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number][] {
blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number, number][] {
throw new Error('Method not implemented.');
}
prevStop(x?: number): number {
@@ -182,7 +183,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal {
export class MockBuffer implements IBuffer {
isCursorInViewport: boolean;
lines: ICircularList<[number, string, number][]>;
lines: ICircularList<[number, string, number, number][]>;
ydisp: number;
ybase: number;
y: number;
@@ -193,7 +194,7 @@ export class MockBuffer implements IBuffer {
savedY: number;
savedX: number;
translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string {
throw new Error('Method not implemented.');
return Buffer.prototype.translateBufferLineToString.apply(this, arguments);
}
nextStop(x?: number): number {
throw new Error('Method not implemented.');
+5
View File
@@ -133,6 +133,11 @@ interface ILinkMatcherOptions {
*/
validationCallback?: (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => void;
/**
* A callback that fired when the mouse hovers over a link.
*/
hoverCallback?: LinkMatcherHandler;
/**
* 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