/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as browser from 'vs/base/browser/browser';
import {FastDomNode, createFastDomNode} from 'vs/base/browser/styleMutator';
import {HorizontalRange, IConfigurationChangedEvent, IModelDecoration} from 'vs/editor/common/editorCommon';
import {ILineParts, createLineParts} from 'vs/editor/common/viewLayout/viewLineParts';
import {renderLine, RenderLineInput} from 'vs/editor/common/viewLayout/viewLineRenderer';
import {ClassNames, IViewContext} from 'vs/editor/browser/editorBrowser';
import {IVisibleLineData} from 'vs/editor/browser/view/viewLayer';
export class ViewLine implements IVisibleLineData {
protected _context:IViewContext;
private _domNode: FastDomNode;
private _lineParts: ILineParts;
private _isInvalid: boolean;
private _isMaybeInvalid: boolean;
protected _charOffsetInPart:number[];
private _lastRenderedPartIndex:number;
private _cachedWidth: number;
constructor(context:IViewContext) {
this._context = context;
this._domNode = null;
this._isInvalid = true;
this._isMaybeInvalid = false;
this._lineParts = null;
this._charOffsetInPart = [];
this._lastRenderedPartIndex = 0;
}
// --- begin IVisibleLineData
public getDomNode(): HTMLElement {
if (!this._domNode) {
return null;
}
return this._domNode.domNode;
}
public setDomNode(domNode:HTMLElement): void {
this._domNode = createFastDomNode(domNode);
}
public onContentChanged(): void {
this._isInvalid = true;
}
public onLinesInsertedAbove(): void {
this._isMaybeInvalid = true;
}
public onLinesDeletedAbove(): void {
this._isMaybeInvalid = true;
}
public onLineChangedAbove(): void {
this._isMaybeInvalid = true;
}
public onTokensChanged(): void {
this._isMaybeInvalid = true;
}
public onModelDecorationsChanged(): void {
this._isMaybeInvalid = true;
}
public onConfigurationChanged(e:IConfigurationChangedEvent): void {
this._isInvalid = true;
}
public shouldUpdateHTML(lineNumber:number, inlineDecorations:IModelDecoration[]): boolean {
let newLineParts:ILineParts = null;
if (this._isMaybeInvalid || this._isInvalid) {
// Compute new line parts only if there is some evidence that something might have changed
newLineParts = createLineParts(
lineNumber,
this._context.model.getLineMinColumn(lineNumber),
this._context.model.getLineContent(lineNumber),
this._context.model.getLineTokens(lineNumber),
inlineDecorations,
this._context.configuration.editor.renderWhitespace
);
}
// Decide if isMaybeInvalid flips isInvalid to true
if (this._isMaybeInvalid) {
if (!this._isInvalid) {
if (!this._lineParts || !this._lineParts.equals(newLineParts)) {
this._isInvalid = true;
}
}
this._isMaybeInvalid = false;
}
if (this._isInvalid) {
this._lineParts = newLineParts;
}
return this._isInvalid;
}
public getLineOuterHTML(out:string[], lineNumber:number, deltaTop:number): void {
out.push('
');
out.push(this.getLineInnerHTML(lineNumber));
out.push('
');
}
public getLineInnerHTML(lineNumber: number): string {
this._isInvalid = false;
return this._render(lineNumber, this._lineParts).join('');
}
public layoutLine(lineNumber:number, deltaTop:number): void {
this._domNode.setLineNumber(String(lineNumber));
this._domNode.setTop(deltaTop);
this._domNode.setHeight(this._context.configuration.editor.lineHeight);
}
// --- end IVisibleLineData
private _render(lineNumber:number, lineParts:ILineParts): string[] {
this._cachedWidth = -1;
let r = renderLine(new RenderLineInput(
this._context.model.getLineContent(lineNumber),
this._context.model.getTabSize(),
this._context.configuration.editor.stopRenderingLineAfter,
this._context.configuration.editor.renderWhitespace,
lineParts.getParts()
));
this._charOffsetInPart = r.charOffsetInPart;
this._lastRenderedPartIndex = r.lastRenderedPartIndex;
return r.output;
}
// --- Reading from the DOM methods
protected _getReadingTarget(): HTMLElement {
return this._domNode.domNode.firstChild;
}
/**
* Width of the line in pixels
*/
public getWidth(): number {
if (this._cachedWidth === -1) {
this._cachedWidth = this._getReadingTarget().offsetWidth;
}
return this._cachedWidth;
}
/**
* Visible ranges for a model range
*/
public getVisibleRangesForRange(startColumn:number, endColumn:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
startColumn = +startColumn; // @perf
endColumn = +endColumn; // @perf
clientRectDeltaLeft = +clientRectDeltaLeft; // @perf
let stopRenderingLineAfter = +this._context.configuration.editor.stopRenderingLineAfter; // @perf
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter && endColumn > stopRenderingLineAfter) {
// This range is obviously not visible
return null;
}
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter) {
startColumn = stopRenderingLineAfter;
}
if (stopRenderingLineAfter !== -1 && endColumn > stopRenderingLineAfter) {
endColumn = stopRenderingLineAfter;
}
return this._readVisibleRangesForRange(startColumn, endColumn, clientRectDeltaLeft, endNode);
}
protected _readVisibleRangesForRange(startColumn:number, endColumn:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
let result: HorizontalRange[];
if (startColumn === endColumn) {
result = this._readRawVisibleRangesForPosition(startColumn, clientRectDeltaLeft, endNode);
} else {
result = this._readRawVisibleRangesForRange(startColumn, endColumn, clientRectDeltaLeft, endNode);
}
if (!result || result.length <= 1) {
return result;
}
result.sort(compareVisibleRanges);
let output: HorizontalRange[] = [];
let prevRange: HorizontalRange = result[0];
for (let i = 1, len = result.length; i < len; i++) {
let currRange = result[i];
if (prevRange.left + prevRange.width + 0.9 /* account for browser's rounding errors*/ >= currRange.left) {
prevRange.width = Math.max(prevRange.width, currRange.left + currRange.width - prevRange.left);
} else {
output.push(prevRange);
prevRange = currRange;
}
}
output.push(prevRange);
return output;
}
protected _readRawVisibleRangesForPosition(column:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
if (this._charOffsetInPart.length === 0) {
// This line is empty
return [new HorizontalRange(0, 0)];
}
let partIndex = findIndexInArrayWithMax(this._lineParts, column - 1, this._lastRenderedPartIndex);
let charOffsetInPart = this._charOffsetInPart[column - 1];
return this._readRawVisibleRangesFrom(this._getReadingTarget(), partIndex, charOffsetInPart, partIndex, charOffsetInPart, clientRectDeltaLeft, endNode);
}
private _readRawVisibleRangesForRange(startColumn:number, endColumn:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
if (startColumn === 1 && endColumn === this._charOffsetInPart.length) {
// This branch helps IE with bidi text & gives a performance boost to other browsers when reading visible ranges for an entire line
return [this._readRawVisibleRangeForEntireLine()];
}
let startPartIndex = findIndexInArrayWithMax(this._lineParts, startColumn - 1, this._lastRenderedPartIndex);
let startCharOffsetInPart = this._charOffsetInPart[startColumn - 1];
let endPartIndex = findIndexInArrayWithMax(this._lineParts, endColumn - 1, this._lastRenderedPartIndex);
let endCharOffsetInPart = this._charOffsetInPart[endColumn - 1];
return this._readRawVisibleRangesFrom(this._getReadingTarget(), startPartIndex, startCharOffsetInPart, endPartIndex, endCharOffsetInPart, clientRectDeltaLeft, endNode);
}
private _readRawVisibleRangeForEntireLine(): HorizontalRange {
return new HorizontalRange(0, this._getReadingTarget().offsetWidth);
}
private _readRawVisibleRangesFrom(domNode:HTMLElement, startChildIndex:number, startOffset:number, endChildIndex:number, endOffset:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
let range = RangeUtil.createRange();
try {
// Panic check
let min = 0;
let max = domNode.children.length - 1;
if (min > max) {
return null;
}
startChildIndex = Math.min(max, Math.max(min, startChildIndex));
endChildIndex = Math.min(max, Math.max(min, endChildIndex));
// If crossing over to a span only to select offset 0, then use the previous span's maximum offset
// Chrome is buggy and doesn't handle 0 offsets well sometimes.
if (startChildIndex !== endChildIndex) {
if (endChildIndex > 0 && endOffset === 0) {
endChildIndex--;
endOffset = Number.MAX_VALUE;
}
}
let startElement = domNode.children[startChildIndex].firstChild;
let endElement = domNode.children[endChildIndex].firstChild;
if (!startElement || !endElement) {
return null;
}
startOffset = Math.min(startElement.textContent.length, Math.max(0, startOffset));
endOffset = Math.min(endElement.textContent.length, Math.max(0, endOffset));
range.setStart(startElement, startOffset);
range.setEnd(endElement, endOffset);
let clientRects = range.getClientRects();
if (clientRects.length === 0) {
return null;
}
return this._createRawVisibleRangesFromClientRects(clientRects, clientRectDeltaLeft);
} catch (e) {
// This is life ...
return null;
} finally {
RangeUtil.detachRange(range, endNode);
}
}
protected _createRawVisibleRangesFromClientRects(clientRects:ClientRectList, clientRectDeltaLeft:number): HorizontalRange[] {
let result:HorizontalRange[] = [];
for (let i = 0, len = clientRects.length; i < len; i++) {
let cR = clientRects[i];
result.push(new HorizontalRange(Math.max(0, cR.left - clientRectDeltaLeft), cR.width));
}
return result;
}
/**
* Returns the column for the text found at a specific offset inside a rendered dom node
*/
public getColumnOfNodeOffset(lineNumber:number, spanNode:HTMLElement, offset:number): number {
let spanIndex = -1;
while (spanNode) {
spanNode = spanNode.previousSibling;
spanIndex++;
}
let lineParts = this._lineParts.getParts();
if (spanIndex >= lineParts.length) {
return this._context.configuration.editor.stopRenderingLineAfter;
}
if (offset === 0) {
return lineParts[spanIndex].startIndex + 1;
}
let originalMin = lineParts[spanIndex].startIndex;
let originalMax:number;
let originalMaxStartOffset:number;
if (spanIndex + 1 < lineParts.length) {
// Stop searching characters at the beginning of the next part
originalMax = lineParts[spanIndex + 1].startIndex;
originalMaxStartOffset = this._charOffsetInPart[originalMax - 1] + this._charOffsetInPart[originalMax];
} else {
originalMax = this._context.model.getLineMaxColumn(lineNumber) - 1;
originalMaxStartOffset = this._charOffsetInPart[originalMax];
}
let min = originalMin;
let max = originalMax;
if (this._context.configuration.editor.stopRenderingLineAfter !== -1) {
max = Math.min(this._context.configuration.editor.stopRenderingLineAfter - 1, originalMax);
}
let nextStartOffset:number;
let prevStartOffset:number;
// Here are the variables and their relation plotted on an axis
// prevStartOffset a midStartOffset b nextStartOffset
// ------|------------|----------|-----------|-----------|--------->
// Everything in (a;b] will match mid
while (min < max) {
let mid = Math.floor( (min + max) / 2 );
let midStartOffset = this._charOffsetInPart[mid];
if (mid === originalMax) {
// Using Number.MAX_VALUE to ensure that any offset after midStartOffset will match mid
nextStartOffset = Number.MAX_VALUE;
} else if (mid + 1 === originalMax) {
// mid + 1 is already in next part and might have the _charOffsetInPart = 0
nextStartOffset = originalMaxStartOffset;
} else {
nextStartOffset = this._charOffsetInPart[mid + 1];
}
if (mid === originalMin) {
// Using Number.MIN_VALUE to ensure that any offset before midStartOffset will match mid
prevStartOffset = Number.MIN_VALUE;
} else {
prevStartOffset = this._charOffsetInPart[mid - 1];
}
let a = (prevStartOffset + midStartOffset) / 2;
let b = (midStartOffset + nextStartOffset) / 2;
if (a < offset && offset <= b) {
// Hit!
return mid + 1;
}
if (offset <= a) {
max = mid - 1;
} else {
min = mid + 1;
}
}
return min + 1;
}
}
class IEViewLine extends ViewLine {
constructor(context:IViewContext) {
super(context);
}
protected _createRawVisibleRangesFromClientRects(clientRects:ClientRectList, clientRectDeltaLeft:number): HorizontalRange[] {
let ratioX = screen.logicalXDPI / screen.deviceXDPI;
let result:HorizontalRange[] = [];
for (let i = 0, len = clientRects.length; i < len; i++) {
let cR = clientRects[i];
result[i] = new HorizontalRange(Math.max(0, cR.left * ratioX - clientRectDeltaLeft), cR.width * ratioX);
}
return result;
}
}
class WebKitViewLine extends ViewLine {
constructor(context:IViewContext) {
super(context);
}
protected _readVisibleRangesForRange(startColumn:number, endColumn:number, clientRectDeltaLeft:number, endNode:HTMLElement): HorizontalRange[] {
let output = super._readVisibleRangesForRange(startColumn, endColumn, clientRectDeltaLeft, endNode);
if (this._context.configuration.editor.fontLigatures && output.length === 1 && endColumn > 1 && endColumn === this._charOffsetInPart.length) {
let lastSpanBoundingClientRect = (this._getReadingTarget().lastChild).getBoundingClientRect();
let lastSpanBoundingClientRectRight = lastSpanBoundingClientRect.right - clientRectDeltaLeft;
if (startColumn === endColumn) {
output[0].left = lastSpanBoundingClientRectRight;
output[0].width = 0;
} else {
output[0].width = lastSpanBoundingClientRectRight - output[0].left;
}
return output;
}
if (!output || output.length === 0 || startColumn === endColumn || (startColumn === 1 && endColumn === this._charOffsetInPart.length)) {
return output;
}
// WebKit is buggy and returns an expanded range (to contain words in some cases)
// The last client rect is enlarged (I think)
// This is an attempt to patch things up
// Find position of previous column
let beforeEndVisibleRanges = this._readRawVisibleRangesForPosition(endColumn - 1, clientRectDeltaLeft, endNode);
// Find position of last column
let endVisibleRanges = this._readRawVisibleRangesForPosition(endColumn, clientRectDeltaLeft, endNode);
if (beforeEndVisibleRanges && beforeEndVisibleRanges.length > 0 && endVisibleRanges && endVisibleRanges.length > 0) {
let beforeEndVisibleRange = beforeEndVisibleRanges[0];
let endVisibleRange = endVisibleRanges[0];
let isLTR = (beforeEndVisibleRange.left <= endVisibleRange.left);
let lastRange = output[output.length - 1];
if (isLTR && lastRange.left < endVisibleRange.left) {
// Trim down the width of the last visible range to not go after the last column's position
lastRange.width = endVisibleRange.left - lastRange.left;
}
}
return output;
}
}
class RangeUtil {
/**
* Reusing the same range here
* because IE is buggy and constantly freezes when using a large number
* of ranges and calling .detach on them
*/
private static _handyReadyRange:Range;
public static createRange(): Range {
if (!RangeUtil._handyReadyRange) {
RangeUtil._handyReadyRange = document.createRange();
}
return RangeUtil._handyReadyRange;
}
public static detachRange(range:Range, endNode:HTMLElement): void {
// Move range out of the span node, IE doesn't like having many ranges in
// the same spot and will act badly for lines containing dashes ('-')
range.selectNodeContents(endNode);
}
}
function compareVisibleRanges(a: HorizontalRange, b: HorizontalRange): number {
return a.left - b.left;
}
function findIndexInArrayWithMax(lineParts:ILineParts, desiredIndex: number, maxResult:number): number {
let r = lineParts.findIndexOfOffset(desiredIndex);
return r <= maxResult ? r : maxResult;
}
export let createLine: (context: IViewContext) => ViewLine = (function() {
if (window.screen && window.screen.deviceXDPI && (navigator.userAgent.indexOf('Trident/6.0') >= 0 || navigator.userAgent.indexOf('Trident/5.0') >= 0)) {
// IE11 doesn't need the screen.logicalXDPI / screen.deviceXDPI ratio multiplication
// for TextRange.getClientRects() anymore
return createIELine;
} else if (browser.isWebKit) {
return createWebKitLine;
}
return createNormalLine;
})();
function createIELine(context: IViewContext): ViewLine {
return new IEViewLine(context);
}
function createWebKitLine(context: IViewContext): ViewLine {
return new WebKitViewLine(context);
}
function createNormalLine(context: IViewContext): ViewLine {
return new ViewLine(context);
}