blob: 66ba767ac9cf56e2fb822c6a225f670f97fa3cd5 [file] [log] [blame]
// Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
lib.rtdep('hterm.PubSub', 'hterm.Size');
/**
* A 'viewport' view of fixed-height rows with support for selection and
* copy-to-clipboard.
*
* 'Viewport' in this case means that only the visible rows are in the DOM.
* If the rowProvider has 100,000 rows, but the ScrollPort is only 25 rows
* tall, then only 25 dom nodes are created. The ScrollPort will ask the
* RowProvider to create new visible rows on demand as they are scrolled in
* to the visible area.
*
* This viewport is designed so that select and copy-to-clipboard still works,
* even when all or part of the selection is scrolled off screen.
*
* Note that the X11 mouse clipboard does not work properly when all or part
* of the selection is off screen. It would be difficult to fix this without
* adding significant overhead to pathologically large selection cases.
*
* The RowProvider should return rows rooted by the custom tag name 'x-row'.
* This ensures that we can quickly assign the correct display height
* to the rows with css.
*
* @param {RowProvider} rowProvider An object capable of providing rows as
* raw text or row nodes.
*/
hterm.ScrollPort = function(rowProvider) {
hterm.PubSub.addBehavior(this);
this.rowProvider_ = rowProvider;
// SWAG the character size until we can measure it.
this.characterSize = new hterm.Size(10, 10);
// DOM node used for character measurement.
this.ruler_ = null;
this.selection = new hterm.ScrollPort.Selection(this);
// A map of rowIndex => rowNode for each row that is drawn as part of a
// pending redraw_() call. Null if there is no pending redraw_ call.
this.currentRowNodeCache_ = null;
// A map of rowIndex => rowNode for each row that was drawn as part of the
// previous redraw_() call.
this.previousRowNodeCache_ = {};
// Used during scroll events to detect when the underlying cause is a resize.
this.lastScreenWidth_ = null;
this.lastScreenHeight_ = null;
// True if the user should be allowed to select text in the terminal.
// This is disabled when the host requests mouse drag events so that we don't
// end up with two notions of selection.
this.selectionEnabled_ = true;
// The last row count returned by the row provider, re-populated during
// syncScrollHeight().
this.lastRowCount_ = 0;
/**
* True if the last scroll caused the scrollport to show the final row.
*/
this.isScrolledEnd = true;
// The css rule that we use to control the height of a row.
this.xrowCssRule_ = null;
/**
* A guess at the current scrollbar width, fixed in resize().
*/
this.currentScrollbarWidthPx = 16
this.div_ = null;
this.document_ = null;
// Collection of active timeout handles.
this.timeouts_ = {};
this.observers_ = {};
this.DEBUG_ = false;
}
/**
* Proxy for the native selection object which understands how to walk up the
* DOM to find the containing row node and sort out which comes first.
*
* @param {hterm.ScrollPort} scrollPort The parent hterm.ScrollPort instance.
*/
hterm.ScrollPort.Selection = function(scrollPort) {
this.scrollPort_ = scrollPort;
/**
* The row containing the start of the selection.
*
* This may be partially or fully selected. It may be the selection anchor
* or the focus, but its rowIndex is guaranteed to be less-than-or-equal-to
* that of the endRow.
*
* If only one row is selected then startRow == endRow. If there is no
* selection or the selection is collapsed then startRow == null.
*/
this.startRow = null;
/**
* The row containing the end of the selection.
*
* This may be partially or fully selected. It may be the selection anchor
* or the focus, but its rowIndex is guaranteed to be greater-than-or-equal-to
* that of the startRow.
*
* If only one row is selected then startRow == endRow. If there is no
* selection or the selection is collapsed then startRow == null.
*/
this.endRow = null;
/**
* True if startRow != endRow.
*/
this.isMultiline = null;
/**
* True if the selection is just a point rather than a range.
*/
this.isCollapsed = null;
};
/**
* Given a list of DOM nodes and a container, return the DOM node that
* is first according to a depth-first search.
*
* Returns null if none of the children are found.
*/
hterm.ScrollPort.Selection.prototype.findFirstChild = function(
parent, childAry) {
var node = parent.firstChild;
while (node) {
if (childAry.indexOf(node) != -1)
return node;
if (node.childNodes.length) {
var rv = this.findFirstChild(node, childAry);
if (rv)
return rv;
}
node = node.nextSibling;
}
return null;
};
/**
* Synchronize this object with the current DOM selection.
*
* This is a one-way synchronization, the DOM selection is copied to this
* object, not the other way around.
*/
hterm.ScrollPort.Selection.prototype.sync = function() {
var self = this;
// The dom selection object has no way to tell which nodes come first in
// the document, so we have to figure that out.
//
// This function is used when we detect that the "anchor" node is first.
function anchorFirst() {
self.startRow = anchorRow;
self.startNode = selection.anchorNode;
self.startOffset = selection.anchorOffset;
self.endRow = focusRow;
self.endNode = selection.focusNode;
self.endOffset = selection.focusOffset;
}
// This function is used when we detect that the "focus" node is first.
function focusFirst() {
self.startRow = focusRow;
self.startNode = selection.focusNode;
self.startOffset = selection.focusOffset;
self.endRow = anchorRow;
self.endNode = selection.anchorNode;
self.endOffset = selection.anchorOffset;
}
var selection = this.scrollPort_.getDocument().getSelection();
this.startRow = null;
this.endRow = null;
this.isMultiline = null;
this.isCollapsed = !selection || selection.isCollapsed;
if (this.isCollapsed)
return;
var anchorRow = selection.anchorNode;
while (anchorRow && !('rowIndex' in anchorRow)) {
anchorRow = anchorRow.parentNode;
}
if (!anchorRow) {
console.error('Selection anchor is not rooted in a row node: ' +
selection.anchorNode.nodeName);
return;
}
var focusRow = selection.focusNode;
while (focusRow && !('rowIndex' in focusRow)) {
focusRow = focusRow.parentNode;
}
if (!focusRow) {
console.error('Selection focus is not rooted in a row node: ' +
selection.focusNode.nodeName);
return;
}
if (anchorRow.rowIndex < focusRow.rowIndex) {
anchorFirst();
} else if (anchorRow.rowIndex > focusRow.rowIndex) {
focusFirst();
} else if (selection.focusNode == selection.anchorNode) {
if (selection.anchorOffset < selection.focusOffset) {
anchorFirst();
} else {
focusFirst();
}
} else {
// The selection starts and ends in the same row, but isn't contained all
// in a single node.
var firstNode = this.findFirstChild(
anchorRow, [selection.anchorNode, selection.focusNode]);
if (!firstNode)
throw new Error('Unexpected error syncing selection.');
if (firstNode == selection.anchorNode) {
anchorFirst();
} else {
focusFirst();
}
}
this.isMultiline = anchorRow.rowIndex != focusRow.rowIndex;
};
/**
* Turn a div into this hterm.ScrollPort.
*/
hterm.ScrollPort.prototype.decorate = function(div) {
this.div_ = div;
this.iframe_ = div.ownerDocument.createElement('iframe');
this.iframe_.style.cssText = (
'border: 0;' +
'height: 100%;' +
'position: absolute;' +
'width: 100%');
div.appendChild(this.iframe_);
this.iframe_.contentWindow.addEventListener('resize',
this.onResize_.bind(this));
var doc = this.document_ = this.iframe_.contentDocument;
doc.body.style.cssText = (
'margin: 0px;' +
'padding: 0px;' +
'height: 100%;' +
'width: 100%;' +
'overflow: hidden;' +
'-webkit-user-select: none;');
var style = doc.createElement('style');
style.textContent = 'x-row {}';
doc.head.appendChild(style);
this.xrowCssRule_ = doc.styleSheets[0].cssRules[0];
this.xrowCssRule_.style.display = 'block';
this.userCssLink_ = doc.createElement('link');
this.userCssLink_.setAttribute('rel', 'stylesheet');
// TODO(rginda): Sorry, this 'screen_' isn't the same thing as hterm.Screen
// from screen.js. I need to pick a better name for one of them to avoid
// the collision.
this.screen_ = doc.createElement('x-screen');
this.screen_.setAttribute('role', 'textbox');
this.screen_.setAttribute('tabindex', '-1');
this.screen_.style.cssText = (
'display: block;' +
'font-family: monospace;' +
'font-size: 15px;' +
'height: 100%;' +
'overflow-y: scroll; overflow-x: hidden;' +
'white-space: pre;' +
'width: 100%;' +
'outline: none !important');
doc.body.appendChild(this.screen_);
this.screen_.addEventListener('scroll', this.onScroll_.bind(this));
this.screen_.addEventListener('mousewheel', this.onScrollWheel_.bind(this));
this.screen_.addEventListener('copy', this.onCopy_.bind(this));
this.screen_.addEventListener('paste', this.onPaste_.bind(this));
this.screen_.addEventListener('mousedown', this.onMouseDown_.bind(this));
// We send focus to this element just before a paste happens, so we can
// capture the pasted text and forward it on to someone who cares.
this.pasteTarget_ = doc.createElement('textarea');
this.pasteTarget_.setAttribute('tabindex', '-1');
this.pasteTarget_.style.cssText = (
'position: absolute;' +
'top: -999px;');
doc.body.appendChild(this.pasteTarget_);
// This is the main container for the fixed rows.
this.rowNodes_ = doc.createElement('div');
this.rowNodes_.style.cssText = (
'display: block;' +
'position: fixed;' +
'overflow: hidden;' +
'-webkit-user-select: text;');
this.screen_.appendChild(this.rowNodes_);
// Two nodes to hold offscreen text during the copy event.
this.topSelectBag_ = doc.createElement('x-select-bag');
this.topSelectBag_.style.cssText = (
'display: block;' +
'overflow: hidden;' +
'white-space: pre;');
this.bottomSelectBag_ = this.topSelectBag_.cloneNode();
// Nodes above the top fold and below the bottom fold are hidden. They are
// only used to hold rows that are part of the selection but are currently
// scrolled off the top or bottom of the visible range.
this.topFold_ = doc.createElement('x-fold');
this.topFold_.style.cssText = 'display: block;';
this.rowNodes_.appendChild(this.topFold_);
this.bottomFold_ = this.topFold_.cloneNode();
this.rowNodes_.appendChild(this.bottomFold_);
// This hidden div accounts for the vertical space that would be consumed by
// all the rows in the buffer if they were visible. It's what causes the
// scrollbar to appear on the 'x-screen', and it moves within the screen when
// the scrollbar is moved.
//
// It is set 'visibility: hidden' to keep the browser from trying to include
// it in the selection when a user 'drag selects' upwards (drag the mouse to
// select and scroll at the same time). Without this, the selection gets
// out of whack.
this.scrollArea_ = doc.createElement('div');
this.scrollArea_.style.cssText = 'visibility: hidden';
this.screen_.appendChild(this.scrollArea_);
this.setSelectionEnabled(true);
this.resize();
};
/**
* Enable or disable mouse based text selection in the scrollport.
*/
hterm.ScrollPort.prototype.setSelectionEnabled = function(state) {
this.selectionEnabled_ = state;
};
/**
* Select the font-family and font-smoothing for this scrollport.
*
* @param {string} fontFamily Value of the CSS 'font-family' to use for this
* scrollport. Should be a monospace font.
* @param {string} opt_smoothing Optional value for '-webkit-font-smoothing'.
* Defaults to an empty string if not specified.
*/
hterm.ScrollPort.prototype.setFontFamily = function(fontFamily, opt_smoothing) {
this.screen_.style.fontFamily = fontFamily;
if (opt_smoothing) {
this.screen_.style.webkitFontSmoothing = opt_smoothing;
} else {
this.screen_.style.webkitFontSmoothing = '';
}
this.syncCharacterSize();
};
hterm.ScrollPort.prototype.getFontFamily = function() {
return this.screen_.style.fontFamily;
};
/**
* Set a custom stylesheet to include in the scrollport.
*
* Defaults to null, meaning no custom css is loaded. Set it back to null or
* the empty string to remove a previously applied custom css.
*/
hterm.ScrollPort.prototype.setUserCss = function(url) {
if (url) {
this.userCssLink_.setAttribute('href', url);
if (!this.userCssLink_.parentNode)
this.document_.head.appendChild(this.userCssLink_);
} else if (this.userCssLink_.parentNode) {
this.document_.head.removeChild(this.userCssLink_);
}
};
hterm.ScrollPort.prototype.focus = function() {
this.iframe_.focus();
this.screen_.focus();
};
hterm.ScrollPort.prototype.getForegroundColor = function() {
return this.screen_.style.color;
};
hterm.ScrollPort.prototype.setForegroundColor = function(color) {
this.screen_.style.color = color;
};
hterm.ScrollPort.prototype.getBackgroundColor = function() {
return this.screen_.style.backgroundColor;
};
hterm.ScrollPort.prototype.setBackgroundColor = function(color) {
this.screen_.style.backgroundColor = color;
};
hterm.ScrollPort.prototype.setBackgroundImage = function(image) {
this.screen_.style.backgroundImage = image;
};
hterm.ScrollPort.prototype.setBackgroundSize = function(size) {
this.screen_.style.backgroundSize = size;
};
hterm.ScrollPort.prototype.setBackgroundPosition = function(position) {
this.screen_.style.backgroundPosition = position;
};
/**
* Get the usable size of the scrollport screen.
*
* The width will not include the scrollbar width.
*/
hterm.ScrollPort.prototype.getScreenSize = function() {
var size = hterm.getClientSize(this.screen_);
return {
height: size.height,
width: size.width - this.currentScrollbarWidthPx
};
};
/**
* Get the usable width of the scrollport screen.
*
* This the widget width minus scrollbar width.
*/
hterm.ScrollPort.prototype.getScreenWidth = function() {
return this.getScreenSize().width ;
};
/**
* Get the usable height of the scrollport screen.
*/
hterm.ScrollPort.prototype.getScreenHeight = function() {
return this.getScreenSize().height;
};
/**
* Return the document that holds the visible rows of this hterm.ScrollPort.
*/
hterm.ScrollPort.prototype.getDocument = function() {
return this.document_;
};
/**
* Returns the x-screen element that holds the rows of this hterm.ScrollPort.
*/
hterm.ScrollPort.prototype.getScreenNode = function() {
return this.screen_;
};
/**
* Clear out any cached rowNodes.
*/
hterm.ScrollPort.prototype.resetCache = function() {
this.currentRowNodeCache_ = null;
this.previousRowNodeCache_ = {};
};
/**
* Change the current rowProvider.
*
* This will clear the row cache and cause a redraw.
*
* @param {Object} rowProvider An object capable of providing the rows
* in this hterm.ScrollPort.
*/
hterm.ScrollPort.prototype.setRowProvider = function(rowProvider) {
this.resetCache();
this.rowProvider_ = rowProvider;
this.scheduleRedraw();
};
/**
* Inform the ScrollPort that the root DOM nodes for some or all of the visible
* rows are no longer valid.
*
* Specifically, this should be called if this.rowProvider_.getRowNode() now
* returns an entirely different node than it did before. It does not
* need to be called if the content of a row node is the only thing that
* changed.
*
* This skips some of the overhead of a full redraw, but should not be used
* in cases where the scrollport has been scrolled, or when the row count has
* changed.
*/
hterm.ScrollPort.prototype.invalidate = function() {
var node = this.topFold_.nextSibling;
while (node != this.bottomFold_) {
var nextSibling = node.nextSibling;
node.parentElement.removeChild(node);
node = nextSibling;
}
this.previousRowNodeCache_ = null;
var topRowIndex = this.getTopRowIndex();
var bottomRowIndex = this.getBottomRowIndex(topRowIndex);
this.drawVisibleRows_(topRowIndex, bottomRowIndex);
};
hterm.ScrollPort.prototype.scheduleInvalidate = function() {
if (this.timeouts_.invalidate)
return;
var self = this;
this.timeouts_.invalidate = setTimeout(function () {
delete self.timeouts_.invalidate;
self.invalidate();
}, 0);
};
/**
* Set the font size of the ScrollPort.
*/
hterm.ScrollPort.prototype.setFontSize = function(px) {
this.screen_.style.fontSize = px + 'px';
this.syncCharacterSize();
};
/**
* Return the current font size of the ScrollPort.
*/
hterm.ScrollPort.prototype.getFontSize = function() {
return parseInt(this.screen_.style.fontSize);
};
/**
* Measure the size of a single character in pixels.
*
* @param {string} opt_weight The font weight to measure, or 'normal' if
* omitted.
* @return {hterm.Size} A new hterm.Size object.
*/
hterm.ScrollPort.prototype.measureCharacterSize = function(opt_weight) {
if (!this.ruler_) {
this.ruler_ = this.document_.createElement('div');
this.ruler_.style.cssText = (
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'visibility: hidden;' +
'height: auto !important;' +
'width: auto !important;');
this.ruler_.textContent = ('XXXXXXXXXXXXXXXXXXXX' +
'XXXXXXXXXXXXXXXXXXXX' +
'XXXXXXXXXXXXXXXXXXXX' +
'XXXXXXXXXXXXXXXXXXXX' +
'XXXXXXXXXXXXXXXXXXXX');
this.rulerBaseline_ = this.document_.createElement('span');
// We want to collapse it on the baseline
this.rulerBaseline_.style.fontSize = '0px';
this.rulerBaseline_.textContent = 'X';
}
this.ruler_.style.fontWeight = opt_weight || '';
this.rowNodes_.appendChild(this.ruler_);
var rulerSize = hterm.getClientSize(this.ruler_);
// In some fonts, underscores actually show up below the reported height.
// We add one to the height here to compensate, and have to add a bottom
// border to text with a background color over in text_attributes.js.
var size = new hterm.Size(rulerSize.width / this.ruler_.textContent.length,
rulerSize.height + 1);
this.ruler_.appendChild(this.rulerBaseline_);
size.baseline = this.rulerBaseline_.offsetTop;
this.ruler_.removeChild(this.rulerBaseline_);
this.rowNodes_.removeChild(this.ruler_);
if ('width' in this.document_) {
size.zoomFactor = this.document_.width / this.document_.body.clientWidth;
} else {
// Current versions of Chrome have removed document.width/height. We can
// no longer depend on it to determine the zoom factor.
// TODO(rginda): Remove this code once document.width/height are a distant
// memory.
size.zoomFactor = 1;
}
return size;
};
/**
* Synchronize the character size.
*
* This will re-measure the current character size and adjust the height
* of an x-row to match.
*/
hterm.ScrollPort.prototype.syncCharacterSize = function() {
this.characterSize = this.measureCharacterSize();
var lineHeight = this.characterSize.height + 'px';
this.xrowCssRule_.style.height = lineHeight;
this.topSelectBag_.style.height = lineHeight;
this.bottomSelectBag_.style.height = lineHeight;
this.resize();
if (this.DEBUG_) {
// When we're debugging we add padding to the body so that the offscreen
// elements are visible.
this.document_.body.style.paddingTop =
this.document_.body.style.paddingBottom =
3 * this.characterSize.height + 'px';
}
};
/**
* Reset dimensions and visible row count to account for a change in the
* dimensions of the 'x-screen'.
*/
hterm.ScrollPort.prototype.resize = function() {
this.currentScrollbarWidthPx = hterm.getClientWidth(this.screen_) -
this.screen_.clientWidth;
this.syncScrollHeight();
this.syncRowNodesDimensions_();
var self = this;
this.publish(
'resize', { scrollPort: this },
function() {
self.scrollRowToBottom(self.rowProvider_.getRowCount());
self.scheduleRedraw();
});
};
/**
* Set the position and size of the row nodes element.
*/
hterm.ScrollPort.prototype.syncRowNodesDimensions_ = function() {
var screenSize = this.getScreenSize();
this.lastScreenWidth_ = screenSize.width;
this.lastScreenHeight_ = screenSize.height;
// We don't want to show a partial row because it would be distracting
// in a terminal, so we floor any fractional row count.
this.visibleRowCount = Math.floor(
screenSize.height / this.characterSize.height);
// Then compute the height of our integral number of rows.
var visibleRowsHeight = this.visibleRowCount * this.characterSize.height;
// Then the difference between the screen height and total row height needs to
// be made up for as top margin. We need to record this value so it
// can be used later to determine the topRowIndex.
this.visibleRowTopMargin = 0;
this.visibleRowBottomMargin = screenSize.height - visibleRowsHeight;
this.topFold_.style.marginBottom = this.visibleRowTopMargin + 'px';
var topFoldOffset = 0;
var node = this.topFold_.previousSibling;
while (node) {
topFoldOffset += hterm.getClientHeight(node);
node = node.previousSibling;
}
// Set the dimensions of the visible rows container.
this.rowNodes_.style.width = screenSize.width + 'px';
this.rowNodes_.style.height = visibleRowsHeight + topFoldOffset + 'px';
this.rowNodes_.style.left = this.screen_.offsetLeft + 'px';
this.rowNodes_.style.top = this.screen_.offsetTop - topFoldOffset + 'px';
};
hterm.ScrollPort.prototype.syncScrollHeight = function() {
// Resize the scroll area to appear as though it contains every row.
this.lastRowCount_ = this.rowProvider_.getRowCount();
this.scrollArea_.style.height = (this.characterSize.height *
this.lastRowCount_ +
this.visibleRowTopMargin +
this.visibleRowBottomMargin +
'px');
};
/**
* Schedule a redraw to happen asynchronously.
*
* If this method is called multiple times before the redraw has a chance to
* run only one redraw occurs.
*/
hterm.ScrollPort.prototype.scheduleRedraw = function() {
if (this.timeouts_.redraw)
return;
var self = this;
this.timeouts_.redraw = setTimeout(function () {
delete self.timeouts_.redraw;
self.redraw_();
}, 0);
};
/**
* Redraw the current hterm.ScrollPort based on the current scrollbar position.
*
* When redrawing, we are careful to make sure that the rows that start or end
* the current selection are not touched in any way. Doing so would disturb
* the selection, and cleaning up after that would cause flashes at best and
* incorrect selection at worst. Instead, we modify the DOM around these nodes.
* We even stash the selection start/end outside of the visible area if
* they are not supposed to be visible in the hterm.ScrollPort.
*/
hterm.ScrollPort.prototype.redraw_ = function() {
this.resetSelectBags_();
this.selection.sync();
this.syncScrollHeight();
this.currentRowNodeCache_ = {};
var topRowIndex = this.getTopRowIndex();
var bottomRowIndex = this.getBottomRowIndex(topRowIndex);
this.drawTopFold_(topRowIndex);
this.drawBottomFold_(bottomRowIndex);
this.drawVisibleRows_(topRowIndex, bottomRowIndex);
this.syncRowNodesDimensions_();
this.previousRowNodeCache_ = this.currentRowNodeCache_;
this.currentRowNodeCache_ = null;
this.isScrolledEnd = (
this.getTopRowIndex() + this.visibleRowCount >= this.lastRowCount_);
};
/**
* Ensure that the nodes above the top fold are as they should be.
*
* If the selection start and/or end nodes are above the visible range
* of this hterm.ScrollPort then the dom will be adjusted so that they appear
* before the top fold (the first x-fold element, aka this.topFold).
*
* If not, the top fold will be the first element.
*
* It is critical that this method does not move the selection nodes. Doing
* so would clear the current selection. Instead, the rest of the DOM is
* adjusted around them.
*/
hterm.ScrollPort.prototype.drawTopFold_ = function(topRowIndex) {
if (!this.selection.startRow ||
this.selection.startRow.rowIndex >= topRowIndex) {
// Selection is entirely below the top fold, just make sure the fold is
// the first child.
if (this.rowNodes_.firstChild != this.topFold_)
this.rowNodes_.insertBefore(this.topFold_, this.rowNodes_.firstChild);
return;
}
if (!this.selection.isMultiline ||
this.selection.endRow.rowIndex >= topRowIndex) {
// Only the startRow is above the fold.
if (this.selection.startRow.nextSibling != this.topFold_)
this.rowNodes_.insertBefore(this.topFold_,
this.selection.startRow.nextSibling);
} else {
// Both rows are above the fold.
if (this.selection.endRow.nextSibling != this.topFold_) {
this.rowNodes_.insertBefore(this.topFold_,
this.selection.endRow.nextSibling);
}
// Trim any intermediate lines.
while (this.selection.startRow.nextSibling !=
this.selection.endRow) {
this.rowNodes_.removeChild(this.selection.startRow.nextSibling);
}
}
while(this.rowNodes_.firstChild != this.selection.startRow) {
this.rowNodes_.removeChild(this.rowNodes_.firstChild);
}
};
/**
* Ensure that the nodes below the bottom fold are as they should be.
*
* If the selection start and/or end nodes are below the visible range
* of this hterm.ScrollPort then the dom will be adjusted so that they appear
* after the bottom fold (the second x-fold element, aka this.bottomFold).
*
* If not, the bottom fold will be the last element.
*
* It is critical that this method does not move the selection nodes. Doing
* so would clear the current selection. Instead, the rest of the DOM is
* adjusted around them.
*/
hterm.ScrollPort.prototype.drawBottomFold_ = function(bottomRowIndex) {
if (!this.selection.endRow ||
this.selection.endRow.rowIndex <= bottomRowIndex) {
// Selection is entirely above the bottom fold, just make sure the fold is
// the last child.
if (this.rowNodes_.lastChild != this.bottomFold_)
this.rowNodes_.appendChild(this.bottomFold_);
return;
}
if (!this.selection.isMultiline ||
this.selection.startRow.rowIndex <= bottomRowIndex) {
// Only the endRow is below the fold.
if (this.bottomFold_.nextSibling != this.selection.endRow)
this.rowNodes_.insertBefore(this.bottomFold_,
this.selection.endRow);
} else {
// Both rows are below the fold.
if (this.bottomFold_.nextSibling != this.selection.startRow) {
this.rowNodes_.insertBefore(this.bottomFold_,
this.selection.startRow);
}
// Trim any intermediate lines.
while (this.selection.startRow.nextSibling !=
this.selection.endRow) {
this.rowNodes_.removeChild(this.selection.startRow.nextSibling);
}
}
while(this.rowNodes_.lastChild != this.selection.endRow) {
this.rowNodes_.removeChild(this.rowNodes_.lastChild);
}
};
/**
* Ensure that the rows between the top and bottom folds are as they should be.
*
* This method assumes that drawTopFold_() and drawBottomFold_() have already
* run, and that they have left any visible selection row (selection start
* or selection end) between the folds.
*
* It recycles DOM nodes from the previous redraw where possible, but will ask
* the rowSource to make new nodes if necessary.
*
* It is critical that this method does not move the selection nodes. Doing
* so would clear the current selection. Instead, the rest of the DOM is
* adjusted around them.
*/
hterm.ScrollPort.prototype.drawVisibleRows_ = function(
topRowIndex, bottomRowIndex) {
var self = this;
// Keep removing nodes, starting with currentNode, until we encounter
// targetNode. Throws on failure.
function removeUntilNode(currentNode, targetNode) {
while (currentNode != targetNode) {
if (!currentNode)
throw 'Did not encounter target node';
if (currentNode == self.bottomFold_)
throw 'Encountered bottom fold before target node';
var deadNode = currentNode;
currentNode = currentNode.nextSibling;
deadNode.parentNode.removeChild(deadNode);
}
}
// Shorthand for things we're going to use a lot.
var selectionStartRow = this.selection.startRow;
var selectionEndRow = this.selection.endRow;
var bottomFold = this.bottomFold_;
// The node we're examining during the current iteration.
var node = this.topFold_.nextSibling;
var targetDrawCount = Math.min(this.visibleRowCount,
this.rowProvider_.getRowCount());
for (var drawCount = 0; drawCount < targetDrawCount; drawCount++) {
var rowIndex = topRowIndex + drawCount;
if (node == bottomFold) {
// We've hit the bottom fold, we need to insert a new row.
var newNode = this.fetchRowNode_(rowIndex);
if (!newNode) {
console.log("Couldn't fetch row index: " + rowIndex);
break;
}
this.rowNodes_.insertBefore(newNode, node);
continue;
}
if (node.rowIndex == rowIndex) {
// This node is in the right place, move along.
node = node.nextSibling;
continue;
}
if (selectionStartRow && selectionStartRow.rowIndex == rowIndex) {
// The selection start row is supposed to be here, remove nodes until
// we find it.
removeUntilNode(node, selectionStartRow);
node = selectionStartRow.nextSibling;
continue;
}
if (selectionEndRow && selectionEndRow.rowIndex == rowIndex) {
// The selection end row is supposed to be here, remove nodes until
// we find it.
removeUntilNode(node, selectionEndRow);
node = selectionEndRow.nextSibling;
continue;
}
if (node == selectionStartRow || node == selectionEndRow) {
// We encountered the start/end of the selection, but we don't want it
// yet. Insert a new row instead.
var newNode = this.fetchRowNode_(rowIndex);
if (!newNode) {
console.log("Couldn't fetch row index: " + rowIndex);
break;
}
this.rowNodes_.insertBefore(newNode, node);
continue;
}
// There is nothing special about this node, but it's in our way. Replace
// it with the node that should be here.
var newNode = this.fetchRowNode_(rowIndex);
if (!newNode) {
console.log("Couldn't fetch row index: " + rowIndex);
break;
}
if (node == newNode) {
node = node.nextSibling;
continue;
}
this.rowNodes_.insertBefore(newNode, node);
if (!newNode.nextSibling)
debugger;
this.rowNodes_.removeChild(node);
node = newNode.nextSibling;
}
if (node != this.bottomFold_)
removeUntilNode(node, bottomFold);
};
/**
* Empty out both select bags and remove them from the document.
*
* These nodes hold the text between the start and end of the selection
* when that text is otherwise off screen. They are filled out in the
* onCopy_ event.
*/
hterm.ScrollPort.prototype.resetSelectBags_ = function() {
if (this.topSelectBag_.parentNode) {
this.topSelectBag_.textContent = '';
this.topSelectBag_.parentNode.removeChild(this.topSelectBag_);
}
if (this.bottomSelectBag_.parentNode) {
this.bottomSelectBag_.textContent = '';
this.bottomSelectBag_.parentNode.removeChild(this.bottomSelectBag_);
}
};
/**
* Place a row node in the cache of visible nodes.
*
* This method may only be used during a redraw_.
*/
hterm.ScrollPort.prototype.cacheRowNode_ = function(rowNode) {
this.currentRowNodeCache_[rowNode.rowIndex] = rowNode;
};
/**
* Fetch the row node for the given index.
*
* This will return a node from the cache if possible, or will request one
* from the RowProvider if not.
*
* If a redraw_ is in progress the row will be added to the current cache.
*/
hterm.ScrollPort.prototype.fetchRowNode_ = function(rowIndex) {
var node;
if (this.previousRowNodeCache_ && rowIndex in this.previousRowNodeCache_) {
node = this.previousRowNodeCache_[rowIndex];
} else {
node = this.rowProvider_.getRowNode(rowIndex);
}
if (this.currentRowNodeCache_)
this.cacheRowNode_(node);
return node;
};
/**
* Select all rows in the viewport.
*/
hterm.ScrollPort.prototype.selectAll = function() {
var firstRow;
if (this.topFold_.nextSibling.rowIndex != 0) {
while (this.topFold_.previousSibling) {
this.rowNodes_.removeChild(this.topFold_.previousSibling);
}
firstRow = this.fetchRowNode_(0);
this.rowNodes_.insertBefore(firstRow, this.topFold_);
this.syncRowNodesDimensions_();
} else {
firstRow = this.topFold_.nextSibling;
}
var lastRowIndex = this.rowProvider_.getRowCount() - 1;
var lastRow;
if (this.bottomFold_.previousSibling.rowIndex != lastRowIndex) {
while (this.bottomFold_.nextSibling) {
this.rowNodes_.removeChild(this.bottomFold_.nextSibling);
}
lastRow = this.fetchRowNode_(lastRowIndex);
this.rowNodes_.appendChild(lastRow);
} else {
lastRow = this.bottomFold_.previousSibling.rowIndex;
}
var selection = this.document_.getSelection();
selection.collapse(firstRow, 0);
selection.extend(lastRow, lastRow.childNodes.length);
this.selection.sync();
};
/**
* Return the maximum scroll position in pixels.
*/
hterm.ScrollPort.prototype.getScrollMax_ = function(e) {
return (hterm.getClientHeight(this.scrollArea_) +
this.visibleRowTopMargin + this.visibleRowBottomMargin -
hterm.getClientHeight(this.screen_));
};
/**
* Scroll the given rowIndex to the top of the hterm.ScrollPort.
*
* @param {integer} rowIndex Index of the target row.
*/
hterm.ScrollPort.prototype.scrollRowToTop = function(rowIndex) {
this.syncScrollHeight();
this.isScrolledEnd = (
rowIndex + this.visibleRowCount >= this.lastRowCount_);
var scrollTop = rowIndex * this.characterSize.height +
this.visibleRowTopMargin;
var scrollMax = this.getScrollMax_();
if (scrollTop > scrollMax)
scrollTop = scrollMax;
if (this.screen_.scrollTop == scrollTop)
return;
this.screen_.scrollTop = scrollTop;
this.scheduleRedraw();
};
/**
* Scroll the given rowIndex to the bottom of the hterm.ScrollPort.
*
* @param {integer} rowIndex Index of the target row.
*/
hterm.ScrollPort.prototype.scrollRowToBottom = function(rowIndex) {
this.syncScrollHeight();
this.isScrolledEnd = (
rowIndex + this.visibleRowCount >= this.lastRowCount_);
var scrollTop = rowIndex * this.characterSize.height +
this.visibleRowTopMargin + this.visibleRowBottomMargin;
scrollTop -= this.visibleRowCount * this.characterSize.height;
if (scrollTop < 0)
scrollTop = 0;
if (this.screen_.scrollTop == scrollTop)
return;
this.screen_.scrollTop = scrollTop;
};
/**
* Return the row index of the first visible row.
*
* This is based on the scroll position. If a redraw_ is in progress this
* returns the row that *should* be at the top.
*/
hterm.ScrollPort.prototype.getTopRowIndex = function() {
return Math.floor(this.screen_.scrollTop / this.characterSize.height);
};
/**
* Return the row index of the last visible row.
*
* This is based on the scroll position. If a redraw_ is in progress this
* returns the row that *should* be at the bottom.
*/
hterm.ScrollPort.prototype.getBottomRowIndex = function(topRowIndex) {
return topRowIndex + this.visibleRowCount - 1;
};
/**
* Handler for scroll events.
*
* The onScroll event fires when scrollArea's scrollTop property changes. This
* may be due to the user manually move the scrollbar, or a programmatic change.
*/
hterm.ScrollPort.prototype.onScroll_ = function(e) {
var screenSize = this.getScreenSize();
if (screenSize.width != this.lastScreenWidth_ ||
screenSize.height != this.lastScreenHeight_) {
// This event may also fire during a resize (but before the resize event!).
// This happens when the browser moves the scrollbar as part of the resize.
// In these cases, we want to ignore the scroll event and let onResize
// handle things. If we don't, then we end up scrolling to the wrong
// position after a resize.
this.resize();
return;
}
this.redraw_();
this.publish('scroll', { scrollPort: this });
};
/**
* Clients can override this if they want to hear scrollwheel events.
*
* Clients may call event.preventDefault() if they want to keep the scrollport
* from also handling the events.
*/
hterm.ScrollPort.prototype.onScrollWheel = function(e) {};
/**
* Handler for scroll-wheel events.
*
* The onScrollWheel event fires when the user moves their scrollwheel over this
* hterm.ScrollPort. Because the frontmost element in the hterm.ScrollPort is
* a fixed position DIV, the scroll wheel does nothing by default. Instead, we
* have to handle it manually.
*/
hterm.ScrollPort.prototype.onScrollWheel_ = function(e) {
this.onScrollWheel(e);
if (e.defaultPrevented)
return;
var top = this.screen_.scrollTop - e.wheelDeltaY;
if (top < 0)
top = 0;
var scrollMax = this.getScrollMax_();
if (top > scrollMax)
top = scrollMax;
if (top != this.screen_.scrollTop) {
// Moving scrollTop causes a scroll event, which triggers the redraw.
this.screen_.scrollTop = top;
// Only preventDefault when we've actually scrolled. If there's nothing
// to scroll we want to pass the event through so Chrome can detect the
// overscroll.
e.preventDefault();
}
};
/**
* Handler for resize events.
*
* The browser will resize us such that the top row stays at the top, but we
* prefer to the bottom row to stay at the bottom.
*/
hterm.ScrollPort.prototype.onResize_ = function(e) {
// Re-measure, since onResize also happens for browser zoom changes.
this.syncCharacterSize();
this.resize();
};
/**
* Clients can override this if they want to hear copy events.
*
* Clients may call event.preventDefault() if they want to keep the scrollport
* from also handling the events.
*/
hterm.ScrollPort.prototype.onCopy = function(e) { };
/**
* Handler for copy-to-clipboard events.
*
* If some or all of the selected rows are off screen we may need to fill in
* the rows between selection start and selection end. This handler determines
* if we're missing some of the selected text, and if so populates one or both
* of the "select bags" with the missing text.
*/
hterm.ScrollPort.prototype.onCopy_ = function(e) {
this.onCopy(e);
if (e.defaultPrevented)
return;
this.resetSelectBags_();
this.selection.sync();
if (!this.selection.startRow ||
this.selection.endRow.rowIndex - this.selection.startRow.rowIndex < 2) {
return;
}
var topRowIndex = this.getTopRowIndex();
var bottomRowIndex = this.getBottomRowIndex(topRowIndex);
if (this.selection.startRow.rowIndex < topRowIndex) {
// Start of selection is above the top fold.
var endBackfillIndex;
if (this.selection.endRow.rowIndex < topRowIndex) {
// Entire selection is above the top fold.
endBackfillIndex = this.selection.endRow.rowIndex;
} else {
// Selection extends below the top fold.
endBackfillIndex = this.topFold_.nextSibling.rowIndex;
}
this.topSelectBag_.textContent = this.rowProvider_.getRowsText(
this.selection.startRow.rowIndex + 1, endBackfillIndex);
this.rowNodes_.insertBefore(this.topSelectBag_,
this.selection.startRow.nextSibling);
this.syncRowNodesDimensions_();
}
if (this.selection.endRow.rowIndex > bottomRowIndex) {
// Selection ends below the bottom fold.
var startBackfillIndex;
if (this.selection.startRow.rowIndex > bottomRowIndex) {
// Entire selection is below the bottom fold.
startBackfillIndex = this.selection.startRow.rowIndex + 1;
} else {
// Selection starts above the bottom fold.
startBackfillIndex = this.bottomFold_.previousSibling.rowIndex + 1;
}
this.bottomSelectBag_.textContent = this.rowProvider_.getRowsText(
startBackfillIndex, this.selection.endRow.rowIndex);
this.rowNodes_.insertBefore(this.bottomSelectBag_, this.selection.endRow);
}
};
/**
* Handle a paste event on the the ScrollPort's screen element.
*/
hterm.ScrollPort.prototype.onPaste_ = function(e) {
this.pasteTarget_.focus();
var self = this;
setTimeout(function() {
self.publish('paste', { text: self.pasteTarget_.value });
self.pasteTarget_.value = '';
self.screen_.focus();
}, 0);
};
/**
* Handle mouse down events on the ScrollPort's screen element.
*/
hterm.ScrollPort.prototype.onMouseDown_ = function(e) {
if (e.which == 1 && !this.selectionEnabled_) {
e.preventDefault();
}
};
/**
* Set the vertical scrollbar mode of the ScrollPort.
*/
hterm.ScrollPort.prototype.setScrollbarVisible = function(state) {
this.screen_.style.overflowY = state ? 'scroll' : 'hidden';
};