blob: 37b8c8dabda83f51c8da0e3dacff6618a854fcc4 [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('lib.f', '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;
// The scroll wheel pixel delta multiplier to increase/decrease
// the scroll speed of mouse wheel events. See: https://goo.gl/sXelnq
this.scrollWheelMultiplier_ = 1;
// The last touch events we saw to support touch based scrolling. Indexed
// by touch identifier since we can have more than one touch active.
this.lastTouch_ = {};
/**
* True if the last scroll caused the scrollport to show the final row.
*/
this.isScrolledEnd = true;
/**
* A guess at the current scrollbar width, fixed in resize().
*/
this.currentScrollbarWidthPx = 16;
/**
* Whether the ctrl-v key on the screen should paste.
*/
this.ctrlVPaste = false;
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 (!selection) {
return;
}
// Usually collapsed selections wouldn't be interesting, however screen
// readers will set a collapsed selection as they navigate through the DOM.
// It is important to preserve these nodes in the DOM as scrolling happens
// so that screen reader navigation isn't cleared.
const accessibilityEnabled = this.scrollPort_.accessibilityReader_ &&
this.scrollPort_.accessibilityReader_.accessibilityEnabled;
if (this.isCollapsed && !accessibilityEnabled) {
return;
}
var anchorRow = selection.anchorNode;
while (anchorRow && anchorRow.nodeName != 'X-ROW') {
anchorRow = anchorRow.parentNode;
}
if (!anchorRow) {
// Don't set a selection if it's not a row node that's selected.
return;
}
var focusRow = selection.focusNode;
while (focusRow && focusRow.nodeName != 'X-ROW') {
focusRow = focusRow.parentNode;
}
if (!focusRow) {
// Don't set a selection if it's not a row node that's selected.
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%');
// Set the iframe src to # in FF. Otherwise when the frame's
// load event fires in FF it clears out the content of the iframe.
if ('mozInnerScreenX' in window) // detect a FF only property
this.iframe_.src = '#';
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;' +
'cursor: var(--hterm-mouse-cursor-style);' +
'-webkit-user-select: none;' +
'-moz-user-select: none;');
const metaCharset = doc.createElement('meta');
metaCharset.setAttribute('charset', 'utf-8');
doc.head.appendChild(metaCharset);
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 =
'calc(var(--hterm-charsize-height) * 3)';
}
var style = doc.createElement('style');
// Hide rows that are above or below the x-fold elements. This is necessary to
// ensure that these rows aren't visible to a screen reader. First hide all
// rows that are children of the <x-screen>. Then display the nodes that are
// after the top fold. Then rehide nodes that are after the bottom fold.
style.textContent = `
x-row {
display: block;
height: var(--hterm-charsize-height);
line-height: var(--hterm-charsize-height);
}
x-screen x-row {
visibility: hidden;
}
#hterm\\:top-fold-for-row-selection ~ x-row {
visibility: visible;
}
#hterm\\:bottom-fold-for-row-selection ~ x-row {
visibility: hidden;
}`;
doc.head.appendChild(style);
this.userCssLink_ = doc.createElement('link');
this.userCssLink_.setAttribute('rel', 'stylesheet');
this.userCssText_ = doc.createElement('style');
doc.head.appendChild(this.userCssText_);
// 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.
// We make this field editable even though we don't actually allow anything
// to be edited here so that Chrome will do the right thing with virtual
// keyboards and IMEs. But make sure we turn off all the input helper logic
// that doesn't make sense here, and might inadvertently mung or save input.
// Some of these attributes are standard while others are browser specific,
// but should be safely ignored by other browsers.
this.screen_ = doc.createElement('x-screen');
this.screen_.setAttribute('contenteditable', 'true');
this.screen_.setAttribute('spellcheck', 'false');
this.screen_.setAttribute('autocomplete', 'off');
this.screen_.setAttribute('autocorrect', 'off');
this.screen_.setAttribute('autocapitalize', 'none');
// In some ways the terminal behaves like a text box but not in all ways. It
// is not editable in the same ways a text box is editable and the content we
// want to be read out by a screen reader does not always align with the edits
// (selection changes) that happen in the terminal window. Use the log role so
// that the screen reader doesn't treat it like a text box and announce all
// selection changes. The announcements that we want spoken are generated
// by a separate live region, which gives more control over what will be
// spoken.
this.screen_.setAttribute('role', 'log');
this.screen_.setAttribute('aria-live', 'off');
this.screen_.setAttribute('aria-roledescription', 'Terminal');
// Set aria-readonly to indicate to the screen reader that the text on the
// screen is not modifiable by the html cursor. It may be modifiable by
// sending input to the application running in the terminal, but this is
// orthogonal to the DOM's notion of modifiable.
this.screen_.setAttribute('aria-readonly', 'true');
this.screen_.setAttribute('tabindex', '-1');
this.screen_.style.cssText = (
'caret-color: transparent;' +
'display: block;' +
'font-family: monospace;' +
'font-size: 15px;' +
'font-variant-ligatures: none;' +
'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('wheel', this.onScrollWheel_.bind(this));
this.screen_.addEventListener('touchstart', this.onTouch_.bind(this));
this.screen_.addEventListener('touchmove', this.onTouch_.bind(this));
this.screen_.addEventListener('touchend', this.onTouch_.bind(this));
this.screen_.addEventListener('touchcancel', this.onTouch_.bind(this));
this.screen_.addEventListener('copy', this.onCopy_.bind(this));
this.screen_.addEventListener('paste', this.onPaste_.bind(this));
this.screen_.addEventListener('drop', this.onDragAndDrop_.bind(this));
doc.body.addEventListener('keydown', this.onBodyKeyDown_.bind(this));
// Add buttons to make accessible scrolling through terminal history work
// well. These are positioned off-screen until they are selected, at which
// point they are moved on-screen.
const scrollButtonHeight = 30;
const scrollButtonBorder = 1;
const scrollButtonTotalHeight = scrollButtonHeight + 2 * scrollButtonBorder;
const scrollButtonStyle = `right: 0px;
position:fixed;
z-index: 1;
text-align: center;
cursor: pointer;
height: ${scrollButtonHeight}px;
width: 110px;
line-height: ${scrollButtonHeight}px;
border-width: ${scrollButtonBorder}px;
border-style: solid;
font-weight: bold;`;
// Note: we use a <div> rather than a <button> because we don't want it to be
// focusable. If it's focusable this interferes with the contenteditable
// focus.
this.scrollUpButton_ = this.document_.createElement('div');
this.scrollUpButton_.id = 'hterm:a11y:page-up';
this.scrollUpButton_.innerText = hterm.msg('BUTTON_PAGE_UP', [], 'Page up');
this.scrollUpButton_.setAttribute('role', 'button');
this.scrollUpButton_.style.cssText = scrollButtonStyle;
this.scrollUpButton_.style.top = -scrollButtonTotalHeight + 'px';
this.scrollUpButton_.addEventListener('click', this.scrollPageUp.bind(this));
this.scrollDownButton_ = this.document_.createElement('div');
this.scrollDownButton_.id = 'hterm:a11y:page-down';
this.scrollDownButton_.innerText =
hterm.msg('BUTTON_PAGE_DOWN', [], 'Page down');
this.scrollDownButton_.setAttribute('role', 'button');
this.scrollDownButton_.style.cssText = scrollButtonStyle;
this.scrollDownButton_.style.bottom = -scrollButtonTotalHeight + 'px';
this.scrollDownButton_.addEventListener(
'click', this.scrollPageDown.bind(this));
// We only allow the scroll buttons to display after a delay, otherwise the
// page up button can flash onto the screen during the intial change in focus.
// This seems to be because it is the first element inside the <x-screen>
// element, which will get focussed on page load.
this.allowScrollButtonsToDisplay_ = false;
setTimeout(() => { this.allowScrollButtonsToDisplay_ = true; }, 500);
this.document_.addEventListener('selectionchange', () => {
this.selection.sync();
if (!this.allowScrollButtonsToDisplay_)
return;
const accessibilityEnabled = this.accessibilityReader_ &&
this.accessibilityReader_.accessibilityEnabled;
const selection = this.document_.getSelection();
let selectedElement;
if (selection.anchorNode && selection.anchorNode.parentElement) {
selectedElement = selection.anchorNode.parentElement;
}
if (accessibilityEnabled && selectedElement == this.scrollUpButton_) {
this.scrollUpButton_.style.top = '0px';
} else {
this.scrollUpButton_.style.top = -scrollButtonTotalHeight + 'px';
}
if (accessibilityEnabled && selectedElement == this.scrollDownButton_) {
this.scrollDownButton_.style.bottom = '0px';
} else {
this.scrollDownButton_.style.bottom = -scrollButtonTotalHeight + 'px';
}
});
this.screen_.appendChild(this.scrollUpButton_);
// This is the main container for the fixed rows.
this.rowNodes_ = doc.createElement('div');
this.rowNodes_.id = 'hterm:row-nodes';
this.rowNodes_.style.cssText = (
'display: block;' +
'position: fixed;' +
'overflow: hidden;' +
'-webkit-user-select: text;' +
'-moz-user-select: text;');
this.screen_.appendChild(this.rowNodes_);
this.screen_.appendChild(this.scrollDownButton_);
// 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;' +
'height: var(--hterm-charsize-height);' +
'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_.id = 'hterm:top-fold-for-row-selection';
this.topFold_.style.cssText = 'display: block;';
this.rowNodes_.appendChild(this.topFold_);
this.bottomFold_ = this.topFold_.cloneNode();
this.bottomFold_.id = 'hterm:bottom-fold-for-row-selection';
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_.id = 'hterm:scrollarea';
this.scrollArea_.style.cssText = 'visibility: hidden';
this.screen_.appendChild(this.scrollArea_);
// This svg element is used to detect when the browser is zoomed. It must be
// placed in the outermost document for currentScale to be correct.
// TODO(rginda): This means that hterm nested in an iframe will not correctly
// detect browser zoom level. We should come up with a better solution.
// Note: This must be http:// else Chrome cannot create the element correctly.
var xmlns = 'http://www.w3.org/2000/svg';
this.svg_ = this.div_.ownerDocument.createElementNS(xmlns, 'svg');
this.svg_.id = 'hterm:zoom-detector';
this.svg_.setAttribute('xmlns', xmlns);
this.svg_.setAttribute('version', '1.1');
this.svg_.style.cssText = (
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'visibility: hidden');
// 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_.id = 'hterm:ctrl-v-paste-target';
this.pasteTarget_.setAttribute('tabindex', '-1');
this.pasteTarget_.setAttribute('aria-hidden', 'true');
this.pasteTarget_.style.cssText = (
'position: absolute;' +
'height: 1px;' +
'width: 1px;' +
'left: 0px; ' +
'bottom: 0px;' +
'opacity: 0');
this.pasteTarget_.contentEditable = true;
this.screen_.appendChild(this.pasteTarget_);
this.pasteTarget_.addEventListener(
'textInput', this.handlePasteTargetTextInput_.bind(this));
this.resize();
};
/**
* Set the AccessibilityReader object to use to announce page scroll updates.
*
* @param {hterm.AccessibilityReader} accessibilityReader for announcing page
* scroll updates.
*/
hterm.ScrollPort.prototype.setAccessibilityReader =
function(accessibilityReader) {
this.accessibilityReader_ = accessibilityReader;
};
/**
* Scroll the terminal one page up (minus one line) relative to the current
* position.
*/
hterm.ScrollPort.prototype.scrollPageUp = function() {
if (this.getTopRowIndex() == 0) {
return;
}
const i = this.getTopRowIndex();
this.scrollRowToTop(i - this.visibleRowCount + 1);
this.assertiveAnnounce_();
};
/**
* Scroll the terminal one page down (minus one line) relative to the current
* position.
*/
hterm.ScrollPort.prototype.scrollPageDown = function() {
if (this.isScrolledEnd) {
return;
}
const i = this.getTopRowIndex();
this.scrollRowToTop(i + this.visibleRowCount - 1);
this.assertiveAnnounce_();
};
/**
* 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.setUserCssUrl = 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.setUserCssText = function(text) {
this.userCssText_.textContent = text;
};
hterm.ScrollPort.prototype.focus = function() {
this.iframe_.focus();
this.screen_.focus();
this.publish('focus');
};
hterm.ScrollPort.prototype.getForegroundColor = function() {
return this.screen_.style.color;
};
hterm.ScrollPort.prototype.setForegroundColor = function(color) {
this.screen_.style.color = color;
this.scrollUpButton_.style.backgroundColor = color;
this.scrollDownButton_.style.backgroundColor = color;
};
hterm.ScrollPort.prototype.getBackgroundColor = function() {
return this.screen_.style.backgroundColor;
};
hterm.ScrollPort.prototype.setBackgroundColor = function(color) {
this.screen_.style.backgroundColor = color;
this.scrollUpButton_.style.color = color;
this.scrollDownButton_.style.color = 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;
};
hterm.ScrollPort.prototype.setCtrlVPaste = function(ctrlVPaste) {
this.ctrlVPaste = ctrlVPaste;
};
/**
* 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) {
// Number of lines used to average the height of a single character.
var numberOfLines = 100;
// Number of chars per line used to average the width of a single character.
var lineLength = 100;
if (!this.ruler_) {
this.ruler_ = this.document_.createElement('div');
this.ruler_.id = 'hterm:ruler-character-size';
this.ruler_.style.cssText = (
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'visibility: hidden;' +
'height: auto !important;' +
'width: auto !important;');
// We need to put the text in a span to make the size calculation
// work properly in Firefox
this.rulerSpan_ = this.document_.createElement('span');
this.rulerSpan_.id = 'hterm:ruler-span-workaround';
this.rulerSpan_.innerHTML =
('X'.repeat(lineLength) + '\r').repeat(numberOfLines);
this.ruler_.appendChild(this.rulerSpan_);
this.rulerBaseline_ = this.document_.createElement('span');
this.rulerSpan_.id = 'hterm:ruler-baseline';
// We want to collapse it on the baseline
this.rulerBaseline_.style.fontSize = '0px';
this.rulerBaseline_.textContent = 'X';
}
this.rulerSpan_.style.fontWeight = opt_weight || '';
this.rowNodes_.appendChild(this.ruler_);
var rulerSize = hterm.getClientSize(this.rulerSpan_);
var size = new hterm.Size(rulerSize.width / lineLength,
rulerSize.height / numberOfLines);
this.ruler_.appendChild(this.rulerBaseline_);
size.baseline = this.rulerBaseline_.offsetTop;
this.ruler_.removeChild(this.rulerBaseline_);
this.rowNodes_.removeChild(this.ruler_);
this.div_.ownerDocument.body.appendChild(this.svg_);
size.zoomFactor = this.svg_.currentScale;
this.div_.ownerDocument.body.removeChild(this.svg_);
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();
this.resize();
};
/**
* 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();
});
};
/**
* Announce text content on the current screen for the screen reader.
*/
hterm.ScrollPort.prototype.assertiveAnnounce_ = function() {
if (!this.accessibilityReader_) {
return;
}
const topRow = this.getTopRowIndex();
const bottomRow = this.getBottomRowIndex(topRow);
let percentScrolled = 100 * topRow /
Math.max(1, this.rowProvider_.getRowCount() - this.visibleRowCount);
percentScrolled = Math.min(100, Math.round(percentScrolled));
let currentScreenContent = hterm.msg('ANNOUNCE_CURRENT_SCREEN_HEADER',
[percentScrolled],
'$1% scrolled,');
currentScreenContent += '\n';
for (let i = topRow; i <= bottomRow; ++i) {
const node = this.fetchRowNode_(i);
currentScreenContent += node.textContent + '\n';
}
this.accessibilityReader_.assertiveAnnounce(currentScreenContent);
};
/**
* 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 = lib.f.smartFloorDivide(
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);
};
/**
* Update the state of scroll up/down buttons.
*
* If the viewport is at the top or bottom row of output, these buttons will
* be made transparent and clicking them shouldn't scroll any further.
*/
hterm.ScrollPort.prototype.updateScrollButtonState_ = function() {
const setButton = (button, disabled) => {
button.setAttribute('aria-disabled', disabled ? 'true' : 'false');
button.style.opacity = disabled ? 0.5 : 1;
};
setButton(this.scrollUpButton_, this.getTopRowIndex() == 0);
setButton(this.scrollDownButton_, this.isScrolledEnd);
};
/**
* 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_);
this.updateScrollButtonState_();
};
/**
* 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.round(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;
// Figure out how far this event wants us to scroll.
var delta = this.scrollWheelDelta(e);
var top = this.screen_.scrollTop - delta;
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();
}
};
/**
* Calculate how far a wheel event should scroll.
*
* @param {WheelEvent} e The mouse wheel event to process.
* @return {number} How far (in pixels) to scroll.
*/
hterm.ScrollPort.prototype.scrollWheelDelta = function(e) {
var delta;
switch (e.deltaMode) {
case WheelEvent.DOM_DELTA_PIXEL:
delta = e.deltaY * this.scrollWheelMultiplier_;
break;
case WheelEvent.DOM_DELTA_LINE:
delta = e.deltaY * this.characterSize.height;
break;
case WheelEvent.DOM_DELTA_PAGE:
delta = e.deltaY * this.characterSize.height * this.screen_.getHeight();
break;
}
// The sign is inverted from what we would expect.
return delta * -1;
};
/**
* Clients can override this if they want to hear touch events.
*
* Clients may call event.preventDefault() if they want to keep the scrollport
* from also handling the events.
*/
hterm.ScrollPort.prototype.onTouch = function(e) {};
/**
* Handler for touch events.
*/
hterm.ScrollPort.prototype.onTouch_ = function(e) {
this.onTouch(e);
if (e.defaultPrevented)
return;
// Extract the fields from the Touch event that we need. If we saved the
// event directly, it has references to other objects (like x-row) that
// might stick around for a long time. This way we only have small objects
// in our lastTouch_ state.
var scrubTouch = function(t) {
return {
id: t.identifier,
y: t.clientY,
x: t.clientX,
};
};
var i, touch;
switch (e.type) {
case 'touchstart':
// Save the current set of touches.
for (i = 0; i < e.changedTouches.length; ++i) {
touch = scrubTouch(e.changedTouches[i]);
this.lastTouch_[touch.id] = touch;
}
break;
case 'touchcancel':
case 'touchend':
// Throw away existing touches that we're finished with.
for (i = 0; i < e.changedTouches.length; ++i)
delete this.lastTouch_[e.changedTouches[i].identifier];
break;
case 'touchmove':
// Walk all of the touches in this one event and merge all of their
// changes into one delta. This lets multiple fingers scroll faster.
var delta = 0;
for (i = 0; i < e.changedTouches.length; ++i) {
touch = scrubTouch(e.changedTouches[i]);
delta += (this.lastTouch_[touch.id].y - touch.y);
this.lastTouch_[touch.id] = touch;
}
// Invert to match the touchscreen scrolling direction of browser windows.
delta *= -1;
var top = this.screen_.scrollTop - delta;
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;
}
break;
}
// To disable gestures or anything else interfering with our scrolling.
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();
};
/**
* 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.isCollapsed ||
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);
}
};
/**
* Focuses on the paste target on a ctrl-v keydown event, as in
* FF a content editable element must be focused before the paste event.
*/
hterm.ScrollPort.prototype.onBodyKeyDown_ = function(e) {
if (!this.ctrlVPaste)
return;
var key = String.fromCharCode(e.which);
var lowerKey = key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && lowerKey == "v")
this.pasteTarget_.focus();
};
/**
* Handle a paste event on the the ScrollPort's screen element.
*
* TODO: Handle ClipboardData.files transfers. https://crbug.com/433581.
*/
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.focus();
}, 0);
};
/**
* Handles a textInput event on the paste target. Stops this from
* propagating as we want this to be handled in the onPaste_ method.
*/
hterm.ScrollPort.prototype.handlePasteTargetTextInput_ = function(e) {
e.stopPropagation();
};
/**
* Handle a drop event on the the ScrollPort's screen element.
*
* By default we try to copy in the structured format (HTML/whatever).
* The shift key can select plain text though.
*
* TODO: Handle DataTransfer.files transfers. https://crbug.com/433581.
*
* @param {DragEvent} e The drag event that fired us.
*/
hterm.ScrollPort.prototype.onDragAndDrop_ = function(e) {
e.preventDefault();
let data;
let format;
// If the shift key active, try to find a "rich" text source (but not plain
// text). e.g. text/html is OK.
if (e.shiftKey) {
e.dataTransfer.types.forEach((t) => {
if (!format && t != 'text/plain' && t.startsWith('text/'))
format = t;
});
// If we found a non-plain text source, try it out first.
if (format)
data = e.dataTransfer.getData(format);
}
// If we haven't loaded anything useful, fall back to plain text.
if (!data)
data = e.dataTransfer.getData('text/plain');
if (data)
this.publish('paste', {text: data});
};
/**
* Set the vertical scrollbar mode of the ScrollPort.
*/
hterm.ScrollPort.prototype.setScrollbarVisible = function(state) {
this.screen_.style.overflowY = state ? 'scroll' : 'hidden';
};
/**
* Set scroll wheel multiplier. This alters how much the screen scrolls on
* mouse wheel events.
*/
hterm.ScrollPort.prototype.setScrollWheelMoveMultipler = function(multiplier) {
this.scrollWheelMultiplier_ = multiplier;
};