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": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
}
"parser": "babel-eslint"
}

View File

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

View File

@ -2,34 +2,6 @@ import React from "react"
import ReactDOM from "react-dom"
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 = {
contextTypes: {
location: React.PropTypes.object,

View File

@ -1,116 +1,151 @@
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 { VirtualScrollMixin } from "./virtualscroll.js"
import AutoScroll from "./helpers/AutoScroll";
import {calcVScroll} from "./helpers/VirtualScroll"
import {StoreView} from "../store/view.js"
import _ from "lodash"
var LogMessage = React.createClass({
render: function () {
var entry = this.props.entry;
var indicator;
switch (entry.level) {
case "web":
indicator = <i className="fa fa-fw fa-html5"></i>;
break;
case "debug":
indicator = <i className="fa fa-fw fa-bug"></i>;
break;
default:
indicator = <i className="fa fa-fw fa-info"></i>;
}
return (
<div>
{ indicator } {entry.message}
</div>
class EventLogContents extends React.Component {
static contextTypes = {
eventStore: React.PropTypes.object.isRequired,
};
static defaultProps = {
rowHeight: 18,
};
constructor(props, context) {
super(props, context);
this.view = new StoreView(
this.context.eventStore,
entry => this.props.filter[entry.level]
);
},
shouldComponentUpdate: function () {
return false; // log entries are immutable.
this.heights = {};
this.state = { entries: this.view.list, vScroll: calcVScroll() };
this.onChange = this.onChange.bind(this);
this.onViewportUpdate = this.onViewportUpdate.bind(this);
}
});
var EventLogContents = React.createClass({
contextTypes: {
eventStore: React.PropTypes.object.isRequired
},
mixins: [AutoScrollMixin, VirtualScrollMixin],
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);
componentDidMount() {
window.addEventListener("resize", this.onViewportUpdate);
this.view.addListener("add", this.onChange);
this.view.addListener("recalculate", this.onChange);
this.onViewportUpdate();
}
return {
view: view
};
},
componentWillUnmount: function () {
this.state.view.close();
},
filter: function (entry) {
return this.props.filter[entry.level];
},
onEventLogChange: function () {
this.forceUpdate();
},
componentWillReceiveProps: function (nextProps) {
componentWillUnmount() {
window.removeEventListener("resize", this.onViewportUpdate);
this.view.removeListener("add", this.onChange);
this.view.removeListener("recalculate", this.onChange);
this.view.close();
}
componentDidUpdate() {
this.onViewportUpdate();
}
componentWillReceiveProps(nextProps) {
if (nextProps.filter !== this.props.filter) {
this.state.view.recalculate(entry =>
nextProps.filter[entry.level]
this.view.recalculate(
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({
toggle: function (e) {
e.preventDefault();
return this.props.toggleLevel(this.props.name);
},
render: function () {
var className = "label ";
if (this.props.active) {
className += "label-primary";
} else {
className += "label-default";
onViewportUpdate() {
const viewport = ReactDOM.findDOMNode(this);
const vScroll = calcVScroll({
itemCount: this.state.entries.length,
rowHeight: this.props.rowHeight,
viewportTop: viewport.scrollTop,
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 (
<a
href="#"
className={className}
onClick={this.toggle}>
{this.props.name}
</a>
<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";
} else {
className += "label-default";
}
function onClick(event) {
event.preventDefault();
toggleLevel(name);
}
return (
<a
href="#"
className={className}
onClick={onClick}>
{name}
</a>
);
}
const AutoScrollEventLog = AutoScroll(EventLogContents);
var EventLog = React.createClass({
mixins: [Router],
getInitialState: function () {
getInitialState() {
return {
filter: {
"debug": false,
@ -119,18 +154,17 @@ var EventLog = React.createClass({
}
};
},
close: function () {
close() {
var d = {};
d[Query.SHOW_EVENTLOG] = undefined;
this.updateLocation(undefined, d);
},
toggleLevel: function (level) {
toggleLevel(level) {
var filter = _.extend({}, this.state.filter);
filter[level] = !filter[level];
this.setState({filter: filter});
},
render: function () {
render() {
return (
<div className="eventlog">
<div>
@ -143,10 +177,10 @@ var EventLog = React.createClass({
</div>
</div>
<EventLogContents filter={this.state.filter}/>
<AutoScrollEventLog filter={this.state.filter}/>
</div>
);
}
});
export default EventLog;
export default EventLog;

View File

@ -1,188 +1,216 @@
import React from "react";
import ReactDOM from 'react-dom';
import {StickyHeadMixin, AutoScrollMixin} from "./common.js";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {reverseString} from "../utils.js";
import _ from "lodash";
import { VirtualScrollMixin } from "./virtualscroll.js"
import shallowEqual from "shallowequal";
import AutoScroll from "./helpers/AutoScroll";
import {calcVScroll} from "./helpers/VirtualScroll";
import flowtable_columns from "./flowtable-columns.js";
var FlowRow = React.createClass({
render: function () {
var flow = this.props.flow;
var columns = this.props.columns.map(function (Column) {
return <Column key={Column.displayName} flow={flow}/>;
}.bind(this));
var className = "";
if (this.props.selected) {
className += " selected";
}
if (this.props.highlighted) {
className += " highlighted";
}
if (flow.intercepted) {
className += " intercepted";
}
if (flow.request) {
className += " has-request";
}
if (flow.response) {
className += " has-response";
}
FlowRow.propTypes = {
selectFlow: React.PropTypes.func.isRequired,
columns: React.PropTypes.array.isRequired,
flow: React.PropTypes.object.isRequired,
highlighted: React.PropTypes.bool,
selected: React.PropTypes.bool,
};
return (
<tr className={className} onClick={this.props.selectFlow.bind(null, flow)}>
{columns}
</tr>);
},
shouldComponentUpdate: function (nextProps) {
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)
//);
function FlowRow(props) {
const flow = props.flow;
const className = classNames({
"selected": props.selected,
"highlighted": props.highlighted,
"intercepted": flow.intercepted,
"has-request": flow.request,
"has-response": flow.response,
});
return (
<tr className={className} onClick={() => props.selectFlow(flow)}>
{props.columns.map(Column => (
<Column key={Column.displayName} flow={flow}/>
))}
</tr>
);
}
class FlowTableHead extends React.Component {
static propTypes = {
setSortKeyFun: React.PropTypes.func.isRequired,
columns: React.PropTypes.array.isRequired,
};
constructor(props, context) {
super(props, context);
this.state = { sortColumn: undefined, sortDesc: false };
}
});
var FlowTableHead = React.createClass({
getInitialState: function(){
return {
sortColumn: undefined,
sortDesc: false
};
},
onClick: function(Column){
var sortDesc = this.state.sortDesc;
var hasSort = Column.sortKeyFun;
if(Column === this.state.sortColumn){
onClick(Column) {
const hasSort = Column.sortKeyFun;
let sortDesc = this.state.sortDesc;
if (Column === this.state.sortColumn) {
sortDesc = !sortDesc;
this.setState({
sortDesc: sortDesc
});
this.setState({ sortDesc });
} else {
this.setState({
sortColumn: hasSort && Column,
sortDesc: false
})
this.setState({ sortColumn: hasSort && Column, sortDesc: false });
}
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;
let sortKeyFun = Column.sortKeyFun;
if (sortDesc) {
sortKeyFun = hasSort && function() {
const k = Column.sortKeyFun.apply(this, arguments);
if (_.isString(k)) {
return reverseString("" + 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>;
}
});
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>
);
}
}
var ROW_HEIGHT = 32;
class FlowTable extends React.Component {
var FlowTable = React.createClass({
mixins: [StickyHeadMixin, AutoScrollMixin, VirtualScrollMixin],
contextTypes: {
view: React.PropTypes.object.isRequired
},
getInitialState: function () {
return {
columns: flowtable_columns
};
},
componentWillMount: function () {
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("update", this.onChange);
this.context.view.addListener("remove", 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("update", this.onChange);
this.context.view.removeListener("remove", 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}
ref={flow.id}
flow={flow}
columns={this.state.columns}
selected={selected}
highlighted={highlighted}
selectFlow={this.props.selectFlow}
/>;
},
render: function () {
var flows = this.context.view.list;
var rows = this.renderRows(flows);
componentDidUpdate() {
this.onViewportUpdate();
}
onViewportUpdate() {
const viewport = ReactDOM.findDOMNode(this);
const viewportTop = viewport.scrollTop;
const vScroll = calcVScroll({
viewportTop,
viewportHeight: viewport.offsetHeight,
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 (
<div className="flow-table" onScroll={this.onScrollFlowTable}>
<div className="flow-table" onScroll={this.onViewportUpdate}>
<table>
<FlowTableHead ref="head"
columns={this.state.columns}
setSortKeyFun={this.props.setSortKeyFun}/>
<tbody ref="body">
{ this.getPlaceholderTop(flows.length) }
{rows}
{ this.getPlaceholderBottom(flows.length) }
<thead ref="head" style={{ transform }}>
<FlowTableHead
columns={flowtable_columns}
setSortKeyFun={this.props.setSortKeyFun}
/>
</thead>
<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>
</table>
</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;
}
},
};