Refactor TreeNodeComponent

This commit is contained in:
Thomas Nordquist
2019-06-25 17:44:34 +02:00
parent 1638080e85
commit d59a1af5d4
4 changed files with 154 additions and 182 deletions

View File

@@ -56,13 +56,6 @@ class TreeComponent extends React.PureComponent<Props, State> {
average.push(Date.now(), ms) average.push(Date.now(), ms)
} }
public time(): number {
const time = performance.now() - this.perf
this.perf = performance.now()
return time
}
public componentWillReceiveProps(nextProps: Props) { public componentWillReceiveProps(nextProps: Props) {
if (this.props.tree !== nextProps.tree) { if (this.props.tree !== nextProps.tree) {
if (this.props.tree) { if (this.props.tree) {
@@ -110,6 +103,14 @@ class TreeComponent extends React.PureComponent<Props, State> {
}, Math.max(0, timeUntilNextUpdate)) }, Math.max(0, timeUntilNextUpdate))
} }
public componentWillUpdate() {
this.perf = performance.now()
}
public componentDidUpdate() {
this.performanceCallback(performance.now() - this.perf)
}
public render() { public render() {
const { tree } = this.props const { tree } = this.props
if (!tree) { if (!tree) {
@@ -139,10 +140,9 @@ class TreeComponent extends React.PureComponent<Props, State> {
treeNode={tree} treeNode={tree}
name={this.props.host} name={this.props.host}
collapsed={false} collapsed={false}
performanceCallback={this.performanceCallback}
settings={this.props.settings} settings={this.props.settings}
lastUpdate={tree.lastUpdate} lastUpdate={tree.lastUpdate}
didSelectTopic={this.props.actions.selectTopic} selectTopicAction={this.props.actions.selectTopic}
/> />
</div> </div>
) )

View File

@@ -1,10 +1,11 @@
import * as q from '../../../../backend/src/Model' import * as q from '../../../../backend/src/Model'
import * as React from 'react' import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import TreeNodeSubnodes from './TreeNodeSubnodes' import TreeNodeSubnodes from './TreeNodeSubnodes'
import TreeNodeTitle from './TreeNodeTitle' import TreeNodeTitle from './TreeNodeTitle'
import { SettingsState } from '../../reducers/Settings' import { SettingsState } from '../../reducers/Settings'
import { Theme, withStyles } from '@material-ui/core/styles' import { Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../model/TopicViewModel' import { TopicViewModel } from '../../model/TopicViewModel'
import { useViewModelSubscriptions } from './useViewModelSubscriptions'
const debounce = require('lodash.debounce') const debounce = require('lodash.debounce')
declare var performance: any declare var performance: any
@@ -53,216 +54,145 @@ interface Props {
treeNode: q.TreeNode<TopicViewModel> treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined name?: string | undefined
collapsed?: boolean | undefined collapsed?: boolean | undefined
performanceCallback?: ((ms: number) => void) | undefined
classes: any classes: any
className?: string className?: string
lastUpdate: number lastUpdate: number
didSelectTopic: any selectTopicAction: (treeNode: q.TreeNode<any>) => void
theme: Theme theme: Theme
settings: SettingsState settings: SettingsState
} }
interface State { function useIsAllowedToAutoExpandState(props: Props): boolean {
collapsedOverride: boolean | undefined const { settings, treeNode, isRoot } = props
mouseOver: boolean const [isAllowedToAutoExpand, setAllowAutoExpand] = useState(false)
selected: boolean
useEffect(() => {
const newIsAllowedToAutoExpand = !(treeNode.edgeCount() > settings.get('autoExpandLimit'))
if (!isRoot && newIsAllowedToAutoExpand !== isAllowedToAutoExpand) {
setAllowAutoExpand(newIsAllowedToAutoExpand)
}
}, [treeNode.edgeCount(), settings.get('autoExpandLimit')])
return isAllowedToAutoExpand
} }
class TreeNodeComponent extends React.Component<Props, State> { function TreeNodeComponent(props: Props) {
private animationDirty: boolean = false const { classes, className, settings, theme, treeNode, lastUpdate } = props
private cssAnimationWasSetAt?: number
private willUpdateTime: number = performance.now()
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private setHover = debounce((hover: boolean) => { const [animationDirty, setAnimationDirty] = useState(false)
this.setState({ mouseOver: hover }) const [collapsed, setCollapsed] = useState<boolean | undefined>(props.collapsed)
const [cssAnimationWasSetAt, setCssAnimationWasSetAt] = useState(0)
const [willUpdateTime, setWillUpdateTime] = useState(performance.now())
const [isHovering, setIsHovering] = useState(false)
const [selected, setSelected] = useState(false)
const nodeRef = useRef<HTMLDivElement>()
const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props)
useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsed)
const setHover = debounce((hover: boolean) => {
setIsHovering(hover)
}, 45) }, 45)
constructor(props: Props) { const toggle = useCallback(() => {
super(props) setCollapsed(!collapsed)
}, [collapsed])
this.state = { const didSelectTopic = useCallback(
collapsedOverride: props.collapsed, (event?: React.MouseEvent) => {
mouseOver: false, console.log('Did select', treeNode.path())
selected: false, console.log(event)
} event && event.stopPropagation()
} props.selectTopicAction(treeNode)
},
[treeNode]
)
private addSubscriber(treeNode: q.TreeNode<TopicViewModel>) { const didClickTitle = (event: React.MouseEvent) => {
treeNode.viewModel = new TopicViewModel()
treeNode.viewModel.selectionChange.subscribe(this.selectionDidChange)
treeNode.viewModel.expandedChange.subscribe(this.expandedDidChange)
}
private selectionDidChange = () => {
const selected = this.props.treeNode.viewModel && this.props.treeNode.viewModel.isSelected()
this.props.treeNode.viewModel && this.setState({ selected: Boolean(selected) })
if (selected && this.nodeRef && this.nodeRef.current) {
this.nodeRef.current.focus({ preventScroll: false })
}
}
private expandedDidChange = () => {
this.props.treeNode.viewModel && this.setState({ collapsedOverride: !this.props.treeNode.viewModel.isExpanded() })
}
private removeSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
if (treeNode.viewModel) {
treeNode.viewModel.selectionChange.unsubscribe(this.selectionDidChange)
treeNode.viewModel.expandedChange.unsubscribe(this.expandedDidChange)
treeNode.viewModel = undefined
}
}
private stateHasChanged(newState: State) {
return (
this.state.collapsedOverride !== newState.collapsedOverride ||
this.state.mouseOver !== newState.mouseOver ||
this.state.selected !== newState.selected
)
}
private toggle() {
this.setState({ collapsedOverride: !this.collapsed() })
}
private collapsed() {
if (this.state.collapsedOverride !== undefined) {
return this.state.collapsedOverride
}
return this.props.treeNode.edgeCount() > this.props.settings.get('autoExpandLimit')
}
private didSelectTopic = () => {
this.props.didSelectTopic(this.props.treeNode)
}
private didClickTitle = (event: React.MouseEvent) => {
event.preventDefault()
this.props.didSelectTopic(this.props.treeNode)
this.toggle()
}
private mouseOver = (event: React.MouseEvent) => {
event.stopPropagation() event.stopPropagation()
this.setHover(true) didSelectTopic()
if ( toggle()
this.props.settings.get('selectTopicWithMouseOver') &&
this.props.treeNode &&
this.props.treeNode.message &&
this.props.treeNode.message.value
) {
this.props.didSelectTopic(this.props.treeNode)
}
} }
private mouseOut = (event: React.MouseEvent) => { const didObtainFocus = useCallback(() => {
didSelectTopic()
}, [])
const mouseOver = (event: React.MouseEvent) => {
event.stopPropagation() event.stopPropagation()
this.setHover(false) setHover(true)
if (settings.get('selectTopicWithMouseOver') && treeNode && treeNode.message && treeNode.message.value) {
didSelectTopic()
}
} }
private toggleCollapsed = (event: React.MouseEvent) => { const mouseOut = (event: React.MouseEvent) => {
event.stopPropagation() event.stopPropagation()
this.toggle() setHover(false)
} }
private renderNodes() { const toggleCollapsed = useCallback(
const isCollapsed = this.collapsed() (event: React.MouseEvent) => {
this.props.treeNode.viewModel && this.props.treeNode.viewModel.setExpanded(!isCollapsed, false) event.stopPropagation()
toggle()
},
[toggle]
)
if (this.collapsed()) { useEffect(() => {
return null treeNode.viewModel && treeNode.viewModel.setExpanded(!collapsed, false)
}, [collapsed])
return useMemo(() => {
const shouldBeRenderedCollapsed = Boolean(collapsed) === collapsed ? Boolean(collapsed) : !isAllowedToAutoExpand
function renderNodes() {
if (shouldBeRenderedCollapsed) {
return null
}
return (
<TreeNodeSubnodes
treeNode={treeNode}
lastUpdate={treeNode.lastUpdate}
selectTopicAction={props.selectTopicAction}
settings={settings}
/>
)
} }
return ( const shouldStartAnimation = settings.get('highlightTopicUpdates')
<TreeNodeSubnodes const animationName = theme.palette.type === 'light' ? 'updateLight' : 'updateDark'
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
lastUpdate={this.props.treeNode.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
settings={this.props.settings}
/>
)
}
public componentDidMount() {
const { treeNode } = this.props
this.addSubscriber(treeNode)
}
public componentWillUnmount() {
const { treeNode } = this.props
this.removeSubscriber(treeNode)
this.nodeRef = undefined
}
public shouldComponentUpdate(nextProps: Props, nextState: State) {
const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined
return (
this.stateHasChanged(nextState) ||
this.props.settings !== nextProps.settings ||
this.props.lastUpdate !== nextProps.lastUpdate ||
this.animationDirty ||
shouldRenderToRemoveCssAnimation
)
}
public componentDidUpdate() {
if (this.props.performanceCallback) {
const renderTime = performance.now() - this.willUpdateTime
this.props.performanceCallback(renderTime)
}
}
public componentWillUpdate() {
if (this.props.performanceCallback) {
this.willUpdateTime = performance.now()
}
}
public render() {
const { classes } = this.props
const shouldStartAnimation =
!this.animationDirty && !this.props.isRoot && this.props.settings.get('highlightTopicUpdates')
const animationName = this.props.theme.palette.type === 'light' ? 'updateLight' : 'updateDark'
const animation = shouldStartAnimation const animation = shouldStartAnimation
? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` } ? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` }
: {} : {}
this.animationDirty = shouldStartAnimation
const highlightClass = this.state.selected const highlightClass = selected ? classes.selected : isHovering ? classes.hover : ''
? this.props.classes.selected
: this.state.mouseOver
? this.props.classes.hover
: ''
return ( return (
<div> <div>
<div <div
key={this.props.treeNode.hash()} key={treeNode.hash()}
className={`${classes.node} ${this.props.className} ${highlightClass} ${classes.title}`} className={`${classes.node} ${className} ${highlightClass} ${classes.title}`}
style={animation} style={animation}
onMouseOver={this.mouseOver} onMouseOver={mouseOver}
onMouseOut={this.mouseOut} onMouseOut={mouseOut}
onClick={this.didClickTitle} onClick={didClickTitle}
onFocus={() => this.props.didSelectTopic(this.props.treeNode)} onFocus={didObtainFocus}
ref={this.nodeRef} ref={nodeRef as any}
tabIndex={1000} tabIndex={1000}
> >
<TreeNodeTitle <TreeNodeTitle
toggleCollapsed={this.toggleCollapsed} toggleCollapsed={toggleCollapsed}
didSelectNode={this.didSelectTopic} didSelectNode={didSelectTopic}
collapsed={this.collapsed()} collapsed={shouldBeRenderedCollapsed}
treeNode={this.props.treeNode} treeNode={treeNode}
name={this.props.name} name={name}
/> />
</div> </div>
{this.renderNodes()} {renderNodes()}
</div> </div>
) )
} }, [lastUpdate, treeNode, collapsed, selected, isAllowedToAutoExpand, isHovering])
} }
export default withStyles(styles, { withTheme: true })(TreeNodeComponent) export default withStyles(styles, { withTheme: true })(TreeNodeComponent)

View File

@@ -9,11 +9,10 @@ import { TopicViewModel } from '../../model/TopicViewModel'
export interface Props { export interface Props {
treeNode: q.TreeNode<TopicViewModel> treeNode: q.TreeNode<TopicViewModel>
filter?: string filter?: string
collapsed?: boolean | undefined
classes: any classes: any
lastUpdate: number lastUpdate: number
selectedTopic?: q.TreeNode<TopicViewModel> selectedTopic?: q.TreeNode<TopicViewModel>
didSelectTopic: any selectTopicAction: (treeNode: q.TreeNode<any>) => void
settings: SettingsState settings: SettingsState
} }
@@ -43,7 +42,7 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
public render() { public render() {
const edges = this.props.treeNode.edgeArray const edges = this.props.treeNode.edgeArray
if (edges.length === 0 || this.props.collapsed) { if (edges.length === 0) {
return null return null
} }
@@ -59,7 +58,7 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
treeNode={node} treeNode={node}
className={this.props.classes.listItem} className={this.props.classes.listItem}
lastUpdate={node.lastUpdate} lastUpdate={node.lastUpdate}
didSelectTopic={this.props.didSelectTopic} selectTopicAction={this.props.selectTopicAction}
settings={this.props.settings} settings={this.props.settings}
/> />
) )

View File

@@ -0,0 +1,43 @@
import * as q from '../../../../backend/src/Model'
import React, { useEffect } from 'react'
import { TopicViewModel } from '../../model/TopicViewModel'
export function useViewModelSubscriptions(
treeNode: q.TreeNode<TopicViewModel>,
nodeRef: React.MutableRefObject<HTMLDivElement | undefined>,
setSelected: (value: boolean) => void,
setCollapsedOverride: (value: boolean) => void
) {
const selectionDidChange = () => {
const selected = treeNode.viewModel && treeNode.viewModel.isSelected()
treeNode.viewModel && setSelected(Boolean(selected))
if (selected && nodeRef && nodeRef.current) {
nodeRef.current.focus({ preventScroll: false })
}
}
const expandedDidChange = () => {
treeNode.viewModel && setCollapsedOverride(!treeNode.viewModel.isExpanded())
}
useEffect(() => {
addSubscriber()
return function cleanup() {
removeSubscriber()
}
})
function addSubscriber() {
treeNode.viewModel = new TopicViewModel()
treeNode.viewModel.selectionChange.subscribe(selectionDidChange)
treeNode.viewModel.expandedChange.subscribe(expandedDidChange)
}
function removeSubscriber() {
if (treeNode.viewModel) {
treeNode.viewModel.selectionChange.unsubscribe(selectionDidChange)
treeNode.viewModel.expandedChange.unsubscribe(expandedDidChange)
treeNode.viewModel = undefined
}
}
}