From 1c0496e051d8b1af297138732475b1689ada5eb8 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 10 Mar 2016 21:40:07 +0800 Subject: [web] VirtualScroll and AutoScroll helper --- web/.babelrc | 3 +- web/.eslintrc.yml | 8 +- web/package.json | 6 +- web/src/js/components/common.js | 28 -- web/src/js/components/eventlog.js | 230 ++++++++++------- web/src/js/components/flowtable.js | 340 +++++++++++++------------ web/src/js/components/helpers/AutoScroll.js | 25 ++ web/src/js/components/helpers/VirtualScroll.js | 70 +++++ web/src/js/components/virtualscroll.js | 84 ------ 9 files changed, 419 insertions(+), 375 deletions(-) create mode 100644 web/src/js/components/helpers/AutoScroll.js create mode 100644 web/src/js/components/helpers/VirtualScroll.js delete mode 100644 web/src/js/components/virtualscroll.js (limited to 'web') diff --git a/web/.babelrc b/web/.babelrc index facd1809..5dd7708c 100644 --- a/web/.babelrc +++ b/web/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015", "react"] + "presets": ["es2015", "react"], + "plugins": ["transform-class-properties"] } \ No newline at end of file diff --git a/web/.eslintrc.yml b/web/.eslintrc.yml index 319fa67c..60028c97 100644 --- a/web/.eslintrc.yml +++ b/web/.eslintrc.yml @@ -1,9 +1,3 @@ { - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - } + "parser": "babel-eslint" } \ No newline at end of file diff --git a/web/package.json b/web/package.json index 63a664ae..e489a0b1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/js/components/common.js b/web/src/js/components/common.js index 23e0a434..ad97ab38 100644 --- a/web/src/js/components/common.js +++ b/web/src/js/components/common.js @@ -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, diff --git a/web/src/js/components/eventlog.js b/web/src/js/components/eventlog.js index 51f1578e..d1b23ace 100644 --- a/web/src/js/components/eventlog.js +++ b/web/src/js/components/eventlog.js @@ -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 = ; - break; - case "debug": - indicator = ; - break; - default: - indicator = ; - } - return ( -
- { indicator } {entry.message} -
+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 ; - }, - render: function () { - var entries = this.state.view.list; - var rows = this.renderRows(entries); - - return
-            { this.getPlaceholderTop(entries.length) }
-            {rows}
-            { this.getPlaceholderBottom(entries.length) }
-        
; } -}); -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 ( - - {this.props.name} - +
+                
+ {entries.map((entry, index) => ( +
+ + {entry.message} +
+ ))} +
+
); } -}); +} + +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 ( + + {name} + + ); +} + +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 (
@@ -143,10 +177,10 @@ var EventLog = React.createClass({
- + ); } }); -export default EventLog; \ No newline at end of file +export default EventLog; diff --git a/web/src/js/components/flowtable.js b/web/src/js/components/flowtable.js index 988d1895..f03b8ec0 100644 --- a/web/src/js/components/flowtable.js +++ b/web/src/js/components/flowtable.js @@ -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 ; - }.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 ( - - {columns} - ); - }, - 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 ( + props.selectFlow(flow)}> + {props.columns.map(Column => ( + + ))} + + ); +} + +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 + {this.props.columns.map(Column => ( + ; - }.bind(this)); - return - {columns} - ; + onClick={() => this.onClick(Column)} + className={sortColumn === Column && sortType} + /> + ))} + + ); + } +} + +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); } -}); - - -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 () { + + 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 ; - }, - 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 ( -
+
- - - { this.getPlaceholderTop(flows.length) } - {rows} - { this.getPlaceholderBottom(flows.length) } + + + + + + {flows.map(flow => ( + + ))} +
); } -}); +} -export default FlowTable; +export default AutoScroll(FlowTable); diff --git a/web/src/js/components/helpers/AutoScroll.js b/web/src/js/components/helpers/AutoScroll.js new file mode 100644 index 00000000..d37b9f37 --- /dev/null +++ b/web/src/js/components/helpers/AutoScroll.js @@ -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); diff --git a/web/src/js/components/helpers/VirtualScroll.js b/web/src/js/components/helpers/VirtualScroll.js new file mode 100644 index 00000000..5d4cf796 --- /dev/null +++ b/web/src/js/components/helpers/VirtualScroll.js @@ -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} [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 }; +} diff --git a/web/src/js/components/virtualscroll.js b/web/src/js/components/virtualscroll.js deleted file mode 100644 index f462fdcc..00000000 --- a/web/src/js/components/virtualscroll.js +++ /dev/null @@ -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 = ; - - if (this.state.start % 2 === 1) { - // fix even/odd rows - return [spacer, ]; - } 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 ; - }, - 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; - } - }, -}; -- cgit v1.2.3