This commit is contained in:
Thomas Nordquist
2019-06-26 17:41:04 +02:00
parent e02091f645
commit f9829c2d5c
24 changed files with 204 additions and 152 deletions

View File

@@ -0,0 +1,82 @@
import * as q from '../../../../../backend/src/Model'
import React from 'react'
import TreeNode from '.'
import { SettingsState } from '../../../reducers/Settings'
import { sortedNodes } from '../../../sortedNodes'
import { Theme, withStyles } from '@material-ui/core'
import { TopicViewModel } from '../../../model/TopicViewModel'
import { treeActions } from '../../../actions'
export interface Props {
treeNode: q.TreeNode<TopicViewModel>
filter?: string
classes: any
lastUpdate: number
selectedTopic?: q.TreeNode<TopicViewModel>
selectTopicAction: (treeNode: q.TreeNode<any>) => void
settings: SettingsState
actions: typeof treeActions
}
interface State {
alreadyAdded: number
}
class TreeNodeSubnodes extends React.Component<Props, State> {
private renderMoreAnimationFrame?: any
constructor(props: Props) {
super(props)
this.state = { alreadyAdded: 10 }
}
private renderMore() {
this.renderMoreAnimationFrame = (window as any).requestIdleCallback(
() => {
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
},
{ timeout: 500 }
)
}
public componentWillUnmount() {
;(window as any).cancelIdleCallback(this.renderMoreAnimationFrame)
}
public render() {
const edges = this.props.treeNode.edgeArray
if (edges.length === 0) {
return null
}
if (this.state.alreadyAdded < edges.length) {
this.renderMore()
}
const nodes = sortedNodes(this.props.settings, this.props.treeNode).slice(0, this.state.alreadyAdded)
const listItems = nodes.map(node => {
return (
<TreeNode
key={`${node.hash()}-${this.props.filter}`}
treeNode={node}
className={this.props.classes.listItem}
lastUpdate={node.lastUpdate}
selectTopicAction={this.props.selectTopicAction}
settings={this.props.settings}
actions={this.props.actions}
/>
)
})
return <span className={this.props.classes.list}>{listItems}</span>
}
}
const styles = (theme: Theme) => ({
list: {
display: 'block' as 'block',
clear: 'both' as 'both',
marginLeft: theme.spacing(1.5),
},
})
export default withStyles(styles)(TreeNodeSubnodes)

View File

@@ -0,0 +1,92 @@
import * as React from 'react'
import * as q from '../../../../../backend/src/Model'
import { withStyles, Theme } from '@material-ui/core'
import { TopicViewModel } from '../../../model/TopicViewModel'
import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
didSelectNode: any
toggleCollapsed: any
classes: any
}
class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
private renderSourceEdge() {
const name = this.props.name || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name)
return (
<span key="edge" className={this.props.classes.sourceEdge}>
{name}
</span>
)
}
private renderValue() {
return this.props.treeNode.message &&
this.props.treeNode.message.value &&
this.props.treeNode.message.length > 0 ? (
<span key="value" className={this.props.classes.value}>
{' '}
= {Base64Message.toUnicodeString(this.props.treeNode.message.value).slice(0, 120)}
</span>
) : null
}
private renderExpander() {
if (this.props.treeNode.edgeCount() === 0) {
return null
}
return (
<span key="expander" className={this.props.classes.expander} onClick={this.props.toggleCollapsed}>
{this.props.collapsed ? '▶' : '▼'}
</span>
)
}
private renderMetadata() {
if (this.props.treeNode.edgeCount() === 0 || !this.props.collapsed) {
return null
}
const messages = this.props.treeNode.leafMessageCount()
return (
<span
key="metadata"
className={this.props.classes.collapsedSubnodes}
>{`(${this.props.treeNode.childTopicCount()} topics, ${messages} messages)`}</span>
)
}
public render() {
return [this.renderExpander(), this.renderSourceEdge(), this.renderMetadata(), this.renderValue()]
}
}
const styles = (theme: Theme) => ({
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
},
expander: {
color: theme.palette.type === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: theme.spacing(0.25),
userSelect: 'none' as 'none',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
},
})
export default withStyles(styles)(TreeNodeTitle)

View File

@@ -0,0 +1,22 @@
import React, { useEffect } from 'react'
export function useAnimationToIndicateTopicUpdate(
lastUpdate: number,
selected: boolean,
setShowUpdateAnimation: React.Dispatch<React.SetStateAction<boolean>>,
showUpdateAnimation: boolean
) {
useEffect(() => {
if (Date.now() - lastUpdate < 3000 && !selected) {
setShowUpdateAnimation(true)
}
}, [lastUpdate, selected])
useEffect(() => {
if (showUpdateAnimation) {
const timeout = setTimeout(() => setShowUpdateAnimation(false), 500)
return function cleanup() {
clearTimeout(timeout)
}
}
}, [showUpdateAnimation])
}

View File

@@ -0,0 +1,17 @@
import * as q from '../../../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { KeyCodes } from '../../../../utils/KeyCodes'
import { treeActions } from '../../../../actions'
export function useDeleteKeyCallback(topic: q.TreeNode<any>, actions: typeof treeActions) {
return useCallback(
(event: React.KeyboardEvent) => {
if (event.keyCode === KeyCodes.delete || event.keyCode === KeyCodes.backspace) {
event.stopPropagation()
event.preventDefault()
actions.clearTopic(topic, true, 50)
}
},
[topic]
)
}

