Merge pull request #1004 from gzzhanghao/vscroll

[web] VirtualScroll and AutoScroll helper
This commit is contained in:
Maximilian Hils 2016-03-10 15:13:24 +01:00
commit 4a6edd92e6
11 changed files with 2132 additions and 1024 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
{ {
"presets": ["es2015", "react"] "presets": ["es2015", "react"],
"plugins": ["transform-class-properties"]
} }

View File

@ -1,9 +1,3 @@
{ {
"parserOptions": { "parser": "babel-eslint"
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
}
} }

View File

@ -17,16 +17,20 @@
}, },
"dependencies": { "dependencies": {
"bootstrap": "^3.3.6", "bootstrap": "^3.3.6",
"classnames": "^2.2.3",
"flux": "^2.1.1", "flux": "^2.1.1",
"jquery": "^2.2.1", "jquery": "^2.2.1",
"lodash": "^4.5.1", "lodash": "^4.5.1",
"react": "^0.14.7", "react": "^0.14.7",
"react-dom": "^0.14.7", "react-dom": "^0.14.7",
"react-router": "^2.0.0" "react-router": "^2.0.0",
"shallowequal": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"babel-core": "^6.5.2", "babel-core": "^6.5.2",
"babel-eslint": "^5.0.0",
"babel-jest": "^6.0.1", "babel-jest": "^6.0.1",
"babel-plugin-transform-class-properties": "^6.6.0",
"babel-preset-es2015": "^6.5.0", "babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0", "babel-preset-react": "^6.5.0",
"babelify": "^7.2.0", "babelify": "^7.2.0",

View File

@ -2,34 +2,6 @@ import React from "react"
import ReactDOM from "react-dom" import ReactDOM from "react-dom"
import _ from "lodash" import _ from "lodash"
// http://blog.vjeux.com/2013/javascript/scroll-position-with-react.html (also contains inverse example)
export var AutoScrollMixin = {
componentWillUpdate: function () {
var node = ReactDOM.findDOMNode(this);
this._shouldScrollBottom = (
node.scrollTop !== 0 &&
node.scrollTop + node.clientHeight === node.scrollHeight
);
},
componentDidUpdate: function () {
if (this._shouldScrollBottom) {
var node = ReactDOM.findDOMNode(this);
node.scrollTop = node.scrollHeight;
}
}
};
export var StickyHeadMixin = {
adjustHead: function () {
// Abusing CSS transforms to set the element
// referenced as head into some kind of position:sticky.
var head = ReactDOM.findDOMNode(this.refs.head);
head.style.transform = "translate(0," + ReactDOM.findDOMNode(this).scrollTop + "px)";
}
};
export var Router = { export var Router = {
contextTypes: { contextTypes: {
location: React.PropTypes.object, location: React.PropTypes.object,

View File

@ -1,116 +1,151 @@
import React from "react" import React from "react"
import {AutoScrollMixin, Router} from "./common.js" import ReactDOM from "react-dom"
import shallowEqual from "shallowequal"
import {Router} from "./common.js"
import {Query} from "../actions.js" import {Query} from "../actions.js"
import { VirtualScrollMixin } from "./virtualscroll.js" import AutoScroll from "./helpers/AutoScroll";
import {calcVScroll} from "./helpers/VirtualScroll"
import {StoreView} from "../store/view.js" import {StoreView} from "../store/view.js"
import _ from "lodash" import _ from "lodash"
var LogMessage = React.createClass({ class EventLogContents extends React.Component {
render: function () {
var entry = this.props.entry; static contextTypes = {
var indicator; eventStore: React.PropTypes.object.isRequired,
switch (entry.level) { };
case "web":
indicator = <i className="fa fa-fw fa-html5"></i>; static defaultProps = {
break; rowHeight: 18,
case "debug": };
indicator = <i className="fa fa-fw fa-bug"></i>;
break; constructor(props, context) {
default: super(props, context);
indicator = <i className="fa fa-fw fa-info"></i>;
} this.view = new StoreView(
return ( this.context.eventStore,
<div> entry => this.props.filter[entry.level]
{ indicator } {entry.message}
</div>
); );
},
shouldComponentUpdate: function () { this.heights = {};
return false; // log entries are immutable. this.state = { entries: this.view.list, vScroll: calcVScroll() };
this.onChange = this.onChange.bind(this);
this.onViewportUpdate = this.onViewportUpdate.bind(this);
} }
});
var EventLogContents = React.createClass({ componentDidMount() {
contextTypes: { window.addEventListener("resize", this.onViewportUpdate);
eventStore: React.PropTypes.object.isRequired this.view.addListener("add", this.onChange);
}, this.view.addListener("recalculate", this.onChange);
mixins: [AutoScrollMixin, VirtualScrollMixin], this.onViewportUpdate();
getInitialState: function () { }
var filterFn = function (entry) {
return this.props.filter[entry.level];
};
var view = new StoreView(this.context.eventStore, filterFn.bind(this));
view.addListener("add", this.onEventLogChange);
view.addListener("recalculate", this.onEventLogChange);
return { componentWillUnmount() {
view: view window.removeEventListener("resize", this.onViewportUpdate);
}; this.view.removeListener("add", this.onChange);
}, this.view.removeListener("recalculate", this.onChange);
componentWillUnmount: function () { this.view.close();
this.state.view.close(); }
},
filter: function (entry) { componentDidUpdate() {
return this.props.filter[entry.level]; this.onViewportUpdate();
}, }
onEventLogChange: function () {
this.forceUpdate(); componentWillReceiveProps(nextProps) {
},
componentWillReceiveProps: function (nextProps) {
if (nextProps.filter !== this.props.filter) { if (nextProps.filter !== this.props.filter) {
this.state.view.recalculate(entry => this.view.recalculate(
nextProps.filter[entry.level] entry => nextProps.filter[entry.level]
); );
} }
},
getDefaultProps: function () {
return {
rowHeight: 45,
rowHeightMin: 15,
placeholderTagName: "div"
};
},
renderRow: function (elem) {
return <LogMessage key={elem.id} entry={elem}/>;
},
render: function () {
var entries = this.state.view.list;
var rows = this.renderRows(entries);
return <pre onScroll={this.onScroll}>
{ this.getPlaceholderTop(entries.length) }
{rows}
{ this.getPlaceholderBottom(entries.length) }
</pre>;
} }
});
var ToggleFilter = React.createClass({ onViewportUpdate() {
toggle: function (e) { const viewport = ReactDOM.findDOMNode(this);
e.preventDefault();
return this.props.toggleLevel(this.props.name); const vScroll = calcVScroll({
}, itemCount: this.state.entries.length,
render: function () { rowHeight: this.props.rowHeight,
var className = "label "; viewportTop: viewport.scrollTop,
if (this.props.active) { viewportHeight: viewport.offsetHeight,
itemHeights: this.state.entries.map(entry => this.heights[entry.id]),
});
if (!shallowEqual(this.state.vScroll, vScroll)) {
this.setState({ vScroll });
}
}
onChange() {
this.setState({ entries: this.view.list });
}
setHeight(id, ref) {
if (ref && !this.heights[id]) {
const height = ReactDOM.findDOMNode(ref).offsetHeight;
if (this.heights[id] !== height) {
this.heights[id] = height;
this.onViewportUpdate();
}
}
}
getIcon(level) {
return { web: "html5", debug: "bug" }[level] || "info";
}
render() {
const vScroll = this.state.vScroll;
const entries = this.state.entries.slice(vScroll.start, vScroll.end);
return (
<pre onScroll={this.onViewportUpdate}>
<div style={{ height: vScroll.paddingTop }}></div>
{entries.map((entry, index) => (
<div key={entry.id} ref={this.setHeight.bind(this, entry.id)}>
<i className={`fa fa-fw fa-${this.getIcon(entry.level)}`}></i>
{entry.message}
</div>
))}
<div style={{ height: vScroll.paddingBottom }}></div>
</pre>
);
}
}
ToggleFilter.propTypes = {
name: React.PropTypes.string.isRequired,
toggleLevel: React.PropTypes.func.isRequired,
active: React.PropTypes.bool,
};
function ToggleFilter ({ name, active, toggleLevel }) {
let className = "label ";
if (active) {
className += "label-primary"; className += "label-primary";
} else { } else {
className += "label-default"; className += "label-default";
} }
function onClick(event) {
event.preventDefault();
toggleLevel(name);
}
return ( return (
<a <a
href="#" href="#"
className={className} className={className}
onClick={this.toggle}> onClick={onClick}>
{this.props.name} {name}
</a> </a>
); );
} }
});
const AutoScrollEventLog = AutoScroll(EventLogContents);
var EventLog = React.createClass({ var EventLog = React.createClass({
mixins: [Router], mixins: [Router],
getInitialState: function () { getInitialState() {
return { return {
filter: { filter: {
"debug": false, "debug": false,
@ -119,18 +154,17 @@ var EventLog = React.createClass({
} }
}; };
}, },
close: function () { close() {
var d = {}; var d = {};
d[Query.SHOW_EVENTLOG] = undefined; d[Query.SHOW_EVENTLOG] = undefined;
this.updateLocation(undefined, d); this.updateLocation(undefined, d);
}, },
toggleLevel: function (level) { toggleLevel(level) {
var filter = _.extend({}, this.state.filter); var filter = _.extend({}, this.state.filter);
filter[level] = !filter[level]; filter[level] = !filter[level];
this.setState({filter: filter}); this.setState({filter: filter});
}, },
render: function () { render() {
return ( return (
<div className="eventlog"> <div className="eventlog">
<div> <div>
@ -143,7 +177,7 @@ var EventLog = React.createClass({
</div> </div>
</div> </div>
<EventLogContents filter={this.state.filter}/> <AutoScrollEventLog filter={this.state.filter}/>
</div> </div>
); );
} }

View File

@ -1,188 +1,216 @@
import React from "react"; import React from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import {StickyHeadMixin, AutoScrollMixin} from "./common.js"; import classNames from "classnames";
import {reverseString} from "../utils.js"; import {reverseString} from "../utils.js";
import _ from "lodash"; import _ from "lodash";
import shallowEqual from "shallowequal";
import { VirtualScrollMixin } from "./virtualscroll.js" import AutoScroll from "./helpers/AutoScroll";
import {calcVScroll} from "./helpers/VirtualScroll";
import flowtable_columns from "./flowtable-columns.js"; import flowtable_columns from "./flowtable-columns.js";
var FlowRow = React.createClass({ FlowRow.propTypes = {
render: function () { selectFlow: React.PropTypes.func.isRequired,
var flow = this.props.flow; columns: React.PropTypes.array.isRequired,
var columns = this.props.columns.map(function (Column) { flow: React.PropTypes.object.isRequired,
return <Column key={Column.displayName} flow={flow}/>; highlighted: React.PropTypes.bool,
}.bind(this)); selected: React.PropTypes.bool,
var className = ""; };
if (this.props.selected) {
className += " selected"; function FlowRow(props) {
} const flow = props.flow;
if (this.props.highlighted) {
className += " highlighted"; const className = classNames({
} "selected": props.selected,
if (flow.intercepted) { "highlighted": props.highlighted,
className += " intercepted"; "intercepted": flow.intercepted,
} "has-request": flow.request,
if (flow.request) { "has-response": flow.response,
className += " has-request"; });
}
if (flow.response) {
className += " has-response";
}
return ( return (
<tr className={className} onClick={this.props.selectFlow.bind(null, flow)}> <tr className={className} onClick={() => props.selectFlow(flow)}>
{columns} {props.columns.map(Column => (
</tr>); <Column key={Column.displayName} flow={flow}/>
}, ))}
shouldComponentUpdate: function (nextProps) { </tr>
return true; );
// Further optimization could be done here }
// by calling forceUpdate on flow updates, selection changes and column changes.
//return (
//(this.props.columns.length !== nextProps.columns.length) ||
//(this.props.selected !== nextProps.selected)
//);
}
});
var FlowTableHead = React.createClass({ class FlowTableHead extends React.Component {
getInitialState: function(){
return { static propTypes = {
sortColumn: undefined, setSortKeyFun: React.PropTypes.func.isRequired,
sortDesc: false columns: React.PropTypes.array.isRequired,
}; };
},
onClick: function(Column){ constructor(props, context) {
var sortDesc = this.state.sortDesc; super(props, context);
var hasSort = Column.sortKeyFun; this.state = { sortColumn: undefined, sortDesc: false };
if(Column === this.state.sortColumn){ }
onClick(Column) {
const hasSort = Column.sortKeyFun;
let sortDesc = this.state.sortDesc;
if (Column === this.state.sortColumn) {
sortDesc = !sortDesc; sortDesc = !sortDesc;
this.setState({ this.setState({ sortDesc });
sortDesc: sortDesc
});
} else { } else {
this.setState({ this.setState({ sortColumn: hasSort && Column, sortDesc: false });
sortColumn: hasSort && Column, }
sortDesc: false
}) let sortKeyFun = Column.sortKeyFun;
if (sortDesc) {
sortKeyFun = hasSort && function() {
const k = Column.sortKeyFun.apply(this, arguments);
if (_.isString(k)) {
return reverseString("" + k);
} }
var sortKeyFun;
if(!sortDesc){
sortKeyFun = Column.sortKeyFun;
} else {
sortKeyFun = hasSort && function(){
var k = Column.sortKeyFun.apply(this, arguments);
if(_.isString(k)){
return reverseString(""+k);
} else {
return -k; return -k;
}
}
}
this.props.setSortKeyFun(sortKeyFun);
},
render: function () {
var columns = this.props.columns.map(function (Column) {
var onClick = this.onClick.bind(this, Column);
var className;
if(this.state.sortColumn === Column) {
if(this.state.sortDesc){
className = "sort-desc";
} else {
className = "sort-asc";
}
}
return <Column.Title
key={Column.displayName}
onClick={onClick}
className={className} />;
}.bind(this));
return <thead>
<tr>{columns}</tr>
</thead>;
}
});
var ROW_HEIGHT = 32;
var FlowTable = React.createClass({
mixins: [StickyHeadMixin, AutoScrollMixin, VirtualScrollMixin],
contextTypes: {
view: React.PropTypes.object.isRequired
},
getInitialState: function () {
return {
columns: flowtable_columns
}; };
}, }
componentWillMount: function () {
this.props.setSortKeyFun(sortKeyFun);
}
render() {
const sortColumn = this.state.sortColumn;
const sortType = this.state.sortDesc ? "sort-desc" : "sort-asc";
return (
<tr>
{this.props.columns.map(Column => (
<Column.Title
key={Column.displayName}
onClick={() => this.onClick(Column)}
className={sortColumn === Column && sortType}
/>
))}
</tr>
);
}
}
class FlowTable extends React.Component {
static contextTypes = {
view: React.PropTypes.object.isRequired,
};
static propTypes = {
rowHeight: React.PropTypes.number,
};
static defaultProps = {
rowHeight: 32,
};
constructor(props, context) {
super(props, context);
this.state = { flows: [], vScroll: calcVScroll() };
this.onChange = this.onChange.bind(this);
this.onViewportUpdate = this.onViewportUpdate.bind(this);
}
componentWillMount() {
window.addEventListener("resize", this.onViewportUpdate);
this.context.view.addListener("add", this.onChange); this.context.view.addListener("add", this.onChange);
this.context.view.addListener("update", this.onChange); this.context.view.addListener("update", this.onChange);
this.context.view.addListener("remove", this.onChange); this.context.view.addListener("remove", this.onChange);
this.context.view.addListener("recalculate", this.onChange); this.context.view.addListener("recalculate", this.onChange);
}, }
componentWillUnmount: function(){
componentWillUnmount() {
window.removeEventListener("resize", this.onViewportUpdate);
this.context.view.removeListener("add", this.onChange); this.context.view.removeListener("add", this.onChange);
this.context.view.removeListener("update", this.onChange); this.context.view.removeListener("update", this.onChange);
this.context.view.removeListener("remove", this.onChange); this.context.view.removeListener("remove", this.onChange);
this.context.view.removeListener("recalculate", this.onChange); this.context.view.removeListener("recalculate", this.onChange);
}, }
getDefaultProps: function () {
return {
rowHeight: ROW_HEIGHT
};
},
onScrollFlowTable: function () {
this.adjustHead();
this.onScroll();
},
onChange: function () {
this.forceUpdate();
},
scrollIntoView: function (flow) {
this.scrollRowIntoView(
this.context.view.indexOf(flow),
ReactDOM.findDOMNode(this.refs.body).offsetTop
);
},
renderRow: function (flow) {
var selected = (flow === this.props.selected);
var highlighted =
(
this.context.view._highlight &&
this.context.view._highlight[flow.id]
);
return <FlowRow key={flow.id} componentDidUpdate() {
ref={flow.id} this.onViewportUpdate();
flow={flow} }
columns={this.state.columns}
selected={selected} onViewportUpdate() {
highlighted={highlighted} const viewport = ReactDOM.findDOMNode(this);
selectFlow={this.props.selectFlow} const viewportTop = viewport.scrollTop;
/>;
}, const vScroll = calcVScroll({
render: function () { viewportTop,
var flows = this.context.view.list; viewportHeight: viewport.offsetHeight,
var rows = this.renderRows(flows); itemCount: this.state.flows.length,
rowHeight: this.props.rowHeight,
});
if (!shallowEqual(this.state.vScroll, vScroll) ||
this.state.viewportTop !== viewportTop) {
this.setState({ vScroll, viewportTop });
}
}
onChange() {
this.setState({ flows: this.context.view.list });
}
scrollIntoView(flow) {
const viewport = ReactDOM.findDOMNode(this);
const index = this.context.view.indexOf(flow);
const rowHeight = this.props.rowHeight;
const head = ReactDOM.findDOMNode(this.refs.head);
const headHeight = head ? head.offsetHeight : 0;
const rowTop = (index * rowHeight) + headHeight;
const rowBottom = rowTop + rowHeight;
const viewportTop = viewport.scrollTop;
const viewportHeight = viewport.offsetHeight;
// Account for pinned thead
if (rowTop - headHeight < viewportTop) {
viewport.scrollTop = rowTop - headHeight;
} else if (rowBottom > viewportTop + viewportHeight) {
viewport.scrollTop = rowBottom - viewportHeight;
}
}
render() {
const vScroll = this.state.vScroll;
const highlight = this.context.view._highlight;
const flows = this.state.flows.slice(vScroll.start, vScroll.end);
const transform = `translate(0,${this.state.viewportTop}px)`;
return ( return (
<div className="flow-table" onScroll={this.onScrollFlowTable}> <div className="flow-table" onScroll={this.onViewportUpdate}>
<table> <table>
<FlowTableHead ref="head" <thead ref="head" style={{ transform }}>
columns={this.state.columns} <FlowTableHead
setSortKeyFun={this.props.setSortKeyFun}/> columns={flowtable_columns}
<tbody ref="body"> setSortKeyFun={this.props.setSortKeyFun}
{ this.getPlaceholderTop(flows.length) } />
{rows} </thead>
{ this.getPlaceholderBottom(flows.length) } <tbody>
<tr style={{ height: vScroll.paddingTop }}></tr>
{flows.map(flow => (
<FlowRow
key={flow.id}
flow={flow}
columns={flowtable_columns}
selected={flow === this.props.selected}
highlighted={highlight && highlight[flow.id]}
selectFlow={this.props.selectFlow}
/>
))}
<tr style={{ height: vScroll.paddingBottom }}></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
); );
} }
}); }
export default FlowTable; export default AutoScroll(FlowTable);

View File

@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom";
const symShouldStick = Symbol("shouldStick");
const isAtBottom = v => v.scrollTop + v.clientHeight === v.scrollHeight;
export default Component => Object.assign(class AutoScrollWrapper extends Component {
static displayName = Component.name;
componentWillUpdate() {
const viewport = ReactDOM.findDOMNode(this);
this[symShouldStick] = viewport.scrollTop && isAtBottom(viewport);
super.componentWillUpdate && super.componentWillUpdate();
}
componentDidUpdate() {
const viewport = ReactDOM.findDOMNode(this);
if (this[symShouldStick] && !isAtBottom(viewport)) {
viewport.scrollTop = viewport.scrollHeight;
}
super.componentDidUpdate && super.componentDidUpdate();
}
}, Component);

View File

@ -0,0 +1,70 @@
/**
* Calculate virtual scroll stuffs
*
* @param {?Object} opts Options for calculation
*
* @returns {Object} result
*
* __opts__ should have following properties:
* - {number} itemCount
* - {number} rowHeight
* - {number} viewportTop
* - {number} viewportHeight
* - {Array<?number>} [itemHeights]
*
* __result__ have following properties:
* - {number} start
* - {number} end
* - {number} paddingTop
* - {number} paddingBottom
*/
export function calcVScroll(opts) {
if (!opts) {
return { start: 0, end: 0, paddingTop: 0, paddingBottom: 0 };
}
const { itemCount, rowHeight, viewportTop, viewportHeight, itemHeights } = opts;
const viewportBottom = viewportTop + viewportHeight;
let start = 0;
let end = 0;
let paddingTop = 0;
let paddingBottom = 0;
if (itemHeights) {
for (let i = 0, pos = 0; i < itemCount; i++) {
const height = itemHeights[i] || rowHeight;
if (pos <= viewportTop && i % 2 === 0) {
paddingTop = pos;
start = i;
}
if (pos <= viewportBottom) {
end = i + 1;
} else {
paddingBottom += height;
}
pos += height;
}
} else {
// Make sure that we start at an even row so that CSS `:nth-child(even)` is preserved
start = Math.max(0, Math.floor(viewportTop / rowHeight) - 1) & ~1;
end = Math.min(
itemCount,
start + Math.ceil(viewportHeight / rowHeight) + 1
);
// When a large trunk of elements is removed from the button, start may be far off the viewport.
// To make this issue less severe, limit the top placeholder to the total number of rows.
paddingTop = Math.min(start, itemCount) * rowHeight;
paddingBottom = Math.max(0, itemCount - end) * rowHeight;
}
return { start, end, paddingTop, paddingBottom };
}

View File

@ -1,84 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
export var VirtualScrollMixin = {
getInitialState: function () {
return {
start: 0,
stop: 0
};
},
componentWillMount: function () {
if (!this.props.rowHeight) {
console.warn("VirtualScrollMixin: No rowHeight specified", this);
}
},
getPlaceholderTop: function (total) {
var Tag = this.props.placeholderTagName || "tr";
// When a large trunk of elements is removed from the button, start may be far off the viewport.
// To make this issue less severe, limit the top placeholder to the total number of rows.
var style = {
height: Math.min(this.state.start, total) * this.props.rowHeight
};
var spacer = <Tag key="placeholder-top" style={style}></Tag>;
if (this.state.start % 2 === 1) {
// fix even/odd rows
return [spacer, <Tag key="placeholder-top-2"></Tag>];
} else {
return spacer;
}
},
getPlaceholderBottom: function (total) {
var Tag = this.props.placeholderTagName || "tr";
var style = {
height: Math.max(0, total - this.state.stop) * this.props.rowHeight
};
return <Tag key="placeholder-bottom" style={style}></Tag>;
},
componentDidMount: function () {
this.onScroll();
window.addEventListener('resize', this.onScroll);
},
componentWillUnmount: function(){
window.removeEventListener('resize', this.onScroll);
},
onScroll: function () {
var viewport = ReactDOM.findDOMNode(this);
var top = viewport.scrollTop;
var height = viewport.offsetHeight;
var start = Math.floor(top / this.props.rowHeight);
var stop = start + Math.ceil(height / (this.props.rowHeightMin || this.props.rowHeight));
this.setState({
start: start,
stop: stop
});
},
renderRows: function (elems) {
var rows = [];
var max = Math.min(elems.length, this.state.stop);
for (var i = this.state.start; i < max; i++) {
var elem = elems[i];
rows.push(this.renderRow(elem));
}
return rows;
},
scrollRowIntoView: function (index, head_height) {
var row_top = (index * this.props.rowHeight) + head_height;
var row_bottom = row_top + this.props.rowHeight;
var viewport = ReactDOM.findDOMNode(this);
var viewport_top = viewport.scrollTop;
var viewport_bottom = viewport_top + viewport.offsetHeight;
// Account for pinned thead
if (row_top - head_height < viewport_top) {
viewport.scrollTop = row_top - head_height;
} else if (row_bottom > viewport_bottom) {
viewport.scrollTop = row_bottom - viewport.offsetHeight;
}
},
};