From f4051b4cdf2387bb674ef0589bfd3bdec528f5ba Mon Sep 17 00:00:00 2001 From: Thomas Nordquist Date: Tue, 25 Jun 2019 01:39:31 +0200 Subject: [PATCH] Add tree navigation via arrow keys --- app/src/actions/Tree.ts | 4 +- app/src/actions/visibleTreeTraversal.ts | 81 ++++++++++++++++++++ app/src/components/Tree/Tree.tsx | 27 +++++-- app/src/components/Tree/TreeNode.tsx | 25 +++--- app/src/components/Tree/TreeNodeSubnodes.tsx | 28 +------ app/src/model/TopicViewModel.ts | 21 ++++- app/src/reducers/Settings.ts | 29 +++---- app/src/reducers/index.ts | 3 +- app/src/sortedNodes.tsx | 19 +++++ 9 files changed, 179 insertions(+), 58 deletions(-) create mode 100644 app/src/actions/visibleTreeTraversal.ts create mode 100644 app/src/sortedNodes.tsx diff --git a/app/src/actions/Tree.ts b/app/src/actions/Tree.ts index 4bcb9f5..0e88fbf 100644 --- a/app/src/actions/Tree.ts +++ b/app/src/actions/Tree.ts @@ -4,11 +4,13 @@ import { ActionTypes as SidebarActionTypes } from '../reducers/Sidebar' import { AnyAction, Dispatch } from 'redux' import { AppState } from '../reducers' import { batchActions } from 'redux-batched-actions' +import { globalActions } from './' import { setTopic } from './Publish' import { TopicViewModel } from '../model/TopicViewModel' -import { globalActions } from '.' const debounce = require('lodash.debounce') +export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal' + export const selectTopic = (topic: q.TreeNode) => ( dispatch: Dispatch, getState: () => AppState diff --git a/app/src/actions/visibleTreeTraversal.ts b/app/src/actions/visibleTreeTraversal.ts new file mode 100644 index 0000000..3c5549b --- /dev/null +++ b/app/src/actions/visibleTreeTraversal.ts @@ -0,0 +1,81 @@ +import * as q from '../../../backend/src/Model' +import { AppState } from '../reducers' +import { Dispatch } from 'redux' +import { selectTopic } from './Tree' +import { SettingsState } from '../reducers/Settings' +import { sortedNodes } from '../sortedNodes' +import { TopicViewModel } from '../model/TopicViewModel' + +export const moveSelectionUpOrDownwards = (direction: 'next' | 'previous') => ( + dispatch: Dispatch, + getState: () => AppState +): any => { + const state = getState() + const selected = state.tree.get('selectedTopic') + const tree = state.tree.get('tree') + if (!selected || !tree) { + if (tree) { + dispatch(selectTopic(tree)) + } + return + } + const nextTreeNode = nextVisibleElementInTree(state.settings, tree, selected, direction) + if (nextTreeNode && nextTreeNode.viewModel) { + dispatch(selectTopic(nextTreeNode)) + } +} + +export const moveInward = () => (dispatch: Dispatch, getState: () => AppState): any => { + const state = getState() + const selected = state.tree.get('selectedTopic') + if (!selected || !selected.viewModel) { + return + } + + if (!selected.viewModel.isExpanded() && selected.edgeCount() > 0) { + selected.viewModel.setExpanded(true, true) + } else { + dispatch(moveSelectionUpOrDownwards('next')) + } +} + +export const moveOutward = () => (dispatch: Dispatch, getState: () => AppState): any => { + const state = getState() + const selected = state.tree.get('selectedTopic') + if (!selected || !selected.viewModel) { + return + } + + if (selected.viewModel.isExpanded() && selected.edgeCount() > 0) { + selected.viewModel.setExpanded(false, true) + } else { + dispatch(moveSelectionUpOrDownwards('previous')) + } +} + +function isTreeNodeVisible(treeNode: q.TreeNode) { + return Boolean(treeNode.viewModel) +} + +function nextVisibleElementInTree( + settings: SettingsState, + tree: q.Tree, + node: q.TreeNode, + direction: 'next' | 'previous' +): q.TreeNode | undefined { + const nodes = flattenVisibleTree(settings, tree) + const idx = nodes.findIndex(n => n.path() === node.path()) + const indexDirection = direction === 'next' ? 1 : -1 + return nodes[idx + indexDirection] +} + +/** Not very efficient but easy to implement, complexity should not be an issue here */ +function flattenVisibleTree( + settings: SettingsState, + treeNode: q.TreeNode +): Array> { + return sortedNodes(settings, treeNode) + .filter(isTreeNodeVisible) + .map(node => [node].concat(flattenVisibleTree(settings, node))) + .reduce((a, b) => a.concat(b), []) +} diff --git a/app/src/components/Tree/Tree.tsx b/app/src/components/Tree/Tree.tsx index 5037339..18aea4d 100644 --- a/app/src/components/Tree/Tree.tsx +++ b/app/src/components/Tree/Tree.tsx @@ -1,13 +1,14 @@ import * as q from '../../../../backend/src/Model' -import * as React from 'react' +import React from 'react' import TreeNode from './TreeNode' import { AppState } from '../../reducers' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' -import { Record } from 'immutable' import { SettingsState } from '../../reducers/Settings' import { TopicViewModel } from '../../model/TopicViewModel' import { treeActions } from '../../actions' +import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler' +import { KeyCodes } from '../../utils/KeyCodes' const MovingAverage = require('moving-average') @@ -20,16 +21,27 @@ interface Props { actions: typeof treeActions connectionId?: string tree?: q.Tree - filter: string host?: string paused: boolean - settings: Record + settings: SettingsState } interface State { lastUpdate: number } +function ArrowKeyHandler(props: { + action: (direction: 'next' | 'previous') => any + leftAction: () => void + rightAction: () => void +}) { + useGlobalKeyEventHandler(KeyCodes.arrow_down, () => props.action('next'), [props.action]) + useGlobalKeyEventHandler(KeyCodes.arrow_up, () => props.action('previous'), [props.action]) + useGlobalKeyEventHandler(KeyCodes.arrow_right, props.rightAction, [props.action]) + useGlobalKeyEventHandler(KeyCodes.arrow_left, props.leftAction, [props.action]) + return
+} + class TreeComponent extends React.PureComponent { private updateTimer?: any private perf: number = 0 @@ -99,7 +111,7 @@ class TreeComponent extends React.PureComponent { } public render() { - const { tree, filter } = this.props + const { tree } = this.props if (!tree) { return null } @@ -116,6 +128,11 @@ class TreeComponent extends React.PureComponent { return (
+ + settings: SettingsState } interface State { @@ -72,9 +70,7 @@ interface State { class TreeNodeComponent extends React.Component { private animationDirty: boolean = false - private cssAnimationWasSetAt?: number - private willUpdateTime: number = performance.now() private nodeRef?: React.RefObject = React.createRef() @@ -94,16 +90,24 @@ class TreeNodeComponent extends React.Component { private addSubscriber(treeNode: q.TreeNode) { treeNode.viewModel = new TopicViewModel() - treeNode.viewModel.change.subscribe(this.viewStateHasChanged) + treeNode.viewModel.selectionChange.subscribe(this.selectionDidChange) + treeNode.viewModel.expandedChange.subscribe(this.expandedDidChange) } - private viewStateHasChanged = () => { + private selectionDidChange = () => { this.props.treeNode.viewModel && this.setState({ selected: this.props.treeNode.viewModel.isSelected() }) } + private expandedDidChange = () => { + this.props.treeNode.viewModel && this.setState({ collapsedOverride: !this.props.treeNode.viewModel.isExpanded() }) + } + private removeSubscriber(treeNode: q.TreeNode) { - treeNode.viewModel && treeNode.viewModel.change.unsubscribe(this.viewStateHasChanged) - treeNode.viewModel = undefined + if (treeNode.viewModel) { + treeNode.viewModel.selectionChange.unsubscribe(this.selectionDidChange) + treeNode.viewModel.expandedChange.unsubscribe(this.expandedDidChange) + treeNode.viewModel = undefined + } } private stateHasChanged(newState: State) { @@ -160,6 +164,9 @@ class TreeNodeComponent extends React.Component { } private renderNodes() { + const isCollapsed = this.collapsed() + this.props.treeNode.viewModel && this.props.treeNode.viewModel.setExpanded(!isCollapsed, false) + if (this.collapsed()) { return null } diff --git a/app/src/components/Tree/TreeNodeSubnodes.tsx b/app/src/components/Tree/TreeNodeSubnodes.tsx index ccbf86d..a719b70 100644 --- a/app/src/components/Tree/TreeNodeSubnodes.tsx +++ b/app/src/components/Tree/TreeNodeSubnodes.tsx @@ -1,8 +1,8 @@ import * as q from '../../../../backend/src/Model' import * as React from 'react' import TreeNode from './TreeNode' -import { Record } from 'immutable' -import { SettingsState, TopicOrder } from '../../reducers/Settings' +import { SettingsState } from '../../reducers/Settings' +import { sortedNodes } from '../../sortedNodes' import { Theme, withStyles } from '@material-ui/core' import { TopicViewModel } from '../../model/TopicViewModel' @@ -14,7 +14,7 @@ export interface Props { lastUpdate: number selectedTopic?: q.TreeNode didSelectTopic: any - settings: Record + settings: SettingsState } interface State { @@ -28,26 +28,6 @@ class TreeNodeSubnodes extends React.Component { this.state = { alreadyAdded: 10 } } - private sortedNodes(): Array> { - const { settings, treeNode } = this.props - const topicOrder = settings.get('topicOrder') - - let edges = treeNode.edgeArray - if (topicOrder === TopicOrder.abc) { - edges = edges.sort((a, b) => a.name.localeCompare(b.name)) - } - - let nodes = edges.map(edge => edge.target) - if (topicOrder === TopicOrder.messages) { - nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount()) - } - if (topicOrder === TopicOrder.topics) { - nodes = nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount()) - } - - return nodes - } - private renderMore() { this.renderMoreAnimationFrame = (window as any).requestIdleCallback( () => { @@ -71,7 +51,7 @@ class TreeNodeSubnodes extends React.Component { this.renderMore() } - const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded) + const nodes = sortedNodes(this.props.settings, this.props.treeNode).slice(0, this.state.alreadyAdded) const listItems = nodes.map(node => { return ( () + private expanded: boolean + public selectionChange = new EventDispatcher() + public expandedChange = new EventDispatcher() public constructor() { this.selected = false + this.expanded = false } public destroy() { - this.change.removeAllListeners() + this.selectionChange.removeAllListeners() } public isSelected() { return this.selected } + public isExpanded() { + return this.expanded + } + public setSelected(selected: boolean) { this.selected = selected - this.change.dispatch() + this.selectionChange.dispatch() + } + + public setExpanded(expanded: boolean, fireEvent: boolean) { + const didChange = this.expanded !== expanded + this.expanded = expanded + if (didChange && fireEvent) { + this.expandedChange.dispatch() + } } } diff --git a/app/src/reducers/Settings.ts b/app/src/reducers/Settings.ts index d1fa74c..1f9ce80 100644 --- a/app/src/reducers/Settings.ts +++ b/app/src/reducers/Settings.ts @@ -9,8 +9,7 @@ export enum TopicOrder { } export type ValueRendererDisplayMode = 'diff' | 'raw' - -export interface SettingsState { +interface SettingsStateModel { autoExpandLimit: number timeLocale: string topicOrder: TopicOrder @@ -21,6 +20,8 @@ export interface SettingsState { theme: 'light' | 'dark' } +export type SettingsState = Record + export type Actions = SetAutoExpandLimitAction & DidLoadSettingsAction & SetTopicOrderAction & @@ -44,7 +45,7 @@ export enum ActionTypes { SETTINGS_SET_TIME_LOCALE = 'SETTINGS_SET_TIME_LOCALE', } -const initialState = Record({ +const initialState = Record({ timeLocale: window.navigator.language, autoExpandLimit: 0, topicOrder: TopicOrder.none, @@ -55,12 +56,12 @@ const initialState = Record({ topicFilter: undefined, }) -const setTheme = (theme: 'light' | 'dark') => (state: Record) => { +const setTheme = (theme: 'light' | 'dark') => (state: SettingsState) => { return state.set('theme', theme) } const reducerActions: { - [s: string]: (state: Record, action: Actions) => Record + [s: string]: (state: SettingsState, action: Actions) => SettingsState } = { SETTINGS_SET_AUTO_EXPAND_LIMIT: setAutoExpandLimit, SETTINGS_SET_TOPIC_ORDER: setTopicOrder, @@ -83,10 +84,10 @@ export interface SetTheme { export interface DidLoadSettingsAction { type: ActionTypes.SETTINGS_DID_LOAD_SETTINGS - settings: Partial + settings: Partial } -function didLoadSettings(state: Record, action: DidLoadSettingsAction) { +function didLoadSettings(state: SettingsState, action: DidLoadSettingsAction): SettingsState { return state.merge(action.settings) } @@ -95,7 +96,7 @@ export interface SetSelectTopicWithMouseOverAction { selectTopicWithMouseOver: boolean } -export function setSelectTopicWithMouseOver(state: Record, action: SetSelectTopicWithMouseOverAction) { +export function setSelectTopicWithMouseOver(state: SettingsState, action: SetSelectTopicWithMouseOverAction) { return state.set('selectTopicWithMouseOver', !state.get('selectTopicWithMouseOver')) } @@ -104,7 +105,7 @@ export interface SetTimeLocale { timeLocale: string } -export function setTimeLocale(state: Record, action: SetTimeLocale): Record { +export function setTimeLocale(state: SettingsState, action: SetTimeLocale): SettingsState { return state.set('timeLocale', action.timeLocale) } @@ -113,7 +114,7 @@ export interface SetValueRendererDisplayModeAction { valueRendererDisplayMode: ValueRendererDisplayMode } -export function setValueRendererDisplayMode(state: Record, action: SetValueRendererDisplayModeAction) { +export function setValueRendererDisplayMode(state: SettingsState, action: SetValueRendererDisplayModeAction) { return state.set('valueRendererDisplayMode', action.valueRendererDisplayMode) } @@ -122,7 +123,7 @@ export interface SetAutoExpandLimitAction { autoExpandLimit: number } -function setAutoExpandLimit(state: Record, action: SetAutoExpandLimitAction) { +function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimitAction) { return state.set('autoExpandLimit', action.autoExpandLimit) } @@ -130,7 +131,7 @@ export interface ToggleHighlightTopicUpdatesAction { type: ActionTypes.SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY } -function toggleHighlightTopicUpdates(state: Record, action: ToggleHighlightTopicUpdatesAction) { +function toggleHighlightTopicUpdates(state: SettingsState, action: ToggleHighlightTopicUpdatesAction) { return state.set('highlightTopicUpdates', !state.get('highlightTopicUpdates')) } @@ -139,7 +140,7 @@ export interface SetTopicOrderAction { topicOrder: TopicOrder } -function setTopicOrder(state: Record, action: SetTopicOrderAction) { +function setTopicOrder(state: SettingsState, action: SetTopicOrderAction) { return state.set('topicOrder', action.topicOrder) } @@ -149,6 +150,6 @@ export interface FilterTopicsAction { } // @Todo: move to tree reducer, should not be persisted / is no application setting -function filterTopics(state: Record, action: FilterTopicsAction) { +function filterTopics(state: SettingsState, action: FilterTopicsAction) { return state.set('topicFilter', action.topicFilter) } diff --git a/app/src/reducers/index.ts b/app/src/reducers/index.ts index 1226b64..60c870c 100644 --- a/app/src/reducers/index.ts +++ b/app/src/reducers/index.ts @@ -4,7 +4,6 @@ import { connectionManagerReducer, ConnectionManagerState } from './ConnectionMa import { connectionReducer, ConnectionState } from './Connection' import { GlobalState, globalState } from './Global' import { publishReducer, PublishState } from './Publish' -import { Record } from 'immutable' import { settingsReducer, SettingsState } from './Settings' import { sidebarReducer, SidebarState } from './Sidebar' import { treeReducer, TreeState } from './Tree' @@ -12,7 +11,7 @@ import { treeReducer, TreeState } from './Tree' export interface AppState { globalState: GlobalState tree: TreeState - settings: Record + settings: SettingsState publish: PublishState charts: ChartsState sidebar: SidebarState diff --git a/app/src/sortedNodes.tsx b/app/src/sortedNodes.tsx new file mode 100644 index 0000000..352864b --- /dev/null +++ b/app/src/sortedNodes.tsx @@ -0,0 +1,19 @@ +import * as q from '../../backend/src/Model' +import { SettingsState, TopicOrder } from './reducers/Settings' +import { TopicViewModel } from './model/TopicViewModel' + +export function sortedNodes(settings: SettingsState, treeNode: q.TreeNode): Array> { + const topicOrder = settings.get('topicOrder') + let edges = treeNode.edgeArray + if (topicOrder === TopicOrder.abc) { + edges = edges.sort((a, b) => a.name.localeCompare(b.name)) + } + let nodes = edges.map(edge => edge.target) + if (topicOrder === TopicOrder.messages) { + nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount()) + } + if (topicOrder === TopicOrder.topics) { + nodes = nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount()) + } + return nodes +}