From d59a1af5d4bab9b14bd5582c8078e558cf888307 Mon Sep 17 00:00:00 2001 From: Thomas Nordquist Date: Tue, 25 Jun 2019 17:44:34 +0200 Subject: [PATCH] Refactor TreeNodeComponent --- app/src/components/Tree/Tree.tsx | 18 +- app/src/components/Tree/TreeNode.tsx | 268 +++++++----------- app/src/components/Tree/TreeNodeSubnodes.tsx | 7 +- .../Tree/useViewModelSubscriptions.tsx | 43 +++ 4 files changed, 154 insertions(+), 182 deletions(-) create mode 100644 app/src/components/Tree/useViewModelSubscriptions.tsx diff --git a/app/src/components/Tree/Tree.tsx b/app/src/components/Tree/Tree.tsx index 18aea4d..28a1846 100644 --- a/app/src/components/Tree/Tree.tsx +++ b/app/src/components/Tree/Tree.tsx @@ -56,13 +56,6 @@ class TreeComponent extends React.PureComponent { average.push(Date.now(), ms) } - public time(): number { - const time = performance.now() - this.perf - this.perf = performance.now() - - return time - } - public componentWillReceiveProps(nextProps: Props) { if (this.props.tree !== nextProps.tree) { if (this.props.tree) { @@ -110,6 +103,14 @@ class TreeComponent extends React.PureComponent { }, Math.max(0, timeUntilNextUpdate)) } + public componentWillUpdate() { + this.perf = performance.now() + } + + public componentDidUpdate() { + this.performanceCallback(performance.now() - this.perf) + } + public render() { const { tree } = this.props if (!tree) { @@ -139,10 +140,9 @@ class TreeComponent extends React.PureComponent { treeNode={tree} name={this.props.host} collapsed={false} - performanceCallback={this.performanceCallback} settings={this.props.settings} lastUpdate={tree.lastUpdate} - didSelectTopic={this.props.actions.selectTopic} + selectTopicAction={this.props.actions.selectTopic} /> ) diff --git a/app/src/components/Tree/TreeNode.tsx b/app/src/components/Tree/TreeNode.tsx index 5ad050c..f2262b9 100644 --- a/app/src/components/Tree/TreeNode.tsx +++ b/app/src/components/Tree/TreeNode.tsx @@ -1,10 +1,11 @@ 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 TreeNodeTitle from './TreeNodeTitle' import { SettingsState } from '../../reducers/Settings' import { Theme, withStyles } from '@material-ui/core/styles' import { TopicViewModel } from '../../model/TopicViewModel' +import { useViewModelSubscriptions } from './useViewModelSubscriptions' const debounce = require('lodash.debounce') declare var performance: any @@ -53,216 +54,145 @@ interface Props { treeNode: q.TreeNode name?: string | undefined collapsed?: boolean | undefined - performanceCallback?: ((ms: number) => void) | undefined classes: any className?: string lastUpdate: number - didSelectTopic: any + selectTopicAction: (treeNode: q.TreeNode) => void theme: Theme settings: SettingsState } -interface State { - collapsedOverride: boolean | undefined - mouseOver: boolean - selected: boolean +function useIsAllowedToAutoExpandState(props: Props): boolean { + const { settings, treeNode, isRoot } = props + const [isAllowedToAutoExpand, setAllowAutoExpand] = useState(false) + + 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 { - private animationDirty: boolean = false - private cssAnimationWasSetAt?: number - private willUpdateTime: number = performance.now() - private nodeRef?: React.RefObject = React.createRef() +function TreeNodeComponent(props: Props) { + const { classes, className, settings, theme, treeNode, lastUpdate } = props - private setHover = debounce((hover: boolean) => { - this.setState({ mouseOver: hover }) + const [animationDirty, setAnimationDirty] = useState(false) + const [collapsed, setCollapsed] = useState(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() + const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props) + useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsed) + + const setHover = debounce((hover: boolean) => { + setIsHovering(hover) }, 45) - constructor(props: Props) { - super(props) + const toggle = useCallback(() => { + setCollapsed(!collapsed) + }, [collapsed]) - this.state = { - collapsedOverride: props.collapsed, - mouseOver: false, - selected: false, - } - } + const didSelectTopic = useCallback( + (event?: React.MouseEvent) => { + console.log('Did select', treeNode.path()) + console.log(event) + event && event.stopPropagation() + props.selectTopicAction(treeNode) + }, + [treeNode] + ) - private addSubscriber(treeNode: q.TreeNode) { - 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) { - 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) => { + const didClickTitle = (event: React.MouseEvent) => { event.stopPropagation() - this.setHover(true) - if ( - this.props.settings.get('selectTopicWithMouseOver') && - this.props.treeNode && - this.props.treeNode.message && - this.props.treeNode.message.value - ) { - this.props.didSelectTopic(this.props.treeNode) - } + didSelectTopic() + toggle() } - private mouseOut = (event: React.MouseEvent) => { + const didObtainFocus = useCallback(() => { + didSelectTopic() + }, []) + + const mouseOver = (event: React.MouseEvent) => { 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() - this.toggle() + setHover(false) } - private renderNodes() { - const isCollapsed = this.collapsed() - this.props.treeNode.viewModel && this.props.treeNode.viewModel.setExpanded(!isCollapsed, false) + const toggleCollapsed = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + toggle() + }, + [toggle] + ) - if (this.collapsed()) { - return null + useEffect(() => { + 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 ( + + ) } - return ( - - ) - } - - 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 shouldStartAnimation = settings.get('highlightTopicUpdates') + const animationName = theme.palette.type === 'light' ? 'updateLight' : 'updateDark' const animation = shouldStartAnimation ? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` } : {} - this.animationDirty = shouldStartAnimation - const highlightClass = this.state.selected - ? this.props.classes.selected - : this.state.mouseOver - ? this.props.classes.hover - : '' + const highlightClass = selected ? classes.selected : isHovering ? classes.hover : '' return (
this.props.didSelectTopic(this.props.treeNode)} - ref={this.nodeRef} + onMouseOver={mouseOver} + onMouseOut={mouseOut} + onClick={didClickTitle} + onFocus={didObtainFocus} + ref={nodeRef as any} tabIndex={1000} >
- {this.renderNodes()} + {renderNodes()}
) - } + }, [lastUpdate, treeNode, collapsed, selected, isAllowedToAutoExpand, isHovering]) } export default withStyles(styles, { withTheme: true })(TreeNodeComponent) diff --git a/app/src/components/Tree/TreeNodeSubnodes.tsx b/app/src/components/Tree/TreeNodeSubnodes.tsx index a719b70..e1291ac 100644 --- a/app/src/components/Tree/TreeNodeSubnodes.tsx +++ b/app/src/components/Tree/TreeNodeSubnodes.tsx @@ -9,11 +9,10 @@ import { TopicViewModel } from '../../model/TopicViewModel' export interface Props { treeNode: q.TreeNode filter?: string - collapsed?: boolean | undefined classes: any lastUpdate: number selectedTopic?: q.TreeNode - didSelectTopic: any + selectTopicAction: (treeNode: q.TreeNode) => void settings: SettingsState } @@ -43,7 +42,7 @@ class TreeNodeSubnodes extends React.Component { public render() { const edges = this.props.treeNode.edgeArray - if (edges.length === 0 || this.props.collapsed) { + if (edges.length === 0) { return null } @@ -59,7 +58,7 @@ class TreeNodeSubnodes extends React.Component { treeNode={node} className={this.props.classes.listItem} lastUpdate={node.lastUpdate} - didSelectTopic={this.props.didSelectTopic} + selectTopicAction={this.props.selectTopicAction} settings={this.props.settings} /> ) diff --git a/app/src/components/Tree/useViewModelSubscriptions.tsx b/app/src/components/Tree/useViewModelSubscriptions.tsx new file mode 100644 index 0000000..2234a0b --- /dev/null +++ b/app/src/components/Tree/useViewModelSubscriptions.tsx @@ -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, + nodeRef: React.MutableRefObject, + 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 + } + } +}