diff --git a/benchmark/index.js b/benchmark/index.js new file mode 100644 index 0000000..f009eab --- /dev/null +++ b/benchmark/index.js @@ -0,0 +1,54 @@ +const Benchmark = require('benchmark'); +require('./jsdom'); +const createElement = require('react').createElement; +const render = require('react-test-renderer').create; +const Component = require('../lib').default; + +const state = { + actionsById: {}, + computedStates: [], + currentStateIndex: -1, + nextActionId: 0, + skippedActionIds: [], + stagedActionIds: [], + monitorState: { + selectedActionId: null, + startActionId: null, + inspectedActionPath: [], + inspectedStatePath: [], + tabName: 'Diff' + } +}; + +function addNewAction() { + const nextId = state.nextActionId; + state.stagedActionIds = state.stagedActionIds.concat(nextId); + state.actionsById = Object.assign(state.actionsById, + { [nextId]: { action: { type: 'ACTION' }, timestamp: 1 } } + ); + state.computedStates = state.computedStates.concat({ state: {} }); + state.currentStateIndex++; + state.nextActionId++; +} + +function removeFirstAction() { + delete state.actionsById[state.stagedActionIds[0]]; + state.stagedActionIds = state.stagedActionIds.slice(1); +} + +const instance = render(createElement(Component, state)); + +const suite = new Benchmark.Suite; +suite.add('Insert', function() { + addNewAction(); + instance.update(createElement(Component, state)); +}) +.add('Update', function() { + removeFirstAction() + addNewAction(); + instance.update(createElement(Component, state)); +}) +.on('cycle', function(event) { + console.log(String(event.target)); +}) +.run({ 'async': true }); diff --git a/benchmark/jsdom.js b/benchmark/jsdom.js new file mode 100644 index 0000000..962b093 --- /dev/null +++ b/benchmark/jsdom.js @@ -0,0 +1,13 @@ +const jsdom = require('jsdom').jsdom; + +global.document = jsdom(''); +global.window = document.defaultView; +global.navigator = { userAgent: 'node.js' }; + +const exposedProperties = ['document', 'window', 'navigator']; +Object.keys(window).forEach((property) => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property); + global[property] = window[property]; + } +}); diff --git a/package.json b/package.json index 382a482..7a74270 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "babel-preset-react": "^6.3.13", "babel-preset-stage-0": "^6.3.13", "base16": "^1.0.0", + "benchmark": "^2.1.3", "chokidar": "^1.6.1", "clean-webpack-plugin": "^0.1.8", "eslint": "^3.9.1", @@ -43,6 +44,7 @@ "export-files-webpack-plugin": "0.0.1", "html-webpack-plugin": "^2.8.1", "imports-loader": "^0.6.5", + "jsdom": "^9.11.0", "json-loader": "^0.5.4", "nyan-progress-webpack-plugin": "^1.1.4", "pre-commit": "^1.1.3", @@ -55,6 +57,7 @@ "react-redux": "^4.4.0", "react-router": "^3.0.0", "react-router-redux": "^4.0.2", + "react-test-renderer": "^15.4.2", "react-transform-hmr": "^1.0.2", "redux": "^3.3.1", "redux-devtools": "^3.1.0", @@ -81,6 +84,7 @@ "jss-vendor-prefixer": "^3.0.1", "lodash.debounce": "^4.0.3", "react-base16-styling": "^0.4.1", + "react-dragula": "^1.1.17", "react-json-tree": "^0.10.0", "react-pure-render": "^1.0.2", "redux-devtools-themes": "^1.0.0" diff --git a/src/ActionList.jsx b/src/ActionList.jsx index 9f1321a..70550ca 100644 --- a/src/ActionList.jsx +++ b/src/ActionList.jsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import ReactDOM from 'react-dom'; +import dragula from 'react-dragula'; import ActionListRow from './ActionListRow'; import ActionListHeader from './ActionListHeader'; import shouldPureComponentUpdate from 'react-pure-render/function'; @@ -19,6 +20,31 @@ export default class ActionList extends Component { componentDidMount() { this.scrollToBottom(true); + if (!this.props.draggableActions) return; + const container = ReactDOM.findDOMNode(this.refs.rows); + this.drake = dragula([container], { + copy: false, + copySortSource: false, + mirrorContainer: container, + accepts: (el, target, source, sibling) => ( + !sibling || parseInt(sibling.getAttribute('data-id')) + ), + moves: (el, source, handle) => ( + parseInt(el.getAttribute('data-id')) && + !/\bselectorButton\b/.test(handle.className) + ), + }).on('drop', (el, target, source, sibling) => { + let beforeActionId = Infinity; + if (sibling && sibling.className.indexOf('gu-mirror') === -1) { + beforeActionId = parseInt(sibling.getAttribute('data-id')); + } + const actionId = parseInt(el.getAttribute('data-id')); + this.props.onReorderAction(actionId, beforeActionId) + }); + } + + componentWillUnmount() { + if (this.drake) this.drake.destroy(); } componentDidUpdate(prevProps) { @@ -29,6 +55,8 @@ export default class ActionList extends Component { scrollToBottom(force) { const el = ReactDOM.findDOMNode(this.refs.rows); + if (!el) return; + const scrollHeight = el.scrollHeight; if (force || Math.abs(scrollHeight - (el.scrollTop + el.offsetHeight)) < 50) { el.scrollTop = scrollHeight; @@ -57,13 +85,16 @@ export default class ActionList extends Component { {filteredActionIds.map(actionId => = startActionId && actionId <= selectedActionId || actionId === selectedActionId } - isInFuture={actionId > currentActionId} + isInFuture={ + actionIds.indexOf(actionId) > actionIds.indexOf(currentActionId) + } onSelect={(e) => onSelect(e, actionId)} timestamps={getTimestamps(actions, actionIds, actionId)} action={actions[actionId].action} diff --git a/src/ActionListRow.jsx b/src/ActionListRow.jsx index 0385d72..46f622a 100644 --- a/src/ActionListRow.jsx +++ b/src/ActionListRow.jsx @@ -27,7 +27,7 @@ export default class ActionListRow extends Component { shouldComponentUpdate = shouldPureComponentUpdate render() { - const { styling, isSelected, action, isInitAction, onSelect, + const { styling, isSelected, action, actionId, isInitAction, onSelect, timestamps, isSkipped, isInFuture } = this.props; const { hover } = this.state; const timeDelta = timestamps.current - timestamps.previous; @@ -41,6 +41,8 @@ export default class ActionListRow extends Component { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseEnter} + data-id={actionId} {...styling([ 'actionListItem', isSelected && 'actionListItemSelected', @@ -91,22 +93,22 @@ export default class ActionListRow extends Component { handleMouseEnter = e => { if (this.hover) return; + this.handleMouseLeave.cancel(); this.handleMouseEnterDebounced(e.buttons); } handleMouseEnterDebounced = debounce((buttons) => { if (buttons) return; this.setState({ hover: true }); - }, 300) + }, 150) - handleMouseLeave = () => { + handleMouseLeave = debounce(() => { this.handleMouseEnterDebounced.cancel(); if (this.state.hover) this.setState({ hover: false }); - } + }, 100) handleMouseDown = e => { if (e.target.className.indexOf('selectorButton') === 0) return; - if (this.handleMouseEnterDebounced) this.handleMouseEnterDebounced.cancel(); - if (this.state.hover) this.setState({ hover: false }); + this.handleMouseLeave(); } } diff --git a/src/DevtoolsInspector.js b/src/DevtoolsInspector.js index 71abed4..2e31154 100644 --- a/src/DevtoolsInspector.js +++ b/src/DevtoolsInspector.js @@ -9,7 +9,7 @@ import { getBase16Theme } from 'react-base16-styling'; import { reducer, updateMonitorState } from './redux'; import { ActionCreators } from 'redux-devtools'; -const { commit, sweep, toggleAction, jumpToAction, jumpToState } = ActionCreators; +const { commit, sweep, toggleAction, jumpToAction, jumpToState, reorderAction } = ActionCreators; function getLastActionId(props) { return props.stagedActionIds[props.stagedActionIds.length - 1]; @@ -86,6 +86,7 @@ export default class DevtoolsInspector extends Component { initialScrollTop: PropTypes.number }), preserveScrollTop: PropTypes.bool, + draggableActions: PropTypes.bool, stagedActions: PropTypes.array, select: PropTypes.func.isRequired, theme: PropTypes.oneOfType([ @@ -100,6 +101,7 @@ export default class DevtoolsInspector extends Component { static defaultProps = { select: (state) => state, supportImmutable: false, + draggableActions: true, theme: 'inspector', invertTheme: true }; @@ -120,7 +122,9 @@ export default class DevtoolsInspector extends Component { } updateSizeMode() { - const isWideLayout = this.refs.inspector.offsetWidth > 500; + const node = this.refs.inspector; + if (!node) return; + const isWideLayout = node.offsetWidth > 500; if (isWideLayout !== this.state.isWideLayout) { this.setState({ isWideLayout }); @@ -136,7 +140,9 @@ export default class DevtoolsInspector extends Component { getCurrentActionId(nextProps, nextMonitorState) || monitorState.startActionId !== nextMonitorState.startActionId || monitorState.inspectedStatePath !== nextMonitorState.inspectedStatePath || - monitorState.inspectedActionPath !== nextMonitorState.inspectedActionPath + monitorState.inspectedActionPath !== nextMonitorState.inspectedActionPath || + this.props.computedStates !== nextProps.computedStates || + this.props.stagedActionIds !== nextProps.stagedActionIds ) { this.setState(createIntermediateState(nextProps, nextMonitorState)); } @@ -148,7 +154,7 @@ export default class DevtoolsInspector extends Component { } render() { - const { stagedActionIds: actionIds, actionsById: actions, computedStates, + const { stagedActionIds: actionIds, actionsById: actions, computedStates, draggableActions, tabs, invertTheme, skippedActionIds, currentStateIndex, monitorState } = this.props; const { selectedActionId, startActionId, searchValue, tabName } = monitorState; const inspectedPathType = tabName === 'Action' ? 'inspectedActionPath' : 'inspectedStatePath'; @@ -162,16 +168,16 @@ export default class DevtoolsInspector extends Component { ref='inspector' {...styling(['inspector', isWideLayout && 'inspectorWide'], isWideLayout)}> { + if (reorderAction) this.props.dispatch(reorderAction(actionId, beforeActionId)); + }; + handleCommit = () => { this.props.dispatch(commit()); }; diff --git a/src/utils/createStylingFromTheme.js b/src/utils/createStylingFromTheme.js index ec972ba..ae1096a 100644 --- a/src/utils/createStylingFromTheme.js +++ b/src/utils/createStylingFromTheme.js @@ -81,7 +81,24 @@ const getSheetFromColorMap = map => ({ }, actionListRows: { - overflow: 'auto' + overflow: 'auto', + + '& div.gu-transit': { + opacity: '0.3' + }, + + '& div.gu-mirror': { + position: 'fixed', + opacity: '0.8', + height: 'auto !important', + 'border-width': '1px', + 'border-style': 'solid', + 'border-color': map.LIST_BORDER_COLOR + }, + + '& div.gu-hide': { + display: 'none' + } }, actionListHeaderSelector: {