Add tree navigation via arrow keys
This commit is contained in:
@@ -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<TopicViewModel>) => (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => AppState
|
||||
|
||||
81
app/src/actions/visibleTreeTraversal.ts
Normal file
81
app/src/actions/visibleTreeTraversal.ts
Normal file
@@ -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<any>,
|
||||
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<any>, 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<any>, 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<any>) {
|
||||
return Boolean(treeNode.viewModel)
|
||||
}
|
||||
|
||||
function nextVisibleElementInTree(
|
||||
settings: SettingsState,
|
||||
tree: q.Tree<TopicViewModel>,
|
||||
node: q.TreeNode<TopicViewModel>,
|
||||
direction: 'next' | 'previous'
|
||||
): q.TreeNode<TopicViewModel> | 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<TopicViewModel>
|
||||
): Array<q.TreeNode<TopicViewModel>> {
|
||||
return sortedNodes(settings, treeNode)
|
||||
.filter(isTreeNodeVisible)
|
||||
.map(node => [node].concat(flattenVisibleTree(settings, node)))
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
}
|
||||
@@ -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<TopicViewModel>
|
||||
filter: string
|
||||
host?: string
|
||||
paused: boolean
|
||||
settings: Record<SettingsState>
|
||||
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 <div />
|
||||
}
|
||||
|
||||
class TreeComponent extends React.PureComponent<Props, State> {
|
||||
private updateTimer?: any
|
||||
private perf: number = 0
|
||||
@@ -99,7 +111,7 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tree, filter } = this.props
|
||||
const { tree } = this.props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
@@ -116,6 +128,11 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<ArrowKeyHandler
|
||||
action={this.props.actions.moveSelectionUpOrDownwards}
|
||||
leftAction={this.props.actions.moveOutward}
|
||||
rightAction={this.props.actions.moveInward}
|
||||
/>
|
||||
<TreeNode
|
||||
key={tree.hash()}
|
||||
isRoot={true}
|
||||
|
||||
@@ -2,11 +2,9 @@ import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import TreeNodeSubnodes from './TreeNodeSubnodes'
|
||||
import TreeNodeTitle from './TreeNodeTitle'
|
||||
import { Record } from 'immutable'
|
||||
import { SettingsState } from '../../reducers/Settings'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
|
||||
const debounce = require('lodash.debounce')
|
||||
|
||||
declare var performance: any
|
||||
@@ -61,7 +59,7 @@ interface Props {
|
||||
lastUpdate: number
|
||||
didSelectTopic: any
|
||||
theme: Theme
|
||||
settings: Record<SettingsState>
|
||||
settings: SettingsState
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -72,9 +70,7 @@ interface State {
|
||||
|
||||
class TreeNodeComponent extends React.Component<Props, State> {
|
||||
private animationDirty: boolean = false
|
||||
|
||||
private cssAnimationWasSetAt?: number
|
||||
|
||||
private willUpdateTime: number = performance.now()
|
||||
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
|
||||
|
||||
@@ -94,16 +90,24 @@ class TreeNodeComponent extends React.Component<Props, State> {
|
||||
|
||||
private addSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
|
||||
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<TopicViewModel>) {
|
||||
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<Props, State> {
|
||||
}
|
||||
|
||||
private renderNodes() {
|
||||
const isCollapsed = this.collapsed()
|
||||
this.props.treeNode.viewModel && this.props.treeNode.viewModel.setExpanded(!isCollapsed, false)
|
||||
|
||||
if (this.collapsed()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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<TopicViewModel>
|
||||
didSelectTopic: any
|
||||
settings: Record<SettingsState>
|
||||
settings: SettingsState
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -28,26 +28,6 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
|
||||
this.state = { alreadyAdded: 10 }
|
||||
}
|
||||
|
||||
private sortedNodes(): Array<q.TreeNode<TopicViewModel>> {
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<TreeNode
|
||||
|
||||
@@ -3,22 +3,37 @@ import { EventDispatcher } from '../../../events'
|
||||
|
||||
export class TopicViewModel implements Destroyable {
|
||||
private selected: boolean
|
||||
public change = new EventDispatcher<void, TopicViewModel>()
|
||||
private expanded: boolean
|
||||
public selectionChange = new EventDispatcher<void, TopicViewModel>()
|
||||
public expandedChange = new EventDispatcher<void, TopicViewModel>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SettingsStateModel>
|
||||
|
||||
export type Actions = SetAutoExpandLimitAction &
|
||||
DidLoadSettingsAction &
|
||||
SetTopicOrderAction &
|
||||
@@ -44,7 +45,7 @@ export enum ActionTypes {
|
||||
SETTINGS_SET_TIME_LOCALE = 'SETTINGS_SET_TIME_LOCALE',
|
||||
}
|
||||
|
||||
const initialState = Record<SettingsState>({
|
||||
const initialState = Record<SettingsStateModel>({
|
||||
timeLocale: window.navigator.language,
|
||||
autoExpandLimit: 0,
|
||||
topicOrder: TopicOrder.none,
|
||||
@@ -55,12 +56,12 @@ const initialState = Record<SettingsState>({
|
||||
topicFilter: undefined,
|
||||
})
|
||||
|
||||
const setTheme = (theme: 'light' | 'dark') => (state: Record<SettingsState>) => {
|
||||
const setTheme = (theme: 'light' | 'dark') => (state: SettingsState) => {
|
||||
return state.set('theme', theme)
|
||||
}
|
||||
|
||||
const reducerActions: {
|
||||
[s: string]: (state: Record<SettingsState>, action: Actions) => Record<SettingsState>
|
||||
[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<SettingsState>
|
||||
settings: Partial<SettingsStateModel>
|
||||
}
|
||||
|
||||
function didLoadSettings(state: Record<SettingsState>, 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<SettingsState>, 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<SettingsState>, action: SetTimeLocale): Record<SettingsState> {
|
||||
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<SettingsState>, 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<SettingsState>, 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<SettingsState>, 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<SettingsState>, 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<SettingsState>, action: FilterTopicsAction) {
|
||||
function filterTopics(state: SettingsState, action: FilterTopicsAction) {
|
||||
return state.set('topicFilter', action.topicFilter)
|
||||
}
|
||||
|
||||
@@ -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<SettingsState>
|
||||
settings: SettingsState
|
||||
publish: PublishState
|
||||
charts: ChartsState
|
||||
sidebar: SidebarState
|
||||
|
||||
19
app/src/sortedNodes.tsx
Normal file
19
app/src/sortedNodes.tsx
Normal file
@@ -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<any>): Array<q.TreeNode<TopicViewModel>> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user