View File

@@ -0,0 +1,14 @@
import { Props } from '..'
import { useEffect, useState } from 'react'
export function useIsAllowedToAutoExpandState(props: Props): boolean {
const { settings, treeNode, isRoot } = props
const [isAllowedToAutoExpand, setAllowAutoExpand] = useState(false)
useEffect(() => {
const newIsAllowedToAutoExpand = isRoot || treeNode.edgeCount() <= settings.get('autoExpandLimit')
if (newIsAllowedToAutoExpand !== isAllowedToAutoExpand) {
setAllowAutoExpand(newIsAllowedToAutoExpand)
}
}, [treeNode.edgeCount(), settings.get('autoExpandLimit')])
return isAllowedToAutoExpand
}

View File

@@ -0,0 +1,44 @@
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()
}
}, [treeNode])
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
}
}
}

View File

@@ -0,0 +1,178 @@
import * as q from '../../../../../backend/src/Model'
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 './effects/useViewModelSubscriptions'
import { treeActions } from '../../../actions'
import { lightBlue, teal, amber, green, deepPurple, blueGrey } from '@material-ui/core/colors'
import { useAnimationToIndicateTopicUpdate } from './effects/useAnimationToIndicateTopicUpdate'
import { useDeleteKeyCallback } from './effects/useDeleteKeyCallback'
import { useIsAllowedToAutoExpandState } from './effects/useIsAllowedToAutoExpandState'
const styles = (theme: Theme) => {
return {
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
displayBlock: {
display: 'block',
},
node: {
display: 'block',
marginLeft: '10px',
'&:hover': {
backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light,
},
},
topicSelect: {
float: 'right' as 'right',
opacity: 0,
cursor: 'pointer',
marginTop: '-1px',
},
subnodes: {
marginLeft: theme.spacing(1.5),
},
selected: {
backgroundColor: (theme.palette.type === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
},
hover: {},
title: {
borderRadius: '4px',
lineHeight: '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 4px 0px 4px',
height: '14px',
margin: '1px 0px 2px 0px',
},
}
}
export interface Props {
isRoot?: boolean
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
classes: any
className?: string
lastUpdate: number
actions: typeof treeActions
selectTopicAction: (treeNode: q.TreeNode<any>) => void
theme: Theme
settings: SettingsState
}
function TreeNodeComponent(props: Props) {
const { actions, classes, className, settings, theme, treeNode, lastUpdate, name } = props
const deleteTopicCallback = useDeleteKeyCallback(treeNode, actions)
const [showUpdateAnimation, setShowUpdateAnimation] = useState(false)
const [collapsedOverride, setCollapsedOverride] = useState<boolean | undefined>(undefined)
const [selected, setSelected] = useState(false)
const nodeRef = useRef<HTMLDivElement>()
const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props)
useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsedOverride)
useAnimationToIndicateTopicUpdate(lastUpdate, selected, setShowUpdateAnimation, showUpdateAnimation)
const isCollapsed =
Boolean(collapsedOverride) === collapsedOverride ? Boolean(collapsedOverride) : !isAllowedToAutoExpand
const toggle = useCallback(() => {
setCollapsedOverride(!isCollapsed)
}, [isCollapsed])
const didSelectTopic = useCallback(
(event?: React.MouseEvent) => {
event && event.stopPropagation()
props.selectTopicAction(treeNode)
},
[treeNode]
)
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
didSelectTopic()
toggle()
},
[toggle, didSelectTopic]
)
const toggleCollapsed = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
toggle()
},
[toggle]
)
const didObtainFocus = useCallback(() => {
didSelectTopic()
}, [didSelectTopic])
const mouseOver = (event: React.MouseEvent) => {
event.stopPropagation()
if (settings.get('selectTopicWithMouseOver') && treeNode && treeNode.message && treeNode.message.value) {
didSelectTopic()
}
}
useEffect(() => {
treeNode.viewModel && treeNode.viewModel.setExpanded(!isCollapsed, false)
}, [isCollapsed])
return useMemo(() => {
function renderNodes() {
if (isCollapsed) {
return null
}
return (
<TreeNodeSubnodes
treeNode={treeNode}
lastUpdate={treeNode.lastUpdate}
selectTopicAction={props.selectTopicAction}
settings={settings}
actions={props.actions}
/>
)
}
const shouldStartAnimation = settings.get('highlightTopicUpdates') && showUpdateAnimation
const animationName = theme.palette.type === 'light' ? 'updateLight' : 'updateDark'
const animation = shouldStartAnimation
? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` }
: {}
const highlightClass = selected ? classes.selected : ''
return (
<div>
<div
key={treeNode.hash()}
className={`${classes.node} ${className} ${highlightClass} ${classes.title}`}
style={animation}
onMouseEnter={mouseOver}
onFocus={didObtainFocus}
onClick={didClickTitle}
ref={nodeRef as any}
tabIndex={-1}
onKeyDown={deleteTopicCallback}
>
<TreeNodeTitle
toggleCollapsed={toggleCollapsed}
didSelectNode={didSelectTopic}
collapsed={isCollapsed}
treeNode={treeNode}
name={name}
/>
</div>
{renderNodes()}
</div>
)
}, [lastUpdate, treeNode, name, isCollapsed, selected, theme, showUpdateAnimation])
}
export default withStyles(styles, { withTheme: true })(TreeNodeComponent)